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,139 @@
|
||||
// 通知与手工开单的端到端验收。
|
||||
// 两个真实用户:A 开单交给 B、B 收到通知并点开;A 改任务状态,B 也应收到提醒。
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const BASE = process.env.BASE ?? "http://127.0.0.1:5180";
|
||||
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||
const CHROME = process.env.CHROME ?? "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
|
||||
|
||||
const stamp = String(Date.now()).slice(-6);
|
||||
const phoneA = `135${stamp}00`;
|
||||
const phoneB = `138${String((Number(stamp) + 2222) % 1e8).padStart(8, "0")}`;
|
||||
const wsName = `通知${stamp}`;
|
||||
|
||||
let failed = 0;
|
||||
const ok = (cond, msg) => {
|
||||
if (cond) console.log(` PASS ${msg}`);
|
||||
else {
|
||||
failed += 1;
|
||||
console.log(` FAIL ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
async function login(page, phone) {
|
||||
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.fill("#phone", phone);
|
||||
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
|
||||
const code = (await page.locator("text=开发环境验证码").first().innerText()).match(/(\d{6})/)?.[1];
|
||||
await page.fill("#code", code);
|
||||
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 15000 });
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: CHROME,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const pageA = await ctxA.newPage();
|
||||
const errorsA = [];
|
||||
pageA.on("console", (m) => m.type() === "error" && errorsA.push(m.text()));
|
||||
pageA.on("response", (r) => r.status() >= 400 && errorsA.push(`${r.url()} ${r.status()}`));
|
||||
|
||||
try {
|
||||
await login(pageA, phoneA);
|
||||
console.log("PASS 1. A 登录");
|
||||
|
||||
// 建工作区
|
||||
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill(wsName);
|
||||
await pageA.getByRole("button", { name: "创建" }).click();
|
||||
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}/, { timeout: 20000 });
|
||||
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})/)[1];
|
||||
console.log("PASS 2. 建工作区");
|
||||
|
||||
// 邀请 B 加入
|
||||
await pageA.goto(`${BASE}/w/${wsId}/members`, { waitUntil: "domcontentloaded" });
|
||||
await pageA.getByRole("button", { name: "邀请成员" }).click();
|
||||
await pageA.waitForSelector("text=/join/", { timeout: 15000 });
|
||||
const link = await pageA
|
||||
.locator("text=/join/")
|
||||
.first()
|
||||
.innerText()
|
||||
.then((t) => t.match(/https?:\/\/\S+join\/\S+/)?.[0]);
|
||||
ok(Boolean(link), "3. 拿到邀请链接");
|
||||
|
||||
const ctxB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const pageB = await ctxB.newPage();
|
||||
const errorsB = [];
|
||||
pageB.on("console", (m) => m.type() === "error" && errorsB.push(m.text()));
|
||||
pageB.on("response", (r) => r.status() >= 400 && errorsB.push(`${r.url()} ${r.status()}`));
|
||||
|
||||
await login(pageB, phoneB);
|
||||
await pageB.goto(link, { waitUntil: "domcontentloaded" });
|
||||
await pageB.waitForURL(new RegExp(`/w/${wsId}`), { timeout: 20000 });
|
||||
console.log("PASS 4. B 从邀请链接登录后自动加入");
|
||||
|
||||
// A 手工开一张工单交给 B
|
||||
await pageA.goto(`${BASE}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
|
||||
await pageA.getByRole("button", { name: "新建工单" }).click();
|
||||
await pageA.getByLabel("要办什么").fill(`核对 9 月发票${stamp}`);
|
||||
await pageA.getByLabel("交给谁").selectOption({ index: 1 });
|
||||
await pageA.getByRole("button", { name: "开单并通知对方" }).click();
|
||||
await pageA.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "5. 手工工单开出(不经过任何任务)");
|
||||
|
||||
// 接口确认它确实是 kind=ticket 且没有来源任务
|
||||
const created = await pageA.evaluate(
|
||||
async (ws) => {
|
||||
const t = localStorage.getItem("dadao.access");
|
||||
const r = await fetch(`/api/v1/workspaces/${ws}/tickets?role=requested`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
return r.json();
|
||||
},
|
||||
wsId,
|
||||
);
|
||||
const t0 = created.data.tickets[0];
|
||||
ok(t0?.kind === "ticket" && t0?.ticket?.parent_item_id === null, "6. 手工单没有来源任务");
|
||||
|
||||
// B 收到通知
|
||||
await pageB.goto(`${BASE}/w/${wsId}/notifications`, { waitUntil: "domcontentloaded" });
|
||||
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "7. B 收到「派给你的工单」通知");
|
||||
|
||||
const unreadBefore = await pageB.locator("li.card").count();
|
||||
ok(unreadBefore >= 1, "8. 未读列表里有条目");
|
||||
|
||||
// 点开通知:跳到工单页
|
||||
await pageB.locator("li.card").first().click();
|
||||
await pageB.waitForURL(/\/tickets\?open=/, { timeout: 20000 });
|
||||
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "9. 点通知跳到工单并展开那张单");
|
||||
|
||||
// 标记已读后未读数归零
|
||||
const unread = await pageB.evaluate(async (ws) => {
|
||||
const t = localStorage.getItem("dadao.access");
|
||||
const r = await fetch(`/api/v1/workspaces/${ws}/notifications/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
return (await r.json()).data.unread;
|
||||
}, wsId);
|
||||
ok(unread === 0, "10. 点开后未读数归零");
|
||||
|
||||
ok(errorsA.length === 0, `11. A 无报错${errorsA.slice(0, 2).join(" | ")}`);
|
||||
ok(errorsB.length === 0, `12. B 无报错${errorsB.slice(0, 2).join(" | ")}`);
|
||||
|
||||
await pageB.screenshot({ path: "e2e/artifacts/notifications.png" });
|
||||
await pageA.screenshot({ path: "e2e/artifacts/ticket-new.png" });
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.log("FAIL 异常:", err.message);
|
||||
await pageA.screenshot({ path: "e2e/artifacts/notifications-fail-a.png" }).catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? "\nALL PASS" : `\n${failed} 项失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user