"""Поля проекта от модулей; доски — в модуле jira (wiki-module, решение 8, спеки modules/data-model/hub-api).""" import pytest from sdlc_hub.core import db, mcp as core_mcp, registry from sdlc_hub.core.client import HubError from sdlc_hub.core.modules import Module, ProjectField from sdlc_hub.modules.jira import mirror from v6db import make_v6_db def _ok(v): return v def test_v6_boards_moved(tmp_path): make_v6_db(tmp_path / "hub.sqlite") conn = db.connect(tmp_path / "hub.sqlite") try: cols = {r["name"] for r in conn.execute("PRAGMA table_info(project)")} assert cols == {"label", "name", "created_at"} rows = conn.execute("SELECT label, board_id, board_type FROM jira_project ORDER BY label").fetchall() assert [tuple(r) for r in rows] == [("AWPCA", 4216, "scrum"), ("sfm_kedr", None, None)] finally: conn.close() def test_projects_get_shape(hub_server, add_project): conn = hub_server["hub"].conn() try: add_project(conn) mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) finally: conn.close() got = hub_server["client"].get("/projects")["projects"] assert got == [{"label": "AWPCA", "name": "АРМ КА", "created_at": got[0]["created_at"], "board_id": 4216, "board_type": "scrum", "issues_count": 1, "wiki_root": None, "wiki_root_title": None, "wiki_pages": None, "wiki_synced_at": None, "wiki_sync_error": None, "jira_synced_at": None}] def test_post_unknown_field(hub_server): c = hub_server["client"] with pytest.raises(HubError) as e: c.post("/projects", {"label": "X", "name": "X", "board": 5}) assert e.value.status == 422 and e.value.error == "bad_project" and "board" in e.value.detail assert c.get("/projects")["projects"] == [] def test_post_response_shape(hub_server): out = hub_server["client"].post("/projects", {"label": "X", "name": "X", "board_id": 7}) assert set(out) == {"project", "batch_id", "batches"} and out["project"]["board_id"] == 7 assert hub_server["client"].get(f"/batches/{out['batch_id']}")["title"] == "Новый проект X" def test_patch_author_ok(hub_server, add_project): conn = hub_server["hub"].conn() try: add_project(conn) finally: conn.close() out = hub_server["client"].patch("/projects/AWPCA", {"author": "viewer"}) assert out["batch_ids"] == [] def test_patch_unknown_field(hub_server, add_project): conn = hub_server["hub"].conn() try: add_project(conn) finally: conn.close() with pytest.raises(HubError) as e: hub_server["client"].patch("/projects/AWPCA", {"nope": 1}) assert e.value.status == 422 and e.value.detail == "nope: неизвестное поле" def test_editable_field_needs_updater(): m = Module(name="a", project_fields=(ProjectField("root", _ok, editable=True),)) with pytest.raises(RuntimeError) as e: registry.check([m], [], {}) assert "root" in str(e.value) and "on_project_updated" in str(e.value) def test_migrate_v6_idempotent(tmp_path): from sdlc_hub.modules.jira import projects as jira_projects make_v6_db(tmp_path / "hub.sqlite") conn = db.connect(tmp_path / "hub.sqlite") try: before = [tuple(r) for r in conn.execute("SELECT * FROM jira_project ORDER BY label")] jira_projects.migrate_v6(conn) assert [tuple(r) for r in conn.execute("SELECT * FROM jira_project ORDER BY label")] == before assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] assert [r["name"] for r in conn.execute("SELECT name FROM project ORDER BY label")] == ["АРМ КА", "sfm_kedr"] finally: conn.close() def test_create_project_text_without_batch(monkeypatch): class C: def post(self, path, body): return {"project": {"label": "P", "name": "p"}, "batch_id": None} monkeypatch.setattr(core_mcp, "client", lambda: C()) assert "#None" not in core_mcp.create_project("P", "p", {}, "wiki") and "пакет" not in core_mcp.create_project("P", "p", {}, "wiki").splitlines()[0] def test_patch_board_forbidden(hub_server, add_project): conn = hub_server["hub"].conn() try: add_project(conn) finally: conn.close() c = hub_server["client"] with pytest.raises(HubError) as e: c.patch("/projects/AWPCA", {"board_id": 1}) assert e.value.status == 422 and e.value.error == "bad_project" assert "board_id после создания не меняется" in e.value.detail assert c.get("/projects")["projects"][0]["board_id"] == 4216 def test_patch_unknown_project(hub_server): c = hub_server["client"] with pytest.raises(HubError) as e: c.patch("/projects/NOPE", {}) assert e.value.status == 404 def test_patch_empty_ok(hub_server, add_project): conn = hub_server["hub"].conn() try: add_project(conn) finally: conn.close() c = hub_server["client"] out = c.patch("/projects/AWPCA", {}) assert out["batch_ids"] == [] and out["project"]["label"] == "AWPCA" def test_field_conflict(): a = Module(name="a", project_fields=(ProjectField("root", _ok),)) b = Module(name="b", project_fields=(ProjectField("root", _ok),)) with pytest.raises(RuntimeError) as e: registry.check([a, b], [], {}) text = str(e.value) assert "root" in text and "a" in text and "b" in text @pytest.mark.parametrize("name", ["label", "name", "created_at", "author"]) def test_field_clashes_with_core(name): with pytest.raises(RuntimeError) as e: registry.check([Module(name="a", project_fields=(ProjectField(name, _ok),))], [], {}) assert name in str(e.value) def test_mcp_json_per_module(): first = core_mcp.mcp_json("AWPCA", "jira")["mcpServers"] assert list(first) == ["sdlc-jira"] and first["sdlc-jira"]["args"][-4:] == ["mcp", "jira", "--project", "AWPCA"] assert first["sdlc-jira"]["args"][3] == "sdlc-hub" other = core_mcp.mcp_json("AWPCA", "wiki")["mcpServers"] assert list(other) == ["sdlc-wiki"] assert other["sdlc-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"] def test_fmt_projects_columns(): items = [{"label": "A", "name": "a", "x": 1}, {"label": "B", "name": "b", "x": None}] text = core_mcp.fmt_projects(items, "B", [("икс", lambda p: core_mcp.value(p.get("x")))]) assert text == "метка | название | икс\nA | a | 1\nB | b | — (текущий)" @pytest.fixture def two_modules(monkeypatch, open_db): """Реестр [jira, stub]: stub объявляет изменяемое поле root, хранит его в setting и ставит пакеты-пустышки.""" import json import re from sdlc_hub.core import api, queue from sdlc_hub.core.http import ApiError from sdlc_hub.modules.jira import MODULE as JIRA def check_root(v): if not isinstance(v, str) or not v: raise ValueError("ожидается непустая строка") return v.upper() def created(conn, project, fields, author): if "root" in fields: conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES (?, ?)", (f"root:{project['label']}", json.dumps(fields["root"]))) def updated(conn, project, fields, author): created(conn, project, fields, author) return [queue.create_batch(conn, "stub", author, [{"type": "refresh", "params": {"keys": ["A-1"]}}])] def view(conn, labels): out = {} for label in labels: row = conn.execute("SELECT value FROM setting WHERE key = ?", (f"root:{label}",)).fetchone() out[label] = {"root": json.loads(row["value"]) if row else None} return out stub = Module(name="stub", project_fields=(ProjectField("root", check_root, editable=True),), project_view=view, on_project_created=created, on_project_updated=updated) monkeypatch.setattr(registry, "_cache", [JIRA, stub]) conn = open_db() return api, ApiError, conn, re def test_module_fields_post_and_patch(two_modules): api, ApiError, conn, re = two_modules code, out = api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "board_id": 3, "root": "doc-1"}) assert code == 201 and out["project"]["root"] == "DOC-1" and out["project"]["board_id"] == 3 assert set(out) == {"project", "batch_id", "batches"} and out["batch_id"] is not None # пакет только у jira assert [b["module"] for b in out["batches"]] == ["jira"] code, out = api.h_project_patch(None, conn, re.match(r"(.*)", "P"), {}, {"root": "doc-2"}) assert code == 200 and out["project"]["root"] == "DOC-2" and len(out["batch_ids"]) == 1 with pytest.raises(ApiError) as e: api.h_project_patch(None, conn, re.match(r"(.*)", "P"), {}, {"root": ""}) assert e.value.status == 422 and e.value.detail.startswith("root") assert api.project_rows(conn)[0]["root"] == "DOC-2" def test_module_field_error_writes_nothing(two_modules): api, ApiError, conn, re = two_modules with pytest.raises(ApiError) as e: api.h_projects_post(None, conn, None, {}, {"label": "P", "name": "p", "root": 5}) assert e.value.status == 422 and e.value.detail.startswith("root") assert api.project_rows(conn) == [] def test_projects_jira_synced_at(hub_server, add_project): """jira_synced_at — finished_at последнего полного обновления; до него — null у каждого проекта.""" h, c = hub_server["hub"], hub_server["client"] conn = h.conn() try: add_project(conn, "AWPCA") add_project(conn, "OTHER", board_id=None) finally: conn.close() assert [p["jira_synced_at"] for p in c.get("/projects")["projects"]] == [None, None] c.post("/refresh", {"full": True}) cmd = hub_server["agent"].post("/agent/claim", {})["command"] hub_server["agent"].post("/agent/state", {"command_id": cmd["id"], "issues": [], "links": [], "neighbor_keys": []}) hub_server["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done"}) conn = h.conn() try: conn.execute("UPDATE sync_run SET finished_at = '2026-10-24T11:07:00' WHERE command_id = ?", (cmd["id"],)) finally: conn.close() assert [p["jira_synced_at"] for p in c.get("/projects")["projects"]] == ["2026-10-24T11:07:00"] * 2