"""通知与手工开单。 这两个功能都有一个共同的翻车方式:**看起来有,实际没送到**。 所以要断言的从来不是"接口 200",而是"该收到的人收到了、不该收到的人没收到"。 1. 自己改自己的任务不该给自己发通知(否则未读数两天就没人看了); 2. 通知必须落到具体的人头上——被指派人、创建人,而不是"整个工作区"; 3. 手工开的工单要和升级来的工单走同一条路:能接受、能提交、能验收; 4. 指派给工作区外的人必须当场拒绝,否则任务会挂在一个没人看得到的名字上。 """ 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 _inbox(client: AsyncClient, user: dict[str, Any], ws_id: str) -> list[dict]: page = unwrap( await client.get( f"{V1}/workspaces/{ws_id}/notifications", params={"unread_only": "false"}, headers=user["headers"], ) ) return page["notifications"] async def _task_assigned_to( client: AsyncClient, owner: dict[str, Any], ws_id: str, assignee_id: str, title: str ) -> dict: return unwrap( await client.post( f"{V1}/workspaces/{ws_id}/items", json={"title": title, "status": "todo", "assignee_id": assignee_id}, headers=owner["headers"], ) ) @pytest.mark.asyncio async def test_task_status_change_notifies_the_other_side(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "通知测试") helper = await _member(client, owner, ws, "member") item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "月度对账") # owner 指派给 helper 时,helper 应该收到通知 helper_inbox = await _inbox(client, helper, ws["id"]) assert any(n["kind"] == "task_assigned" and n["item_id"] == item["id"] for n in helper_inbox) # owner 自己指派的,不该收到 assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "task_assigned"] # helper 把状态改成进行中 → owner(创建人)应该收到 unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/items/{item['id']}/status", json={"status": "doing", "version": item["version"]}, headers=helper["headers"], ) ) owner_inbox = await _inbox(client, owner, ws["id"]) changed = [n for n in owner_inbox if n["kind"] == "task_status_changed"] assert len(changed) == 1 assert "进行中" in changed[0]["body"] assert changed[0]["item_title"] == "月度对账" # helper 是操作者本人,不该收到自己那次改动的通知 assert not [ n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_status_changed" ] @pytest.mark.asyncio async def test_assignee_must_be_a_member(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "越界指派") outsider = await login(client) # 没加入这个工作区 resp = await client.post( f"{V1}/workspaces/{ws['id']}/items", json={"title": "指派给外人", "assignee_id": outsider["user_id"]}, headers=owner["headers"], ) assert resp.status_code == 400, resp.text assert resp.json()["code"] == 1001 item = unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/items", json={"title": "不指派"}, headers=owner["headers"], ) ) resp = await client.patch( f"{V1}/workspaces/{ws['id']}/items/{item['id']}", json={"assignee_id": outsider["user_id"], "version": item["version"]}, headers=owner["headers"], ) assert resp.status_code == 400, resp.text @pytest.mark.asyncio async def test_manual_ticket_runs_the_whole_flow(client: AsyncClient) -> None: """手工开的工单不是"半个工单":接受、提交、验收都得能走。""" owner = await login(client) ws = await create_workspace(client, owner, "手工开单") helper = await _member(client, owner, ws, "member") ticket = unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/tickets", json={ "title": "帮忙核对一遍报价单", "body_md": "客户催得急,今天下班前要", "assignee_id": helper["user_id"], "priority": 2, }, headers=owner["headers"], ) ) assert ticket["status"] == "open" assert ticket["kind"] == "ticket" assert ticket["ticket"]["requester_id"] == owner["user_id"] assert ticket["ticket"]["assignee_id"] == helper["user_id"] assert ticket["ticket"]["parent_item_id"] is None # 没有来源任务 # 出现在被指派人的工单列表里 assigned = unwrap( await client.get( f"{V1}/workspaces/{ws['id']}/tickets", params={"role": "assigned"}, headers=helper["headers"], ) ) assert [t["id"] for t in assigned["tickets"]] == [ticket["id"]] # 被指派人收到通知 inbox = await _inbox(client, helper, ws["id"]) assert any(n["kind"] == "ticket_assigned" and n["item_id"] == ticket["id"] for n in inbox) # 接受 → 开工 → 提交 → 验收通过 for action, body in ( ("accept", {}), ("start", {}), ("submit", {"note": "核对完了"}), ): unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/{action}", json=body, headers=helper["headers"], ) ) done = unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/verify", json={"approve": True, "resolution_md": "确认了三处差异"}, headers=owner["headers"], ) ) assert done["status"] == "done" # 验收是需求人做的,通知该发给办理人;需求人自己不该收到"你通过了"这种通知 helper_inbox = await _inbox(client, helper, ws["id"]) assert any(n["kind"] == "ticket_approved" for n in helper_inbox) assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "ticket_approved"] @pytest.mark.asyncio async def test_manual_ticket_rejects_outsider(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "手工开单越界") outsider = await login(client) resp = await client.post( f"{V1}/workspaces/{ws['id']}/tickets", json={"title": "给外人开单", "assignee_id": outsider["user_id"]}, headers=owner["headers"], ) assert resp.status_code == 400, resp.text assert resp.json()["code"] == 1001 @pytest.mark.asyncio async def test_comment_notifies_the_other_party(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "评论通知") helper = await _member(client, owner, ws, "member") item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "整理发票") unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/items/{item['id']}/comments", json={"body_md": "发票抬头要写公司全称"}, headers=helper["headers"], ) ) owner_inbox = await _inbox(client, owner, ws["id"]) assert any(n["kind"] == "item_commented" for n in owner_inbox) # 操作者本人不该收到 assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "item_commented"] @pytest.mark.asyncio async def test_unread_count_and_read_all(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "未读数") helper = await _member(client, owner, ws, "member") await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第一件") await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第二件") count = unwrap( await client.get( f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"] ) ) assert count["unread"] == 2 first = (await _inbox(client, helper, ws["id"]))[0] unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/notifications/{first['id']}/read", headers=helper["headers"], ) ) count = unwrap( await client.get( f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"] ) ) assert count["unread"] == 1 marked = unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/notifications/read-all", headers=helper["headers"] ) ) assert marked["marked"] == 1 count = unwrap( await client.get( f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"] ) ) assert count["unread"] == 0