495 lines
18 KiB
Python
495 lines
18 KiB
Python
"""通知与手工开单。
|
|
|
|
这两个功能都有一个共同的翻车方式:**看起来有,实际没送到**。
|
|
所以要断言的从来不是"接口 200",而是"该收到的人收到了、不该收到的人没收到"。
|
|
|
|
1. 自己改自己的任务不该给自己发通知(否则未读数两天就没人看了);
|
|
2. 通知必须落到具体的人头上——被指派人、创建人,而不是"整个工作区";
|
|
3. 手工开的工单要和升级来的工单走同一条路:能接受、能提交、能验收;
|
|
4. 指派给工作区外的人必须当场拒绝,否则任务会挂在一个没人看得到的名字上。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from app.services import reminders
|
|
|
|
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
|
|
|
|
|
|
async def _set_status(
|
|
client: AsyncClient, user: dict[str, Any], ws_id: str, item: dict, status: str
|
|
) -> dict:
|
|
return unwrap(
|
|
await client.post(
|
|
f"{V1}/workspaces/{ws_id}/items/{item['id']}/status",
|
|
json={"status": status, "version": item["version"]},
|
|
headers=user["headers"],
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_completion_notifies_the_creator(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"], "季度盘点")
|
|
|
|
await _set_status(client, helper, ws["id"], item, "done")
|
|
|
|
owner_inbox = await _inbox(client, owner, ws["id"])
|
|
completed = [n for n in owner_inbox if n["kind"] == "task_completed"]
|
|
assert len(completed) == 1
|
|
assert completed[0]["item_id"] == item["id"]
|
|
assert completed[0]["item_kind"] == "task"
|
|
assert "已完成" in completed[0]["title"]
|
|
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_completed"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_status_sends_the_same_notifications(client: AsyncClient) -> None:
|
|
"""看板批量拖拽不能静默:拖 3 条完成,创建人就该收到 3 条完成通知。"""
|
|
owner = await login(client)
|
|
ws = await create_workspace(client, owner, "批量完成通知")
|
|
helper = await _member(client, owner, ws, "member")
|
|
items = [
|
|
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], f"批量任务 {i}")
|
|
for i in range(3)
|
|
]
|
|
|
|
unwrap(
|
|
await client.post(
|
|
f"{V1}/workspaces/{ws['id']}/items/batch-status",
|
|
json={
|
|
"status": "done",
|
|
"items": [{"id": item["id"], "version": item["version"]} for item in items],
|
|
},
|
|
headers=helper["headers"],
|
|
)
|
|
)
|
|
|
|
owner_inbox = await _inbox(client, owner, ws["id"])
|
|
completed = {n["item_id"] for n in owner_inbox if n["kind"] == "task_completed"}
|
|
assert completed == {item["id"] for item in items}
|
|
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_completed"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_knowledge_notifications_follow_the_flow(client: AsyncClient) -> None:
|
|
"""知识库的四个节点都要有人知道:新草稿、待核对、已发布、已归档。"""
|
|
owner = await login(client)
|
|
ws = await create_workspace(client, owner, "知识通知")
|
|
admin = await _member(client, owner, ws, "admin")
|
|
|
|
doc = unwrap(
|
|
await client.post(
|
|
f"{V1}/workspaces/{ws['id']}/knowledge",
|
|
json={"title": "报价流程", "body_md": "先核价,再报给客户"},
|
|
headers=admin["headers"],
|
|
)
|
|
)
|
|
|
|
# 管理员新建草稿 → 其他管理员(owner)收到
|
|
created = [
|
|
n
|
|
for n in await _inbox(client, owner, ws["id"])
|
|
if n["kind"] == "knowledge_draft_created" and n["item_id"] == doc["id"]
|
|
]
|
|
assert len(created) == 1
|
|
assert created[0]["item_kind"] == "knowledge"
|
|
|
|
# 提交核对 → owner 收到"待核对"
|
|
doc = unwrap(
|
|
await client.post(
|
|
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
|
json={"action": "submit_review", "version": doc["version"]},
|
|
headers=admin["headers"],
|
|
)
|
|
)
|
|
assert any(
|
|
n["kind"] == "knowledge_review_requested" and n["item_id"] == doc["id"]
|
|
for n in await _inbox(client, owner, ws["id"])
|
|
)
|
|
|
|
# 发布 → 作者(admin)收到
|
|
doc = unwrap(
|
|
await client.post(
|
|
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
|
json={"action": "publish", "version": doc["version"]},
|
|
headers=owner["headers"],
|
|
)
|
|
)
|
|
published = [
|
|
n
|
|
for n in await _inbox(client, admin, ws["id"])
|
|
if n["kind"] == "knowledge_published" and n["item_id"] == doc["id"]
|
|
]
|
|
assert len(published) == 1
|
|
assert published[0]["item_kind"] == "knowledge"
|
|
|
|
# 归档 → 作者(admin)收到
|
|
unwrap(
|
|
await client.post(
|
|
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
|
json={"action": "archive", "version": doc["version"]},
|
|
headers=owner["headers"],
|
|
)
|
|
)
|
|
assert any(
|
|
n["kind"] == "knowledge_archived" and n["item_id"] == doc["id"]
|
|
for n in await _inbox(client, admin, ws["id"])
|
|
)
|
|
|
|
|
|
async def _task_with_due(
|
|
client: AsyncClient,
|
|
owner: dict[str, Any],
|
|
ws_id: str,
|
|
assignee_id: str,
|
|
title: str,
|
|
due_at: datetime,
|
|
status: str = "todo",
|
|
) -> dict:
|
|
return unwrap(
|
|
await client.post(
|
|
f"{V1}/workspaces/{ws_id}/items",
|
|
json={
|
|
"title": title,
|
|
"status": status,
|
|
"assignee_id": assignee_id,
|
|
"due_at": due_at.isoformat(),
|
|
},
|
|
headers=owner["headers"],
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reminders_cover_due_soon_and_overdue_once(client: AsyncClient) -> None:
|
|
"""扫描器只负责"该提醒的人收到且只收到一次",不负责业务判断。"""
|
|
owner = await login(client)
|
|
ws = await create_workspace(client, owner, "到期提醒")
|
|
helper = await _member(client, owner, ws, "member")
|
|
now = datetime.now(timezone.utc)
|
|
|
|
soon = await _task_with_due(
|
|
client, owner, ws["id"], helper["user_id"], "明早交付", now + timedelta(hours=3)
|
|
)
|
|
late = await _task_with_due(
|
|
client, owner, ws["id"], helper["user_id"], "昨天该交", now - timedelta(days=1)
|
|
)
|
|
await _task_with_due(
|
|
client,
|
|
owner,
|
|
ws["id"],
|
|
helper["user_id"],
|
|
"已经做完的",
|
|
now + timedelta(hours=1),
|
|
status="done",
|
|
)
|
|
|
|
first = await reminders.tick()
|
|
assert first["sent"] == 2, first
|
|
|
|
inbox = await _inbox(client, helper, ws["id"])
|
|
due_soon = [n for n in inbox if n["kind"] == "task_due_soon"]
|
|
overdue = [n for n in inbox if n["kind"] == "task_overdue"]
|
|
assert [n["item_id"] for n in due_soon] == [soon["id"]]
|
|
assert [n["item_id"] for n in overdue] == [late["id"]]
|
|
assert "截止时间" in due_soon[0]["body"]
|
|
assert "已逾期" in overdue[0]["title"]
|
|
|
|
# 第二轮不能重复写:dedupe_key 兜底
|
|
second = await reminders.tick()
|
|
assert second["sent"] == 0, second
|
|
inbox = await _inbox(client, helper, ws["id"])
|
|
assert len([n for n in inbox if n["kind"] == "task_due_soon"]) == 1
|
|
assert len([n for n in inbox if n["kind"] == "task_overdue"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reminders_stay_inside_each_workspace(client: AsyncClient) -> None:
|
|
"""提醒是平台级扫描,但写通知必须回到各自工作区,不能串租户。"""
|
|
owner_a = await login(client)
|
|
ws_a = await create_workspace(client, owner_a, "提醒甲")
|
|
helper_a = await _member(client, owner_a, ws_a, "member")
|
|
|
|
owner_b = await login(client)
|
|
ws_b = await create_workspace(client, owner_b, "提醒乙")
|
|
helper_b = await _member(client, owner_b, ws_b, "member")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
item_a = await _task_with_due(
|
|
client, owner_a, ws_a["id"], helper_a["user_id"], "甲的逾期", now - timedelta(hours=2)
|
|
)
|
|
item_b = await _task_with_due(
|
|
client, owner_b, ws_b["id"], helper_b["user_id"], "乙的逾期", now - timedelta(hours=2)
|
|
)
|
|
|
|
result = await reminders.tick()
|
|
assert result["sent"] == 2, result
|
|
|
|
inbox_a = await _inbox(client, helper_a, ws_a["id"])
|
|
inbox_b = await _inbox(client, helper_b, ws_b["id"])
|
|
assert {n["item_id"] for n in inbox_a if n["kind"] == "task_overdue"} == {item_a["id"]}
|
|
assert {n["item_id"] for n in inbox_b if n["kind"] == "task_overdue"} == {item_b["id"]}
|