M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。 通知(backend/app/services/notify.py 统一出口) - 七类触发:task_assigned、task_status_changed、item_commented、 ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked - 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断 - 已读:GET /notifications、/unread-count、POST /read、/read-all 手工开单 - POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item, ticket_ext.parent_item_id 为空,验收通过不回流任务 - 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上") 前端 - Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标); 工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新; 任务与工单支持 ?open= 深链落地即展开 - 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框 验证 - pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例) - ruff check / format --check 全 0 - tsc --noEmit 通过;vite build 成功 - web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零) - 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证, 装回 JDK 17 后第一件事是 assembleDebug
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
"""通知与手工开单。
|
||||
|
||||
这两个功能都有一个共同的翻车方式:**看起来有,实际没送到**。
|
||||
所以要断言的从来不是"接口 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
|
||||
Reference in New Issue
Block a user