test(agent): фейки — project и created в поиске дубля, долгий claim хаба (M-10 #4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bf2ff9179
commit
fa936ba716
@ -2,6 +2,7 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ class FakeHub:
|
|||||||
self.in_flight = None
|
self.in_flight = None
|
||||||
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
||||||
self.release_requests = 0
|
self.release_requests = 0
|
||||||
|
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
hub = self
|
hub = self
|
||||||
|
|
||||||
@ -61,8 +63,16 @@ class FakeHub:
|
|||||||
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
|
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
|
||||||
if h.headers.get("Authorization") != "Bearer " + self.token:
|
if h.headers.get("Authorization") != "Bearer " + self.token:
|
||||||
return self._send(h, 401, {"error": "unauthorized"})
|
return self._send(h, 401, {"error": "unauthorized"})
|
||||||
|
path = h.path.split("?")[0]
|
||||||
|
if path == "/agent/claim" and self.claim_wait:
|
||||||
|
end = time.monotonic() + self.claim_wait
|
||||||
|
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
|
||||||
|
with self.lock:
|
||||||
|
if self.queue:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
code, payload = self.route(method, h.path.split("?")[0], body)
|
code, payload = self.route(method, path, body)
|
||||||
self._send(h, code, payload)
|
self._send(h, code, payload)
|
||||||
|
|
||||||
def _send(self, h, code, payload):
|
def _send(self, h, code, payload):
|
||||||
|
|||||||
@ -4,9 +4,12 @@ import json
|
|||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
MSK = timezone(timedelta(hours=3))
|
||||||
|
|
||||||
WORKFLOW = { # статус → [(имя перехода, целевой статус)]
|
WORKFLOW = { # статус → [(имя перехода, целевой статус)]
|
||||||
"Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")],
|
"Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")],
|
||||||
"Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")],
|
"Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")],
|
||||||
@ -24,6 +27,7 @@ CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "custom
|
|||||||
class FakeJira:
|
class FakeJira:
|
||||||
def __init__(self, project="AURORA"):
|
def __init__(self, project="AURORA"):
|
||||||
self.project = project
|
self.project = project
|
||||||
|
self.now = datetime.now(MSK) # часы фейка: окно created >= -Nd считается от них
|
||||||
self.issues = {}
|
self.issues = {}
|
||||||
self.comments = {}
|
self.comments = {}
|
||||||
self.links = []
|
self.links = []
|
||||||
@ -64,10 +68,14 @@ class FakeJira:
|
|||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
self.server.server_close()
|
self.server.server_close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ts(dt):
|
||||||
|
return dt.astimezone(MSK).strftime("%Y-%m-%dT%H:%M:%S.000+0300")
|
||||||
|
|
||||||
def add(self, key, in_base=True, **fields):
|
def add(self, key, in_base=True, **fields):
|
||||||
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
||||||
"assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
|
"assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
|
||||||
"issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300",
|
"issuetype": "Задача", "created": self.ts(self.now - timedelta(hours=1)), "updated": "2026-10-01T10:00:00.000+0300",
|
||||||
"sprint": None}
|
"sprint": None}
|
||||||
self.id_seq += 1
|
self.id_seq += 1
|
||||||
base["id"] = str(self.id_seq)
|
base["id"] = str(self.id_seq)
|
||||||
@ -108,6 +116,14 @@ class FakeJira:
|
|||||||
return [k for k in keys if k in self.issues]
|
return [k for k in keys if k in self.issues]
|
||||||
if "reporter = currentUser()" in (jql or ""):
|
if "reporter = currentUser()" in (jql or ""):
|
||||||
keys = [k for k, f in self.issues.items() if f["reporter"] == "me"]
|
keys = [k for k, f in self.issues.items() if f["reporter"] == "me"]
|
||||||
|
m = re.search(r"project = (\w+)", jql)
|
||||||
|
if m: # как Jira: только задачи проекта
|
||||||
|
keys = [k for k in keys if k.startswith(m.group(1) + "-")]
|
||||||
|
m = re.search(r"created >= -(\d+)d", jql)
|
||||||
|
if m: # как Jira: только созданные за последние N дней
|
||||||
|
since = self.now - timedelta(days=int(m.group(1)))
|
||||||
|
keys = [k for k in keys
|
||||||
|
if datetime.strptime(self.issues[k]["created"], "%Y-%m-%dT%H:%M:%S.%f%z") >= since]
|
||||||
return keys[::-1] if "ORDER BY created DESC" in jql else keys
|
return keys[::-1] if "ORDER BY created DESC" in jql else keys
|
||||||
return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]]
|
return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]]
|
||||||
|
|
||||||
@ -165,7 +181,7 @@ class FakeJira:
|
|||||||
self.add(key, summary=f.get("summary"), description=f.get("description"),
|
self.add(key, summary=f.get("summary"), description=f.get("description"),
|
||||||
priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"),
|
priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"),
|
||||||
labels=list(f.get("labels") or []), issuetype=self._type_name(f.get("issuetype")),
|
labels=list(f.get("labels") or []), issuetype=self._type_name(f.get("issuetype")),
|
||||||
reporter="me", created="2026-10-11T12:00:00.000+0300",
|
reporter="me", created=self.ts(self.now),
|
||||||
**{c: f.get(c) for c in CUSTOM})
|
**{c: f.get(c) for c in CUSTOM})
|
||||||
return 201, {"id": "1", "key": key}
|
return 201, {"id": "1", "key": key}
|
||||||
if p == "/rest/api/2/issueLinkType":
|
if p == "/rest/api/2/issueLinkType":
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||||
from fake_jira import FakeJira
|
from fake_jira import FakeJira
|
||||||
@ -146,6 +147,20 @@ class ExecutorFieldsTest(unittest.TestCase):
|
|||||||
out = self.run_cmd("ensure_issue", temp_key="n:2", issue_type="task", summary="Точная тема")
|
out = self.run_cmd("ensure_issue", temp_key="n:2", issue_type="task", summary="Точная тема")
|
||||||
self.assertEqual(out["result"], {"key": "AURORA-501", "existed": True})
|
self.assertEqual(out["result"], {"key": "AURORA-501", "existed": True})
|
||||||
|
|
||||||
|
def test_ensure_issue_old_or_foreign_is_not_duplicate(self):
|
||||||
|
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача",
|
||||||
|
created=self.fake.ts(self.fake.now - timedelta(days=8))) # за окном created >= -7d
|
||||||
|
self.fake.add("OTHER-1", reporter="me", summary="Нужная задача") # чужой проект
|
||||||
|
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
|
||||||
|
self.assertTrue(out["result"]["created"])
|
||||||
|
self.assertNotIn(out["result"]["key"], ("AURORA-500", "OTHER-1"))
|
||||||
|
|
||||||
|
def test_ensure_issue_within_window_is_duplicate(self):
|
||||||
|
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача",
|
||||||
|
created=self.fake.ts(self.fake.now - timedelta(days=6)))
|
||||||
|
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
|
||||||
|
self.assertEqual(out["result"], {"key": "AURORA-500", "existed": True})
|
||||||
|
|
||||||
def test_http_error_becomes_failed(self):
|
def test_http_error_becomes_failed(self):
|
||||||
self.fake.fail_next.append((400, {}))
|
self.fake.fail_next.append((400, {}))
|
||||||
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="x")
|
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="x")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@ -224,6 +225,28 @@ class RunnerTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.hub.results[2]["status"], "done")
|
self.assertEqual(self.hub.results[2]["status"], "done")
|
||||||
self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])
|
self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])
|
||||||
|
|
||||||
|
def test_stop_during_long_claim(self):
|
||||||
|
self.hub.claim_wait = 1.0 # хаб держит claim при пустой очереди (как claim_wait = 30)
|
||||||
|
self.runner.idle_limit = None
|
||||||
|
threading.Timer(0.3, lambda: setattr(self.runner, "stop", True)).start()
|
||||||
|
start = time.monotonic()
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertGreaterEqual(time.monotonic() - start, 0.9) # дождался ответа claim
|
||||||
|
self.assertEqual(self.hub.claims, 1)
|
||||||
|
self.assertEqual(self.journal.records(), [])
|
||||||
|
|
||||||
|
def test_command_arrives_during_long_claim(self):
|
||||||
|
self.hub.claim_wait = 5.0
|
||||||
|
|
||||||
|
def arrive():
|
||||||
|
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||||||
|
self.hub.claim_wait = 0 # следующий claim (после отчёта) — без ожидания
|
||||||
|
threading.Timer(0.3, arrive).start()
|
||||||
|
start = time.monotonic()
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertLess(time.monotonic() - start, 4) # ответ пришёл с командой, а не по таймауту
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "done")
|
||||||
|
|
||||||
def test_stop_during_pause(self):
|
def test_stop_during_pause(self):
|
||||||
self.runner.jira.last_call_at = time.monotonic()
|
self.runner.jira.last_call_at = time.monotonic()
|
||||||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5)
|
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user