Хаб: маршруты GET/POST /projects, создание проекта ставит полный refresh (#61)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 20:31:19 +04:00
parent 203c7e7b3c
commit f60aecd889
3 changed files with 82 additions and 0 deletions

View File

@ -250,3 +250,9 @@
раскладки AWPCA. Цена ошибки: схема ключей в `storage.ts`.
21. 2026-10-19 — **`last_sync` после создания проекта — `null` до завершения его обновления, `sync_run.query` не
переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске.
22. 2026-10-19 — **пустой `author` в `POST /projects` — как `api`, нестрока — 422 `bad_project`.** Обоснование: как у
остальных маршрутов (`body.get("author") or "api"`); спека требует 422 для не-строки. Цена ошибки: пустая строка
пишется автором `api`, а не отвергается.
23. 2026-10-19 — **`CommandError` из постановки refresh в `POST /projects` — 422 `bad_batch`, а не `bad_project`.**
Обоснование: ошибка относится к пакету, а не к полям проекта; спека называет только `project_exists` и
`bad_project`. Цена ошибки: код ошибки в ответе.

View File

@ -393,6 +393,30 @@ def h_settings_put(hub, conn, m, q, body):
return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)}
def h_projects_get(hub, conn, m, q, body):
return 200, {"projects": projects.list_projects(conn)}
def h_projects_post(hub, conn, m, q, body):
"""Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»)."""
author = body.get("author") or "api"
if not isinstance(author, str):
raise ApiError(422, "bad_project", "author — строка")
try:
with db.transaction(conn):
project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id"))
batch = queue.create_batch(conn, f"Новый проект {project['label']}", author,
[{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
except projects.ProjectExists as e:
raise ApiError(409, "project_exists", str(e))
except projects.ProjectError as e:
raise ApiError(422, "bad_project", str(e))
except CommandError as e:
raise ApiError(422, "bad_batch", str(e))
return 201, {"project": project, "batch_id": batch["id"]}
ROUTES = [
("GET", r"^/(links)?$", h_root, None),
("GET", r"^/app(?:/(.*))?$", h_app, None),
@ -420,6 +444,8 @@ ROUTES = [
("GET", r"^/sync-runs$", h_sync_runs, "client"),
("GET", r"^/settings$", h_settings_get, "client"),
("PUT", r"^/settings$", h_settings_put, "client"),
("GET", r"^/projects$", h_projects_get, "client"),
("POST", r"^/projects$", h_projects_post, "client"),
]
_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES]

View File

@ -780,3 +780,53 @@ def test_issues_hide_closed(hub):
assert keys() == ["A-1", "A-2", "A-3", "A-4"]
c.put("/settings", {"closed_statuses": ["Готово"]})
assert keys(hide_closed="1") == ["A-1", "A-3", "A-4"]
def test_create_project(hub): # сценарий «Новый проект»
c = hub["client"]
conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close()
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216})
out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"})
assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None
got = c.get("/projects")["projects"]
assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0
b = c.get(f"/batches/{out['batch_id']}")
assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None
assert b["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"}
assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек»
assert c.get("/settings")["base_jql"] == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
def test_create_project_without_board(hub):
c = hub["client"]
out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
assert out["project"]["board_id"] is None and out["project"]["board_type"] is None
b = c.get(f"/batches/{out['batch_id']}")
assert b["author"] == "api"
@pytest.mark.parametrize("body, status, field", [
({"label": "AWPCA", "name": "x"}, 409, "label"),
({"label": "awpca", "name": "x"}, 409, "label"),
({"label": "STOCKS DRRK", "name": "x"}, 422, "label"),
({"label": "STOCKS_DRRK", "name": " "}, 422, "name"),
({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"),
({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None),
])
def test_create_project_errors(hub, body, status, field):
c = hub["client"]
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"})
before = len(c.get("/batches")["batches"])
with pytest.raises(HubError) as e:
c.post("/projects", body)
assert e.value.status == status and (field is None or e.value.detail.startswith(field))
assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before
def test_projects_need_client_token(hub):
with pytest.raises(HubError) as e:
hub["agent"].get("/projects")
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["agent"].post("/projects", {"label": "STOCKS_DRRK", "name": "x"})
assert e.value.status == 401