From fa936ba716f188dcefa8f19c2962c5a91e5788ca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 00:19:35 +0400 Subject: [PATCH] =?UTF-8?q?test(agent):=20=D1=84=D0=B5=D0=B9=D0=BA=D0=B8?= =?UTF-8?q?=20=E2=80=94=20project=20=D0=B8=20created=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=D0=B5=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8F?= =?UTF-8?q?,=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8=D0=B9=20claim=20=D1=85=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=20(M-10=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/fake_hub.py | 12 +++++++++++- tests/fake_jira.py | 20 ++++++++++++++++++-- tests/test_executor_fields.py | 15 +++++++++++++++ tests/test_runner.py | 23 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/tests/fake_hub.py b/tests/fake_hub.py index 14d6297..fe2c263 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -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): diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 90df453..9ce8590 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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": diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py index aa0e949..7aa71f1 100644 --- a/tests/test_executor_fields.py +++ b/tests/test_executor_fields.py @@ -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") diff --git a/tests/test_runner.py b/tests/test_runner.py index 92fd89e..85478a8 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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)