Files
DadaoTask/backend/tests/test_tickets.py
T
dadao 30caaaf1d9 feat(ticket): 工单协同后端——升级不搬数据、受理到验收、站内通知
- item.kind='ticket' + ticket_ext 1:1,迁移 0003 同时重写 item 状态约束为任务∪工单并集
- 工单状态机与动作权限:受理/开工/提交限办理人,验收限需求人(办理人不能自验)
- 升级时原任务转 blocked,验收通过后自动回流 todo,事件流两侧都留痕
- item_link(父子/相关)与 notification(只发当事人),三张表均开 RLS
- 抽出 serializers.py 复用任务序列化,修复工单响应缺 tags 导致前端白屏
- 评论与 PATCH 放开给工单;conftest 清库前终止残留事务,避免 TRUNCATE 死等锁
- tests/test_tickets.py 11 个用例,全量 57 passed
2026-10-28 13:20:07 +08:00

494 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M2 工单域测试:升级、指派、办理、验收、回流、权限、通知。
重点不是"能跑通",而是这几条容易做错的:
1. 升级后**原任务必须变 blocked**(否则用户以为这事还在自己手上);
2. 验收通过后**原任务自动回到 todo**(闭环回流,这是整个产品的卖点);
3. 驳回**回 in_progress 而不是 open**,且 `reject_count` 加一;
4. **办理人不能自己验收**(否则工单退化成打勾);
5. 通知发给对的人:提交 → 提需求的人;通过/驳回 → 办理人。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
async def _member(
client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member"
) -> dict[str, Any]:
"""拉一个新人进工作区。"""
user = await login(client)
inv = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"]
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/join",
json={"code": inv["code"]},
headers=user["headers"],
)
)
return user
async def _task(
client: AsyncClient, user: dict[str, Any], ws_id: str, title: str = "整理需求"
) -> dict:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items",
json={"title": title, "status": "todo"},
headers=user["headers"],
)
)
@pytest.mark.asyncio
async def test_promote_creates_ticket_and_blocks_task(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "工单测试")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
data = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
# 原任务:还是任务,但被卡住了
assert data["task"]["id"] == task["id"]
assert data["task"]["status"] == "blocked"
assert data["task"]["kind"] == "task"
# 工单:新的一条 item,指回原任务
ticket = data["ticket"]
assert ticket["id"] != task["id"]
assert ticket["kind"] == "ticket"
assert ticket["status"] == "open"
assert ticket["ticket"]["requester_id"] == owner["user_id"]
assert ticket["ticket"]["assignee_id"] == helper["user_id"]
assert ticket["ticket"]["parent_item_id"] == task["id"]
# 工单不出现在任务列表里(两条流各看各的)
tasks = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items?view=all", headers=owner["headers"])
)
assert [t["id"] for t in tasks["items"]] == [task["id"]]
# 被指派人收到通知
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=helper["headers"])
)
assert [n["kind"] for n in notes["notifications"]] == ["ticket_assigned"]
assert notes["notifications"][0]["item_id"] == ticket["id"]
@pytest.mark.asyncio
async def test_full_flow_reopens_task_on_approval(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "闭环")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "等运维开通权限")
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
base = f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket"
assert (
unwrap(await client.post(f"{base}/accept", headers=helper["headers"]))["status"]
== "accepted"
)
assert (
unwrap(await client.post(f"{base}/start", headers=helper["headers"]))["status"]
== "in_progress"
)
submitted = unwrap(
await client.post(f"{base}/submit", json={"note": "权限已开通"}, headers=helper["headers"])
)
assert submitted["status"] == "pending_verify"
# 提需求的人收到验收通知
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=owner["headers"])
)
assert notes["notifications"][0]["kind"] == "ticket_submitted"
done = unwrap(
await client.post(
f"{base}/verify",
json={"approve": True, "resolution_md": "权限已开通,验证通过"},
headers=owner["headers"],
)
)
assert done["status"] == "done"
assert done["ticket"]["verified_by"] == owner["user_id"]
assert done["ticket"]["resolution_md"] == "权限已开通,验证通过"
# 闭环回流:原任务从 blocked 回到 todo,并且能看到回流事件
reopened = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items/{task['id']}", headers=owner["headers"])
)
assert reopened["status"] == "todo"
assert reopened["completed_at"] is None
actions = [a["action"] for a in reopened["activities"]]
assert "promoted_to_ticket" in actions
assert "ticket_done" in actions
@pytest.mark.asyncio
async def test_reject_goes_back_to_in_progress_and_counts(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "驳回")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
base = f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket"
await client.post(f"{base}/accept", headers=helper["headers"])
await client.post(f"{base}/start", headers=helper["headers"])
await client.post(f"{base}/submit", json={}, headers=helper["headers"])
rejected = unwrap(
await client.post(
f"{base}/verify",
json={"approve": False, "note": "截图里没看到生效"},
headers=owner["headers"],
)
)
assert rejected["status"] == "in_progress"
assert rejected["ticket"]["reject_count"] == 1
assert rejected["ticket"]["verified_at"] is None
# 原任务仍在 blocked(驳回不算办完)
still = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items/{task['id']}", headers=owner["headers"])
)
assert still["status"] == "blocked"
# 办理人收到驳回通知
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=helper["headers"])
)
kinds = [n["kind"] for n in notes["notifications"]]
assert "ticket_rejected" in kinds
@pytest.mark.asyncio
async def test_assignee_cannot_self_approve(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "越权")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
base = f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket"
await client.post(f"{base}/accept", headers=helper["headers"])
await client.post(f"{base}/start", headers=helper["headers"])
await client.post(f"{base}/submit", json={}, headers=helper["headers"])
resp = await client.post(f"{base}/verify", json={"approve": True}, headers=helper["headers"])
assert resp.json()["code"] == 6004 # TICKET_FORBIDDEN
@pytest.mark.asyncio
async def test_illegal_transition_reports_allowed(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "状态机")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/submit",
json={},
headers=helper["headers"],
)
body = resp.json()
assert body["code"] == 6003
assert body["data"]["from"] == "open"
# allowed 回答"现在还能去哪儿",allowed_from 回答"这个动作要什么前置状态"
assert body["data"]["allowed"] == ["accepted", "canceled"]
assert body["data"]["allowed_from"] == ["in_progress"]
@pytest.mark.asyncio
async def test_promote_requires_member_assignee_and_no_double(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "升级校验")
task = await _task(client, owner, ws["id"])
stranger = await login(client) # 不在工作区
bad = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": stranger["user_id"]},
headers=owner["headers"],
)
assert bad.json()["code"] == 1001
helper = await _member(client, owner, ws)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
twice = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
assert twice.json()["code"] == 6002
@pytest.mark.asyncio
async def test_ticket_list_scopes(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "列表")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "等设计稿")
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
mine_as_helper = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets?role=assigned", headers=helper["headers"]
)
)
assert len(mine_as_helper["tickets"]) == 1
assert mine_as_helper["tickets"][0]["title"] == "等设计稿"
requested = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets?role=requested", headers=owner["headers"]
)
)
assert len(requested["tickets"]) == 1
# 只按状态过滤
empty = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets?role=assigned&status=done",
headers=helper["headers"],
)
)
assert empty["tickets"] == []
@pytest.mark.asyncio
async def test_cross_tenant_ticket_invisible(client: AsyncClient) -> None:
alice = await login(client)
bob = await login(client)
ws_a = await create_workspace(client, alice, "A 公司")
ws_b = await create_workspace(client, bob, "B 公司")
helper = await _member(client, alice, ws_a)
task = await _task(client, alice, ws_a["id"])
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws_a['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=alice["headers"],
)
)["ticket"]
# 用 B 工作区去访问 A 的工单:一律"不存在",不泄漏存在性
detail = await client.get(
f"{V1}/workspaces/{ws_b['id']}/items/{ticket['id']}", headers=bob["headers"]
)
assert detail.json()["code"] in (5001, 3003)
act = await client.post(
f"{V1}/workspaces/{ws_b['id']}/items/{ticket['id']}/ticket/accept", headers=bob["headers"]
)
assert act.json()["code"] in (5001, 3003)
# A 的工单列表在 B 里是空的
listing = unwrap(
await client.get(f"{V1}/workspaces/{ws_b['id']}/tickets?role=all", headers=bob["headers"])
)
assert listing["tickets"] == []
@pytest.mark.asyncio
async def test_links_parent_child_and_related(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "关系")
parent = await _task(client, owner, ws["id"], "上线新版")
child = await _task(client, owner, ws["id"], "写发布说明")
other = await _task(client, owner, ws["id"], "相关:监控告警")
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links",
json={"to_item_id": child["id"], "link_type": "parent"},
headers=owner["headers"],
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links",
json={"to_item_id": other["id"], "link_type": "related"},
headers=owner["headers"],
)
)
links = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links", headers=owner["headers"]
)
)
assert [c["item"]["id"] for c in links["children"]] == [child["id"]]
assert [r["item"]["id"] for r in links["related"]] == [other["id"]]
assert links["parents"] == []
# 从子项看,父项在 parents 里
from_child = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/items/{child['id']}/links", headers=owner["headers"]
)
)
assert [p["item"]["id"] for p in from_child["parents"]] == [parent["id"]]
# 重复挂同一条关系 → 冲突
dup = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{parent['id']}/links",
json={"to_item_id": child["id"], "link_type": "parent"},
headers=owner["headers"],
)
assert dup.json()["code"] == 5005
@pytest.mark.asyncio
async def test_notifications_read_flow(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "通知")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"])
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 1
notes = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/notifications", headers=helper["headers"])
)
note_id = notes["notifications"][0]["id"]
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/notifications/{note_id}/read", headers=helper["headers"]
)
)
after = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert after["unread"] == 0
# 别人的通知看不到(同工作区的另一个人)
other = await _member(client, owner, ws)
stranger_notes = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications?unread_only=false", headers=other["headers"]
)
)
assert stranger_notes["notifications"] == []
@pytest.mark.asyncio
async def test_ticket_payload_always_has_tags(client: AsyncClient) -> None:
"""回归:工单接口曾经自己拼 item 字典,漏了 `tags`,
前端 `item.tags.map(...)` 直接白屏。任何返回 item 的接口都得带 tags 数组。"""
owner = await login(client)
ws = await create_workspace(client, owner, "标签回归")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "带标签的任务")
tagged = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}",
json={"tag_names": ["权限", "运维"], "version": task["version"]},
headers=owner["headers"],
)
)
data = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"], "version": tagged["version"]},
headers=owner["headers"],
)
)
# 工单是独立的一条事项,标签不自动继承;关键是字段必须在、而且必须是数组
assert isinstance(data["task"]["tags"], list)
assert isinstance(data["ticket"]["tags"], list)
assert sorted(t["name"] for t in tagged["tags"]) == ["权限", "运维"]
# 列表与详情也要一致
listing = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/tickets?role=all", headers=owner["headers"])
)
assert isinstance(listing["tickets"][0]["tags"], list)
# 详情接口(任务与工单走同一个入口)都必须带 tags
task_detail = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/items/{task['id']}", headers=owner["headers"])
)
assert sorted(t["name"] for t in task_detail["tags"]) == ["权限", "运维"]
ticket_detail = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/items/{data['ticket']['id']}", headers=owner["headers"]
)
)
assert isinstance(ticket_detail["tags"], list)