test: bool и [] в параметрах, журнал при пределе выдач, 501, эпик без ключа, «подготовлена заново», предупреждение и офлайн агента, тик (T3.2, T5.2, M-8, minor-3 #16; M-10 #4) (#32)

Решение 13: тесты фиксируют уже верное поведение; каждый проверен мутацией.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 10:46:13 +04:00
parent e180bb4e5f
commit 1f1dad6911
5 changed files with 97 additions and 3 deletions

View File

@ -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

View File

@ -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": []}}])

View File

@ -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"]

View File

@ -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

View File

@ -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):