"""任务域:状态机、乐观锁、视图、搜索、租户隔离。 这些用例覆盖的是"用户能感觉到的错",不是代码覆盖率: 状态乱跳、两个人同时改、别人的任务突然出现、今天的清单里混进明天的任务。 """ 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