feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
CI / backend (push) Canceled after 0s

- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
This commit is contained in:
Steven
2026-10-28 03:48:48 +08:00
parent d390b7b58a
commit e25a1a4e31
19 changed files with 1959 additions and 31 deletions
+272
View File
@@ -0,0 +1,272 @@
"""任务域:状态机、乐观锁、视图、搜索、租户隔离。
这些用例覆盖的是"用户能感觉到的错",不是代码覆盖率:
状态乱跳、两个人同时改、别人的任务突然出现、今天的清单里混进明天的任务。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
TZ = ZoneInfo("Asia/Shanghai")
def items_url(ws_id: str) -> str:
return f"/api/v1/workspaces/{ws_id}/items"
async def _new_item(client: AsyncClient, user: dict, ws_id: str, **overrides) -> dict:
payload = {"title": "写周报", **overrides}
return unwrap(await client.post(items_url(ws_id), json=payload, headers=user["headers"]))
async def test_create_task_defaults_to_inbox(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
item = await _new_item(client, user, ws["id"])
assert item["status"] == "inbox"
assert item["kind"] == "task"
assert item["version"] == 1
assert item["creator_id"] == user["user_id"]
inbox = unwrap(
await client.get(items_url(ws["id"]), params={"view": "inbox"}, headers=user["headers"])
)
assert [i["id"] for i in inbox["items"]] == [item["id"]]
async def test_status_machine_happy_path_sets_completed_at(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
item = await _new_item(client, user, ws["id"])
for target in ("todo", "doing", "done"):
item = unwrap(
await client.post(
f"{items_url(ws['id'])}/{item['id']}/status",
json={"status": target, "version": item["version"]},
headers=user["headers"],
)
)
assert item["status"] == target
assert item["completed_at"] is not None
# 重开:completed_at 必须被清掉,否则"完成时间"会一直挂着
item = unwrap(
await client.post(
f"{items_url(ws['id'])}/{item['id']}/status",
json={"status": "todo", "version": item["version"]},
headers=user["headers"],
)
)
assert item["status"] == "todo"
assert item["completed_at"] is None
async def test_illegal_transition_is_rejected(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
item = await _new_item(client, user, ws["id"])
resp = await client.post(
f"{items_url(ws['id'])}/{item['id']}/status",
json={"status": "done", "version": item["version"]}, # inbox 不能直接 done
headers=user["headers"],
)
body = resp.json()
assert body["code"] == 5002
assert body["data"]["from"] == "inbox"
assert "todo" in body["data"]["allowed"]
async def test_stale_version_is_rejected_not_silently_overwritten(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
item = await _new_item(client, user, ws["id"])
first = unwrap(
await client.patch(
f"{items_url(ws['id'])}/{item['id']}",
json={"version": item["version"], "title": "写周报(周四)"},
headers=user["headers"],
)
)
assert first["version"] == item["version"] + 1
resp = await client.patch(
f"{items_url(ws['id'])}/{item['id']}",
json={"version": item["version"], "title": "覆盖别人的修改"},
headers=user["headers"],
)
body = resp.json()
assert resp.status_code == 409
assert body["code"] == 4009
assert body["data"]["current_version"] == first["version"]
async def test_other_tenant_cannot_read_or_list_my_task(client: AsyncClient) -> None:
me = await login(client)
other = await login(client)
ws = await create_workspace(client, me, "我的空间")
other_ws = await create_workspace(client, other, "别人的空间")
item = await _new_item(client, me, ws["id"], title="机密方案")
resp = await client.get(f"{items_url(ws['id'])}/{item['id']}", headers=other["headers"])
assert resp.json()["code"] == 3003 # 不是成员,连工作区都进不去
listed = unwrap(
await client.get(
items_url(other_ws["id"]), params={"view": "all"}, headers=other["headers"]
)
)
assert listed["items"] == []
async def test_search_by_keyword_and_filter_by_tag(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
await _new_item(client, user, ws["id"], title="整理季度合同", tag_names=["合同", "法务"])
await _new_item(client, user, ws["id"], title="买咖啡豆")
found = unwrap(
await client.get(
items_url(ws["id"]), params={"q": "合同", "view": "all"}, headers=user["headers"]
)
)
assert [i["title"] for i in found["items"]] == ["整理季度合同"]
assert {t["name"] for t in found["items"][0]["tags"]} == {"合同", "法务"}
tagged = unwrap(
await client.get(
items_url(ws["id"]), params={"tag": "法务", "view": "all"}, headers=user["headers"]
)
)
assert [i["title"] for i in tagged["items"]] == ["整理季度合同"]
all_tags = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/tags", headers=user["headers"])
)
assert {t["name"] for t in all_tags["items"]} == {"合同", "法务"}
async def test_today_view_uses_local_timezone_boundaries(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
now_local = datetime.now(TZ)
today_2330 = now_local.replace(hour=23, minute=30, second=0, microsecond=0)
tomorrow_0030 = (now_local + timedelta(days=1)).replace(
hour=0, minute=30, second=0, microsecond=0
)
await _new_item(
client,
user,
ws["id"],
title="今晚交",
status="todo",
due_at=today_2330.astimezone(timezone.utc).isoformat(),
)
await _new_item(
client,
user,
ws["id"],
title="明早交",
status="todo",
due_at=tomorrow_0030.astimezone(timezone.utc).isoformat(),
)
today = unwrap(
await client.get(items_url(ws["id"]), params={"view": "today"}, headers=user["headers"])
)
assert [i["title"] for i in today["items"]] == ["今晚交"]
async def test_soft_delete_hides_from_lists_but_keeps_row(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
item = await _new_item(client, user, ws["id"], status="todo")
unwrap(
await client.delete(
f"{items_url(ws['id'])}/{item['id']}",
params={"version": item["version"]},
headers=user["headers"],
)
)
active = unwrap(
await client.get(items_url(ws["id"]), params={"view": "active"}, headers=user["headers"])
)
assert active["items"] == []
deleted = unwrap(
await client.get(items_url(ws["id"]), params={"view": "deleted"}, headers=user["headers"])
)
assert [i["id"] for i in deleted["items"]] == [item["id"]]
async def test_comments_and_activity_stream(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
item = await _new_item(client, user, ws["id"])
unwrap(
await client.post(
f"{items_url(ws['id'])}/{item['id']}/status",
json={"status": "todo", "version": item["version"]},
headers=user["headers"],
)
)
unwrap(
await client.post(
f"{items_url(ws['id'])}/{item['id']}/comments",
json={"body_md": "先找财务要模板"},
headers=user["headers"],
)
)
detail = unwrap(
await client.get(f"{items_url(ws['id'])}/{item['id']}", headers=user["headers"])
)
actions = [a["action"] for a in detail["activities"]]
assert actions[0] == "commented"
assert "status_changed" in actions
assert "created" in actions
status_change = next(a for a in detail["activities"] if a["action"] == "status_changed")
assert status_change["payload"] == {"from": "inbox", "to": "todo"}
async def test_cursor_pagination_does_not_repeat_rows(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "个人")
for index in range(3):
await _new_item(client, user, ws["id"], title=f"任务{index}", status="todo")
first = unwrap(
await client.get(
items_url(ws["id"]), params={"view": "all", "limit": 2}, headers=user["headers"]
)
)
assert len(first["items"]) == 2
assert first["has_more"] is True
assert first["next_cursor"]
second = unwrap(
await client.get(
items_url(ws["id"]),
params={"view": "all", "limit": 2, "cursor": first["next_cursor"]},
headers=user["headers"],
)
)
ids_first = {i["id"] for i in first["items"]}
ids_second = {i["id"] for i in second["items"]}
assert ids_first.isdisjoint(ids_second)
assert len(ids_second) == 1
assert second["has_more"] is False