- POST /items/batch-status:逐条成败,不做全回滚;拖 10 条里 1 条冲突不该让另 9 条白干
- GET /items/board:四列一次取全,收件箱不进看板
- 修:/items/board 被 /{item_id} 抢先匹配导致 422
- 新增向量库配置项(M3 用,独立实例,不动 fnsvr-01)
- 3 个新测试,全量 46 passed
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""任务域:状态机、乐观锁、视图、搜索、租户隔离。
|
|
|
|
这些用例覆盖的是"用户能感觉到的错",不是代码覆盖率:
|
|
状态乱跳、两个人同时改、别人的任务突然出现、今天的清单里混进明天的任务。
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
async def test_batch_status_partial_success(client: AsyncClient) -> None:
|
|
"""拖 3 条进去:2 条该成功,1 条因为版本过期该失败,且失败不影响另外两条。"""
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "个人")
|
|
a = await _new_item(client, user, ws["id"], title="A", status="todo")
|
|
b = await _new_item(client, user, ws["id"], title="B", status="todo")
|
|
stale = await _new_item(client, user, ws["id"], title="C", status="todo")
|
|
|
|
# 让 C 的版本先过期:另一个人(同一用户)改了一次
|
|
unwrap(
|
|
await client.patch(
|
|
f"{items_url(ws['id'])}/{stale['id']}",
|
|
json={"title": "C 改过", "version": stale["version"]},
|
|
headers=user["headers"],
|
|
)
|
|
)
|
|
|
|
body = unwrap(
|
|
await client.post(
|
|
f"{items_url(ws['id'])}/batch-status",
|
|
json={
|
|
"status": "done",
|
|
"items": [
|
|
{"id": a["id"], "version": a["version"]},
|
|
{"id": b["id"], "version": b["version"]},
|
|
{"id": stale["id"], "version": stale["version"]},
|
|
],
|
|
},
|
|
headers=user["headers"],
|
|
)
|
|
)
|
|
assert len(body["updated"]) == 2
|
|
assert len(body["failed"]) == 1
|
|
assert body["failed"][0]["id"] == stale["id"]
|
|
assert body["failed"][0]["code"] == 4009
|
|
assert body["failed"][0]["current_version"] == stale["version"] + 1
|
|
|
|
detail_a = unwrap(await client.get(f"{items_url(ws['id'])}/{a['id']}", headers=user["headers"]))
|
|
assert detail_a["status"] == "done"
|
|
assert detail_a["completed_at"] is not None
|
|
|
|
|
|
async def test_batch_status_reports_illegal_transition(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "个人")
|
|
inbox = await _new_item(client, user, ws["id"], title="还在收件箱")
|
|
|
|
body = unwrap(
|
|
await client.post(
|
|
f"{items_url(ws['id'])}/batch-status",
|
|
json={"status": "done", "items": [{"id": inbox["id"], "version": inbox["version"]}]},
|
|
headers=user["headers"],
|
|
)
|
|
)
|
|
assert body["updated"] == []
|
|
assert body["failed"][0]["code"] == 5002
|
|
assert "todo" in body["failed"][0]["allowed"]
|
|
|
|
|
|
async def test_board_groups_by_status_and_hides_others(client: AsyncClient) -> None:
|
|
user = await login(client)
|
|
ws = await create_workspace(client, user, "个人")
|
|
await _new_item(client, user, ws["id"], title="待办1", status="todo")
|
|
await _new_item(client, user, ws["id"], title="在做1", status="doing")
|
|
await _new_item(client, user, ws["id"], title="卡住1", status="blocked")
|
|
await _new_item(client, user, ws["id"], title="收件箱的", status="inbox")
|
|
|
|
board = unwrap(await client.get(f"{items_url(ws['id'])}/board", headers=user["headers"]))
|
|
columns = board["columns"]
|
|
assert board["order"] == ["todo", "doing", "blocked", "done"]
|
|
assert [i["title"] for i in columns["todo"]] == ["待办1"]
|
|
assert [i["title"] for i in columns["doing"]] == ["在做1"]
|
|
assert [i["title"] for i in columns["blocked"]] == ["卡住1"]
|
|
assert columns["done"] == []
|
|
# 收件箱不属于看板任何一列
|
|
all_titles = [i["title"] for col in columns.values() for i in col]
|
|
assert "收件箱的" not in all_titles
|
|
|
|
# 别人的工作区看板为空
|
|
other_user = await login(client)
|
|
other_ws = await create_workspace(client, other_user, "别人")
|
|
empty = unwrap(
|
|
await client.get(f"{items_url(other_ws['id'])}/board", headers=other_user["headers"])
|
|
)
|
|
assert all(len(col) == 0 for col in empty["columns"].values())
|