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 re
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -22,6 +23,7 @@ class FakeHub:
self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
self.lock = threading.Lock()
hub = self
@ -61,8 +63,16 @@ class FakeHub:
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
if h.headers.get("Authorization") != "Bearer " + self.token:
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:
code, payload = self.route(method, h.path.split("?")[0], body)
code, payload = self.route(method, path, body)
self._send(h, code, payload)
def _send(self, h, code, payload):

View File

@ -4,9 +4,12 @@ import json
import re
import socket
import threading
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
MSK = timezone(timedelta(hours=3))
WORKFLOW = { # статус → [(имя перехода, целевой статус)]
"Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")],
"Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")],
@ -24,6 +27,7 @@ CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "custom
class FakeJira:
def __init__(self, project="AURORA"):
self.project = project
self.now = datetime.now(MSK) # часы фейка: окно created >= -Nd считается от них
self.issues = {}
self.comments = {}
self.links = []
@ -64,10 +68,14 @@ class FakeJira:
self.server.shutdown()
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):
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
"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}
self.id_seq += 1
base["id"] = str(self.id_seq)
@ -108,6 +116,14 @@ class FakeJira:
return [k for k in keys if k in self.issues]
if "reporter = currentUser()" in (jql or ""):
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 [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"),
priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"),
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})
return 201, {"id": "1", "key": key}
if p == "/rest/api/2/issueLinkType":

View File

@ -1,4 +1,5 @@
import unittest
from datetime import timedelta
from _common import SETTINGS, agent, jira_cfg, no_sleep
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="Точная тема")
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):
self.fake.fail_next.append((400, {}))
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="x")

View File

@ -1,5 +1,6 @@
import os
import tempfile
import threading
import time
import unittest
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.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):
self.runner.jira.last_call_at = time.monotonic()
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5)