test(agent): фейки — project и created в поиске дубля, долгий claim хаба (M-10 #4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 00:19:35 +04:00
parent 1bf2ff9179
commit fa936ba716
4 changed files with 67 additions and 3 deletions

View File

@ -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):

View File

@ -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":

View File

@ -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")

View File

@ -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)