diff --git a/tests/test_api.py b/tests/test_api.py index 384812a..a153aa8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,10 +3,11 @@ import os import threading import urllib.error import urllib.request +from datetime import timedelta import pytest -from jira_hub import api, db, release, settings +from jira_hub import api, db, queue, release, settings from jira_hub.client import HubClient, HubError @@ -209,13 +210,15 @@ def test_numeric_param_400_internal_bug_500(hub, monkeypatch): assert e.value.status == 500 -def test_tick_survives_connection_failure(hub, monkeypatch): +def test_tick_survives_connection_failure(hub, monkeypatch, caplog): def broken(): raise OSError("диск недоступен") monkeypatch.setattr(hub["hub"], "conn", broken) hub["hub"].tick() # не бросает + assert "автообновление: сбой тика" in caplog.text monkeypatch.undo() hub["hub"].tick() + assert hub["client"].get("/batches")["batches"] == [] # агент не на связи — автообновления нет def test_claim_long_poll_returns_new_command(hub): @@ -526,3 +529,47 @@ def test_settings_put_empty_body_returns_settings_unchanged(hub): c = hub["client"] before = c.get("/settings") assert c.put("/settings", {}) == before + + +@pytest.mark.parametrize("method", ["DELETE", "PATCH"]) +def test_other_methods_are_501_not_json(hub, method): + """M-8 #16, спека «Неподдерживаемый метод»: 501 стандартной страницей http.server, не JSON; база не меняется.""" + import http.client + hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", + "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + host, port = hub["base"].removeprefix("http://").split(":") + conn = http.client.HTTPConnection(host, int(port), timeout=10) + try: + conn.request(method, "/batches/1", headers={"Authorization": "Bearer " + hub["client"].token}) + r = conn.getresponse() + r.read() + assert r.status == 501 and not (r.getheader("Content-Type") or "").startswith("application/json") + finally: + conn.close() + assert hub["client"].get("/batches/1")["status"] == "queued" + + +def test_agent_warning_and_offline(hub): + """M-10 #4: «обновите агента» при agent_version < min_agent_version; нет контакта дольше порога — офлайн.""" + c = hub["client"] + c.put("/settings", {"min_agent_version": 3}) + hub["agent"].post("/agent/claim", {"agent_version": 2}) + v = c.get("/agent") + assert v["online"] is True and v["warning"] == "обновите агента" + hub["agent"].post("/agent/claim", {"agent_version": 3}) + assert c.get("/agent")["warning"] is None + old = (db.utcnow() - timedelta(minutes=queue.ONLINE_MINUTES + 1)).isoformat() + conn = hub["hub"].conn() + conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE id = 1", (old,)) + conn.close() + assert c.get("/agent")["online"] is False + + +def test_tick_creates_auto_refresh_once(hub): + """M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята).""" + hub["agent"].post("/agent/claim", {"agent_version": 9}) + hub["hub"].tick() + batches = hub["client"].get("/batches", limit=5)["batches"] + assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub" + hub["hub"].tick() + assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1 diff --git a/tests/test_commands.py b/tests/test_commands.py index 5041530..c310ee3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -177,3 +177,24 @@ def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expe def test_type_error_messages(ctype, name, value, msg): """T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один).""" assert commands._type_error(ctype, name, value) == msg + + +@pytest.mark.parametrize("cmd, name", [ + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": True}}, "duedate"), + ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": False}}, "body"), + ({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": True}}, "summary"), +]) +def test_validate_rejects_bool_in_string_param(cmd, name): + """T3.2 #16: true/false в строковом параметре — 422 с именем параметра, а не запись «True» в Jira.""" + with pytest.raises(CommandError, match=f"{name} — строка"): + commands.validate([cmd]) + + +def test_validate_empty_list_params(): + """T3.2 #16: [] в необязательном списке допустим; [] в обязательном или единственном — как отсутствие.""" + out = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": [], "labels_remove": []}}]) + assert out[0]["params"] == {"key": "A-1", "labels_add": [], "labels_remove": []} + with pytest.raises(CommandError, match="нет target"): + commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}]) + with pytest.raises(CommandError, match="refresh без keys и jql"): + commands.validate([{"type": "refresh", "params": {"keys": []}}]) diff --git a/tests/test_importer.py b/tests/test_importer.py index 992d3b4..00edce1 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -113,3 +113,12 @@ def test_reversed_link_reported_separately(conn, tmp_path): rep = importer.import_package(conn, tmp_path) assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}] assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] + + +def test_epic_without_key_is_skipped(tmp_path, conn): + """M-8 #16 (T7.3 #4), спека migration «Эпик без ключа»: строка без «Ключ» пропущена, заметки new:* нет.""" + write_csv(tmp_path / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"], + [["NEW-2", "", "Без ключа", "ещё не создан"], ["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"]]) + rep = importer.import_package(conn, tmp_path) + assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1350"] + assert [r["key"] for r in conn.execute("SELECT key FROM issue_note ORDER BY key")] == ["AURORA-1350"] diff --git a/tests/test_queue.py b/tests/test_queue.py index 7391674..a6732ae 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -499,3 +499,19 @@ def test_reconcile_confirms_fields_with_crlf(conn): mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], []) assert queue.reconcile(conn) == [c["id"]] assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" + + +def test_delivery_limit_of_write_command_keeps_open_sync_run(conn): + """T5.2 #16: предел выдач у не-refresh команды не закрывает журнал чужой загрузки (finished_at — только для refresh).""" + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + c = queue.claim(conn) + r = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-9"]}}]) + rid = queue.batch_view(conn, r["id"])["commands"][0]["id"] + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2099-01-01T00:00:00+00:00', deliveries = 1 " + "WHERE id = ?", (rid,)) + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, 'keys', 'A-9', ?)", (db.now(), rid)) + for _ in range(queue.MAX_DELIVERIES - 1): + assert queue.claim(conn)["id"] == c["id"] + assert queue.claim(conn)["id"] == rid # c — failed по пределу, довыдаётся refresh + assert queue.get_command(conn, c["id"])["status"] == "failed" + assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None diff --git a/tests/test_relay.py b/tests/test_relay.py index 6f92209..4469904 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -365,7 +365,8 @@ def test_vanished_tree_keeps_work(env, capsys): for _ in range(3): r.step() assert (root / "work" / "busy.json").exists() and (root / "req").is_dir() - assert "удалён незавершённый запрос" not in capsys.readouterr().out + out = capsys.readouterr().out + assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out # minor-3 #16 def test_each_dir_cleaned_once(env, tmp_path, capsys):