660 lines
39 KiB
Python
660 lines
39 KiB
Python
import unittest
|
||
|
||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||
from fake_hub import FakeHub
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]},
|
||
{"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]},
|
||
{"name": "Готово", "statuses": ["Готово", "Снято"]}]
|
||
|
||
|
||
class RefreshTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.hub = FakeHub(SETTINGS)
|
||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||
self.jira = agent.JiraClient(cfg)
|
||
self.jira.sleep = no_sleep
|
||
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
|
||
self.ex.sleep = no_sleep
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
self.hub.stop()
|
||
|
||
def test_to_state_and_links(self):
|
||
self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300",
|
||
customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2")
|
||
self.fake.add("AURORA-2")
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"})
|
||
raw = self.fake.issue_json("AURORA-1")
|
||
st = self.ex.to_state(raw)
|
||
self.assertEqual(st["key"], "AURORA-1")
|
||
self.assertEqual(st["assignee"], "ivanov")
|
||
self.assertEqual(st["planned_start"], "2026-10-20")
|
||
self.assertEqual(st["epic_key"], "AURORA-1350")
|
||
self.assertEqual(st["labels"], ["AWPCA", "kind-dev"])
|
||
self.assertEqual(st["issue_type"], "Задача")
|
||
self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300")
|
||
self.assertIsNone(st["sprint"]) # спринт заполняет do_refresh
|
||
self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
|
||
self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")),
|
||
[{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
|
||
self.assertIn("customfield_15302", self.ex.state_fields())
|
||
|
||
def test_full_refresh_with_neighbors_and_sprints(self):
|
||
for i in range(1, 4):
|
||
self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None)
|
||
self.fake.add("OTHER-9", in_base=False)
|
||
self.fake.add("AURORA-7", in_base=False)
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"})
|
||
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"})
|
||
self.hub.known = ["AURORA-7"]
|
||
out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1})
|
||
state = self.hub.states[0]
|
||
self.assertEqual(state["command_id"], 5)
|
||
self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"])
|
||
self.assertEqual(state["neighbor_keys"], ["OTHER-9"])
|
||
self.assertTrue(state["last_page"])
|
||
sprints = {i["key"]: i["sprint"] for i in state["issues"]}
|
||
self.assertEqual(sprints["AURORA-1"], "Спринт 2")
|
||
self.assertIsNone(sprints["AURORA-3"])
|
||
links = {(l["source_key"], l["target_key"]) for l in state["links"]}
|
||
self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")})
|
||
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||
self.assertEqual(len(searches), 2) # базовая выборка + дочитывание соседей
|
||
|
||
def test_refresh_by_keys_pages(self):
|
||
for i in range(1, 121):
|
||
self.fake.add("AURORA-%d" % i)
|
||
keys = ["AURORA-%d" % i for i in range(1, 121)]
|
||
out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}})
|
||
self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2})
|
||
self.assertEqual(len(self.hub.states), 2)
|
||
self.assertEqual(self.hub.states[0]["page"], 1)
|
||
self.assertFalse(self.hub.states[0]["last_page"])
|
||
self.assertTrue(self.hub.states[1]["last_page"])
|
||
self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120)
|
||
|
||
def test_refresh_empty_keys(self):
|
||
out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}})
|
||
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
|
||
self.assertEqual(self.fake.requests, [])
|
||
self.assertEqual(self.hub.states, [])
|
||
|
||
def test_comments_in_state(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add("AURORA-2")
|
||
self.fake.add_comment("AURORA-1", "первый\nвторая строка", name="petrov", display="Петров Пётр",
|
||
created="2026-10-14T10:00:00.000+0300")
|
||
self.fake.add_comment("AURORA-1", "второй", name="ivanov", display=None, created="2026-10-14T11:00:00.000+0300",
|
||
updated="2026-10-14T12:00:00.000+0300")
|
||
self.fake.add_comment("AURORA-1", None, name=None, display=None) # автор удалён, текста нет
|
||
out = self.ex.run({"id": 8, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||
self.assertEqual(out["status"], "done")
|
||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||
c = by_key["AURORA-1"]["comments"]
|
||
self.assertEqual([x["author"] for x in c], ["Петров Пётр", "ivanov", None])
|
||
self.assertEqual(c[0], {"id": c[0]["id"], "author": "Петров Пётр", "created": "2026-10-14T10:00:00.000+0300",
|
||
"updated": "2026-10-14T10:00:00.000+0300", "body": "первый\nвторая строка"})
|
||
self.assertIsInstance(c[0]["id"], str)
|
||
self.assertEqual(c[1]["updated"], "2026-10-14T12:00:00.000+0300")
|
||
self.assertEqual(c[2]["body"], "")
|
||
self.assertEqual(by_key["AURORA-2"]["comments"], [])
|
||
self.assertIn("comment", self.ex.state_fields().split(","))
|
||
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||
self.assertEqual(len(searches), 1) # комментарии пришли тем же search
|
||
self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests))
|
||
|
||
def test_refresh_attachments_in_state(self): # сценарий «Вложения в состоянии»
|
||
self.fake.add("AURORA-1650")
|
||
self.fake.add_attachment("AURORA-1650", "скрин 1.png", b"x" * 240000, mime="image/png")
|
||
self.fake.add_attachment("AURORA-1650", "ОСВ 60.xlsx", b"y" * 10, name="ivanov", display=None)
|
||
out = self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1650"]}})
|
||
self.assertEqual(out["status"], "done")
|
||
st = self.hub.states[0]["issues"][0]
|
||
a = st["attachments"]
|
||
self.assertEqual([x["filename"] for x in a], ["скрин 1.png", "ОСВ 60.xlsx"])
|
||
self.assertEqual(a[0], {"id": a[0]["id"], "filename": "скрин 1.png", "size": 240000, "mime_type": "image/png",
|
||
"author": "Потапова Анна", "created": "2026-10-20T09:15:00.000+0300"})
|
||
self.assertIsInstance(a[0]["id"], str)
|
||
self.assertEqual(a[1]["author"], "ivanov")
|
||
self.assertIn("attachment", self.ex.state_fields().split(","))
|
||
self.assertEqual(sum(1 for r in self.fake.requests if r[1].endswith("/search")), 1)
|
||
self.assertFalse(any("/secure/attachment/" in r[1] for r in self.fake.requests))
|
||
|
||
def test_refresh_attachment_without_id_dropped(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add_attachment("AURORA-1", "a.txt", b"a")
|
||
self.fake.add_attachment("AURORA-1", "b.txt", b"b")
|
||
self.fake.attachments["AURORA-1"][0]["id"] = None
|
||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
a = self.hub.states[0]["issues"][0]["attachments"]
|
||
self.assertEqual([x["filename"] for x in a], ["b.txt"])
|
||
|
||
def test_refresh_no_attachments_empty_list(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add("AURORA-2")
|
||
self.fake.add_attachment("AURORA-2", "a.txt", b"a")
|
||
self.fake.attachments["AURORA-2"][0]["size"] = None
|
||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||
self.assertEqual(by_key["AURORA-1"]["attachments"], [])
|
||
self.assertIsNone(by_key["AURORA-2"]["attachments"][0]["size"])
|
||
self.fake.hide_attachment_field = True
|
||
self.ex.run({"id": 6, "type": "refresh", "params": {"keys": ["AURORA-2"]}})
|
||
self.assertEqual(self.hub.states[-1]["issues"][0]["attachments"], [])
|
||
|
||
def test_comments_partial_list_is_read_fully(self):
|
||
self.fake.add("AURORA-1")
|
||
for n in range(130):
|
||
self.fake.add_comment("AURORA-1", "к%d" % n)
|
||
self.fake.search_comment_cap = 20
|
||
out = self.ex.run({"id": 9, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
self.assertEqual(out["status"], "done")
|
||
c = self.hub.states[0]["issues"][0]["comments"]
|
||
self.assertEqual(len(c), 130)
|
||
self.assertEqual([x["body"] for x in c[:2]] + [c[-1]["body"]], ["к0", "к1", "к129"])
|
||
reads = [r for r in self.fake.requests if r == ("GET", "/rest/api/2/issue/AURORA-1/comment")]
|
||
self.assertEqual(len(reads), 2) # 100 + 30
|
||
|
||
def test_comments_field_missing(self):
|
||
raw = {"key": "AURORA-1", "fields": {}} # поле comment недоступно
|
||
self.assertEqual(self.ex.comments_of(raw), [])
|
||
raw = {"key": "AURORA-1", "fields": {"comment": {"comments": [{"id": "5", "body": "x"}]}}} # total нет
|
||
self.assertEqual(self.ex.comments_of(raw),
|
||
[{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}])
|
||
self.assertEqual(self.fake.requests, []) # без дочитывания
|
||
|
||
def test_comment_without_id_dropped(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add_comment("AURORA-1", "нормальный")
|
||
self.fake.add_comment("AURORA-1", "второй")["id"] = None
|
||
self.fake.add_comment("AURORA-1", "третий")["id"] = ""
|
||
logs = []
|
||
orig = agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
out = self.ex.run({"id": 10, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
finally:
|
||
agent.log = orig
|
||
self.assertEqual(out["status"], "done")
|
||
c = self.hub.states[0]["issues"][0]["comments"]
|
||
self.assertEqual([x["body"] for x in c], ["нормальный"])
|
||
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "комментарий без id" in l]), 2, logs)
|
||
|
||
def test_comments_read_denied_keeps_refresh(self):
|
||
for key in ("AURORA-1", "AURORA-2", "AURORA-3"):
|
||
self.fake.add(key)
|
||
for n in range(3):
|
||
self.fake.add_comment(key, "%s-%d" % (key, n))
|
||
self.fake.search_comment_cap = 1 # search отдаёт 1 из 3 — нужно дочитывание
|
||
self.fake.comment_denied = {"AURORA-1": 403, "AURORA-2": 404}
|
||
logs = []
|
||
orig = agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-3"]}})
|
||
finally:
|
||
agent.log = orig
|
||
self.assertEqual(out["status"], "done")
|
||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||
self.assertNotIn("comments", by_key["AURORA-1"])
|
||
self.assertNotIn("comments", by_key["AURORA-2"])
|
||
self.assertEqual([x["body"] for x in by_key["AURORA-3"]["comments"]], ["AURORA-3-0", "AURORA-3-1", "AURORA-3-2"])
|
||
self.assertTrue(any("AURORA-1" in l and "HTTP 403" in l for l in logs), logs)
|
||
self.assertTrue(any("AURORA-2" in l and "HTTP 404" in l for l in logs), logs)
|
||
|
||
def test_comments_read_5xx_still_fails_refresh(self):
|
||
self.fake.add("AURORA-1")
|
||
for n in range(3):
|
||
self.fake.add_comment("AURORA-1", "к%d" % n)
|
||
self.fake.search_comment_cap = 1
|
||
self.fake.comment_denied = {"AURORA-1": 500}
|
||
out = self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["retryable"]) # 5xx — по-прежнему повтор через проверку (R-10)
|
||
self.assertEqual(self.hub.states, [])
|
||
|
||
def test_refresh_empty_jql_result(self):
|
||
out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
|
||
self.assertEqual(self.hub.states, [])
|
||
|
||
def test_keys_with_deleted_key(self):
|
||
for i in (1, 2, 3, 4):
|
||
self.fake.add("AURORA-%d" % i)
|
||
out = self.ex.run({"id": 9, "type": "refresh",
|
||
"params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}})
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-99", "AURORA-98"]})
|
||
self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"])
|
||
|
||
def test_all_keys_400_fails(self):
|
||
out = self.ex.run({"id": 14, "type": "refresh", "params": {"keys": ["X-1", "X-2", "X-3"]}})
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("HTTP 400", out["error"])
|
||
self.assertEqual(self.hub.states, [])
|
||
|
||
def test_bisect_cost_one_missing_of_100(self):
|
||
for i in range(1, 100):
|
||
self.fake.add("AURORA-%d" % i)
|
||
keys = ["AURORA-%d" % i for i in range(1, 101)] # AURORA-100 отсутствует
|
||
out = self.ex.run({"id": 15, "type": "refresh", "params": {"keys": keys}})
|
||
self.assertEqual(out["result"]["missing"], ["AURORA-100"])
|
||
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||
self.assertLessEqual(len(searches), 2 * 7 + 1)
|
||
|
||
def test_missing_deduped_and_truncated(self):
|
||
self.fake.add("AURORA-1")
|
||
keys = ["AURORA-1"] + ["GONE-%d" % i for i in range(1, 61)] + ["GONE-1"]
|
||
out = self.ex.run({"id": 16, "type": "refresh", "params": {"keys": keys}})
|
||
self.assertEqual(out["result"]["missing"], ["GONE-%d" % i for i in range(1, 51)])
|
||
|
||
def test_missing_not_duplicated_when_also_a_neighbor(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"})
|
||
out = self.ex.run({"id": 17, "type": "refresh", "params": {"keys": ["AURORA-1", "GONE-1"]}})
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"]["missing"], ["GONE-1"])
|
||
|
||
def test_input_keys_deduplicated(self):
|
||
self.fake.add("AURORA-1")
|
||
out = self.ex.run({"id": 18, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-1", "", "AURORA-1"]}})
|
||
self.assertEqual(out["result"], {"issues": 1, "neighbors": 0, "pages": 1})
|
||
self.assertEqual(len(self.hub.states[0]["issues"]), 1)
|
||
self.assertEqual(self.hub.states[0]["agent_version"], agent.AGENT_VERSION)
|
||
|
||
def test_deleted_neighbor(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add("AURORA-2", in_base=False)
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"})
|
||
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
|
||
out = self.ex.run({"id": 10, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"issues": 1, "neighbors": 1, "pages": 1, "missing": ["GONE-1"]})
|
||
self.assertEqual(self.hub.states[0]["neighbor_keys"], ["AURORA-2"])
|
||
|
||
def test_non_400_error_fails(self):
|
||
self.fake.add("AURORA-1")
|
||
|
||
def boom(jql):
|
||
raise agent.HttpError(500, "oops")
|
||
self.ex.search_all = boom
|
||
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("500", out["error"])
|
||
|
||
def test_link_visible_from_both_ends_deduped(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.add("AURORA-2")
|
||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
|
||
self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||
self.assertEqual(len(self.hub.states[0]["links"]), 1)
|
||
|
||
def test_many_neighbors_several_requests(self):
|
||
self.fake.add("AURORA-1")
|
||
for i in range(1, 151):
|
||
self.fake.add("NB-%d" % i, in_base=False)
|
||
self.fake.links.append({"id": i, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "NB-%d" % i})
|
||
out = self.ex.run({"id": 13, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||
self.assertEqual(out["result"], {"issues": 1, "neighbors": 150, "pages": 2})
|
||
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||
self.assertEqual(len(searches), 1 + 2) # база + два чанка соседей
|
||
|
||
def test_sprint_names_paginated(self):
|
||
for i in range(1, 121):
|
||
self.fake.add("AURORA-%d" % i, sprint="Спринт 2")
|
||
names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216])
|
||
self.assertEqual(len(names), 100)
|
||
pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"]
|
||
self.assertEqual(len(pages), 2)
|
||
|
||
def test_fake_hub_terminal_result_not_overwritten(self):
|
||
cid = self.hub.enqueue("refresh", {"keys": []})
|
||
hub = agent.HubClient(jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token))
|
||
hub.result(cid, {"status": "done", "result": {"a": 1}})
|
||
hub.result(cid, {"status": "failed", "error": "x"})
|
||
self.assertEqual(self.hub.commands[cid]["status"], "done")
|
||
self.assertEqual(self.hub.results[cid]["status"], "done")
|
||
|
||
def test_hub_unreachable_in_refresh_names_hub(self):
|
||
self.fake.add("AURORA-1")
|
||
self.ex.hub = agent.HubClient({"hub_url": "http://127.0.0.1:1", "hub_token": "t", "timeout": 2})
|
||
out = self.ex.run({"id": 1, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
|
||
self.assertTrue(out["retryable"])
|
||
self.assertEqual(out["peer"], "хабом")
|
||
|
||
def test_hub_4xx_in_refresh_not_retryable(self):
|
||
self.fake.add("AURORA-1")
|
||
self.ex.hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": "wrong-token", "timeout": 5})
|
||
out = self.ex.run({"id": 1, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertTrue(out["error"].startswith("хаб: HTTP 401"), out["error"])
|
||
self.assertFalse(out["retryable"])
|
||
|
||
def test_jira_error_has_no_peer(self):
|
||
self.ex.jira_fields()
|
||
self.fake.fail_next.append((500, {}))
|
||
out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}})
|
||
self.assertTrue(out["retryable"])
|
||
self.assertNotIn("peer", out)
|
||
|
||
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
|
||
|
||
def refresh(self, **params):
|
||
return self.ex.run({"id": 5, "type": "refresh", "params": params})
|
||
|
||
def logged(self, fn):
|
||
logs, orig = [], agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
return fn(), logs
|
||
finally:
|
||
agent.log = orig
|
||
|
||
def test_state_rank_points_sprint_ids(self): # сценарий «Доска с закрытыми спринтами», часть задач
|
||
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": s}
|
||
for i, s in ((1, "closed"), (2, "closed"), (3, "active"), (4, "future"))]
|
||
self.fake.add("AURORA-1", sprint="S3", sprint_ids=[2, 3], customfield_10105="0|i0001:", customfield_10106=3.0)
|
||
self.fake.add("AURORA-2")
|
||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||
self.assertEqual((by["AURORA-1"]["rank"], by["AURORA-1"]["story_points"], by["AURORA-1"]["sprint_ids"]),
|
||
("0|i0001:", 3.0, [2, 3]))
|
||
self.assertEqual((by["AURORA-2"]["rank"], by["AURORA-2"]["story_points"], by["AURORA-2"]["sprint_ids"]), (None, None, []))
|
||
fields = [q["fields"].split(",") for q in self.fake.queries if "jql" in q and "fields" in q][0]
|
||
for f in ("customfield_10104", "customfield_10105", "customfield_10106"):
|
||
self.assertIn(f, fields)
|
||
|
||
def test_sprint_field_server_strings(self):
|
||
self.fake.add("AURORA-1", sprint_raw=[
|
||
"com.atlassian.greenhopper.service.sprint.Sprint@1a[id=72,rapidViewId=4216,state=CLOSED,name=A,sequence=72]",
|
||
"com.atlassian.greenhopper.service.sprint.Sprint@1b[id=73,rapidViewId=4216,state=ACTIVE,name=B,sequence=73]",
|
||
"мусор", {"id": "74"}, {"name": "без id"}])
|
||
out, logs = self.logged(lambda: self.refresh(keys=["AURORA-1"]))
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint_ids"], [72, 73, 74])
|
||
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "не разобран" in l]), 2, logs)
|
||
|
||
def test_story_points_non_number_is_null(self):
|
||
for key, v in (("AURORA-1", "3"), ("AURORA-2", True), ("AURORA-3", 5)):
|
||
self.fake.add(key, customfield_10106=v)
|
||
self.refresh(keys=["AURORA-1", "AURORA-2", "AURORA-3"])
|
||
self.assertEqual([i["story_points"] for i in self.hub.states[0]["issues"]], [None, None, 5])
|
||
|
||
def test_missing_fields_not_sent_logged_once(self): # сценарий «Нет поля оценки», сторона refresh
|
||
self.fake.fields = []
|
||
for i in range(1, 121):
|
||
self.fake.add("AURORA-%d" % i, customfield_10106=2)
|
||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL))
|
||
self.assertEqual(out["result"]["pages"], 2)
|
||
for st in self.hub.states[0]["issues"] + self.hub.states[1]["issues"]:
|
||
self.assertFalse({"rank", "story_points", "sprint_ids"} & set(st), st)
|
||
for name in ("rank", "sprint_ids", "story_points"):
|
||
self.assertEqual(len([l for l in logs if "не найдено" in l and name in l]), 1, logs)
|
||
|
||
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
|
||
self.fake.boards[5120] = "kanban"
|
||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}])
|
||
self.assertEqual(out["status"], "done", out)
|
||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||
paths = [r[1] for r in self.fake.requests]
|
||
self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1)
|
||
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths)
|
||
self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||
|
||
def test_board_not_found(self): # сценарий «Доска не найдена»
|
||
self.fake.add("AURORA-1")
|
||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}]))
|
||
self.assertEqual(out["status"], "done", out)
|
||
self.assertEqual(out["result"]["boards"], {"9999": None})
|
||
self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs)
|
||
self.assertEqual(len(self.hub.states), 1)
|
||
|
||
def test_new_scrum_board_sprints_read(self):
|
||
self.fake.boards[5120] = "scrum"
|
||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||
self.fake.add("AURORA-1", sprint="Спринт K")
|
||
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])
|
||
self.assertEqual(out["result"]["boards"], {"5120": "scrum"})
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K")
|
||
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
|
||
|
||
def test_old_hub_without_boards(self): # сценарий «Старый хаб»
|
||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||
out = self.refresh(jql=self.JQL)
|
||
self.assertNotIn("boards", out["result"])
|
||
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||
self.assertNotIn("boards", self.hub.states[0])
|
||
self.assertFalse(any(r[1].endswith("/configuration") for r in self.fake.requests))
|
||
|
||
def test_known_boards_no_type_requests(self):
|
||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||
self.assertNotIn("boards", out["result"])
|
||
self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests])
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||
|
||
def test_empty_boards_no_settings_fallback(self):
|
||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||
out = self.refresh(jql=self.JQL, boards=[])
|
||
self.assertEqual(out["status"], "done", out)
|
||
self.assertNotIn("boards", out["result"])
|
||
self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests))
|
||
self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"])
|
||
|
||
def test_board_type_5xx_logged_refresh_continues(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски
|
||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]))
|
||
self.assertEqual(out["status"], "done", out)
|
||
self.assertEqual(out["result"]["boards"], {"5120": None})
|
||
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
|
||
self.assertEqual(len(self.hub.states), 1) # M-4: состояние ушло в хаб
|
||
|
||
def test_board_other_type_is_null(self):
|
||
self.fake.boards[7000] = "simple"
|
||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}]))
|
||
self.assertEqual(out["result"]["boards"], {"7000": None})
|
||
self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs)
|
||
|
||
def test_duplicate_null_board_requested_once(self):
|
||
self.fake.boards[5120] = "kanban"
|
||
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}])
|
||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||
self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1)
|
||
|
||
def test_empty_keys_still_resolves_boards(self):
|
||
self.fake.boards[5120] = "kanban"
|
||
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
|
||
self.assertEqual(out["status"], "done")
|
||
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 1, "boards": {"5120": "kanban"}})
|
||
self.assertEqual(self.hub.states, [{"command_id": 5, "agent_version": agent.AGENT_VERSION, "issues": [], "links": [],
|
||
"neighbor_keys": [], "page": 1, "last_page": True,
|
||
"boards": [{"id": 5120, "type": "kanban", "columns": COLS,
|
||
"kanban_backlog": True, "sprints": []}]}])
|
||
|
||
def test_last_found_sprint_wins_across_boards(self):
|
||
self.fake.boards[5120] = "scrum"
|
||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||
self.fake.add("AURORA-1")
|
||
self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]}
|
||
both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}]
|
||
self.refresh(jql=self.JQL, boards=both)
|
||
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K")
|
||
self.refresh(jql=self.JQL, boards=both[::-1])
|
||
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
|
||
|
||
def test_scrum_board_turned_kanban_self_heals(self): # I-1, R-36
|
||
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
|
||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}])
|
||
self.assertEqual(out["status"], "done", out)
|
||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||
|
||
def test_scrum_board_sprints_error_fails_refresh(self): # R-32, I-1: ошибка называет доску
|
||
self.fake.add("AURORA-1")
|
||
out = self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": "scrum"}]) # доски нет в Jira
|
||
self.assertEqual(out["status"], "failed")
|
||
self.assertIn("HTTP 404", out["error"])
|
||
self.assertIn("доска 9999", out["error"])
|
||
self.assertFalse(out["retryable"])
|
||
self.assertEqual(self.hub.states, [])
|
||
|
||
def test_non_dict_board_item_skipped(self): # M-2
|
||
self.fake.boards[5120] = "kanban"
|
||
out = self.refresh(keys=[], boards=[4216, None, {"id": 5120, "type": None}])
|
||
self.assertEqual(out["status"], "done", out)
|
||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||
|
||
def test_board_with_closed_sprints(self): # сценарий «Доска с закрытыми спринтами»
|
||
sp = lambda i, s, **kw: dict({"id": i, "name": "S%d" % i, "state": s}, **kw)
|
||
self.fake.board_sprints[4216] = [
|
||
sp(1, "closed", startDate="2026-09-01T09:00:00.000+03:00", endDate="2026-09-14T18:00:00.000+03:00",
|
||
completeDate="2026-09-15T10:00:00.000+03:00", goal="цель 1"),
|
||
sp(2, "closed"), sp(3, "active"), sp(4, "future")]
|
||
for i in range(1, 121):
|
||
self.fake.add("AURORA-%d" % i, sprint="S3")
|
||
self.fake.issues["AURORA-1"]["sprint_ids"] = [2, 3]
|
||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||
self.assertEqual(out["status"], "done", out)
|
||
b = self.hub.states[0]["boards"]
|
||
self.assertEqual([(x["id"], x["type"], x["columns"], x["kanban_backlog"]) for x in b], [(4216, "scrum", COLS, None)])
|
||
self.assertEqual([s["id"] for s in b[0]["sprints"]], [1, 2, 3, 4])
|
||
self.assertEqual(b[0]["sprints"][0], {"id": 1, "name": "S1", "state": "closed",
|
||
"start_date": "2026-09-01T09:00:00.000+03:00", "end_date": "2026-09-14T18:00:00.000+03:00",
|
||
"complete_date": "2026-09-15T10:00:00.000+03:00", "goal": "цель 1"})
|
||
self.assertNotIn("boards", self.hub.states[1])
|
||
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||
self.assertEqual((by["AURORA-1"]["sprint_ids"], by["AURORA-1"]["sprint"]), ([2, 3], "S3"))
|
||
sprint_reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
|
||
self.assertEqual(len(sprint_reads), 1) # имена спринтов — из того же списка
|
||
self.assertNotIn("state", sprint_reads[0])
|
||
|
||
def test_all_sprints_paged(self):
|
||
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": "closed"} for i in range(1, 121)]
|
||
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
|
||
reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
|
||
self.assertEqual(len(reads), 3)
|
||
self.assertEqual(len(self.hub.states[0]["boards"][0]["sprints"]), 120)
|
||
|
||
def test_kanban_backlog_flag(self):
|
||
self.fake.boards[5120] = "kanban"
|
||
for code, want in ((200, True), (400, False), (500, None)):
|
||
self.fake.backlog_codes[5120] = code
|
||
self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}])
|
||
b = self.hub.states[-1]["boards"][0]
|
||
self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS))
|
||
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests])
|
||
|
||
def test_board_config_error_columns_null(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
|
||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
|
||
self.assertEqual(out["status"], "done", out)
|
||
b = self.hub.states[0]["boards"][0]
|
||
self.assertIsNone(b["columns"])
|
||
self.assertEqual([s["id"] for s in b["sprints"]], [1, 2])
|
||
self.assertTrue(any("4216" in l and "HTTP 403" in l for l in logs), logs)
|
||
|
||
def test_board_sprints_error_board_omitted(self): # R-65
|
||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||
self.fake.fail_paths["/rest/agile/1.0/board/4216/sprint"] = [403] # второй запрос (имена спринтов) — успешен
|
||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
|
||
self.assertEqual(out["status"], "done", out)
|
||
self.assertEqual(self.hub.states[0]["boards"], [])
|
||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||
|
||
def test_board_bad_sprint_dropped(self): # R-72
|
||
self.fake.board_sprints[4216] = [{"id": 1, "name": "S1", "state": "CLOSED"}, {"id": 2, "name": "S2", "state": "weird"},
|
||
{"id": 3, "state": "active"}]
|
||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
|
||
self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")])
|
||
self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs)
|
||
|
||
def test_unknown_status_id_skipped_and_status_cached(self):
|
||
self.fake.board_columns[4216] = [("Одна", ["10003", "99999"])]
|
||
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
|
||
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
|
||
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка"]}])
|
||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 1)
|
||
|
||
def test_empty_jql_with_boards_sends_empty_batch(self): # R-66
|
||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||
self.assertEqual(out["result"]["pages"], 1)
|
||
st = self.hub.states[0]
|
||
self.assertEqual((st["issues"], st["links"], st["neighbor_keys"], st["page"], st["last_page"]), ([], [], [], 1, True))
|
||
self.assertEqual(st["boards"][0]["id"], 4216)
|
||
|
||
def test_sprint_ids_non_ascii_digits(self):
|
||
self.assertEqual(self.ex.sprint_ids_of("AURORA-1", [{"id": "²"}, {"id": "٣"}, {"id": "7"}]), [7])
|
||
|
||
|
||
class JiraFieldsTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.hub = FakeHub(SETTINGS)
|
||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||
self.jira = agent.JiraClient(cfg)
|
||
self.jira.sleep = no_sleep
|
||
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
|
||
self.ex.sleep = no_sleep
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
self.hub.stop()
|
||
|
||
def logged(self, fn):
|
||
logs, orig = [], agent.log
|
||
agent.log = logs.append
|
||
try:
|
||
return fn(), logs
|
||
finally:
|
||
agent.log = orig
|
||
|
||
def test_fields_found_by_schema_and_name(self):
|
||
want = {"rank": "customfield_10105", "sprint": "customfield_10104", "story_points": "customfield_10106"}
|
||
self.assertEqual(self.ex.jira_fields(), want)
|
||
self.ex.run({"id": 2, "type": "nope", "params": {}}) # новая команда — кэш процесса жив
|
||
self.assertEqual(self.ex.jira_fields(), want)
|
||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
|
||
|
||
def test_fields_override_skips_lookup(self):
|
||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], rank="cf_r", sprint="cf_s", story_points="cf_p")
|
||
self.assertEqual(self.ex.jira_fields(), {"rank": "cf_r", "sprint": "cf_s", "story_points": "cf_p"})
|
||
self.assertEqual(self.fake.requests, [])
|
||
|
||
def test_fields_partial_override(self):
|
||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
|
||
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_20000")
|
||
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
|
||
|
||
def test_story_points_first_by_numeric_id_any_case(self):
|
||
self.fake.fields += [{"id": "customfield_9999", "name": " story points ", "custom": True},
|
||
{"id": "customfield_10050", "name": "Story Points", "custom": True}]
|
||
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_9999")
|
||
|
||
def test_field_lookup_error_retried_next_command(self):
|
||
self.fake.fail_next.append((403, {}))
|
||
out, logs = self.logged(lambda: (self.ex.jira_fields(), self.ex.jira_fields())) # logged — как в RefreshTest
|
||
self.assertEqual(out[0], {"rank": None, "sprint": None, "story_points": None})
|
||
self.assertTrue(any("HTTP 403" in l for l in logs), logs)
|
||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем
|
||
self.ex.run({"id": 2, "type": "nope", "params": {}})
|
||
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|