/** 工单内容/部门与账务分页/能力开关的契约回归;只控制本地 HTTP 边界。 */ import assert from "node:assert/strict"; import { test } from "node:test"; import { appOrigin, backendOrigin, eventually, login, scenario, } from "./helpers/console-browser.mjs"; async function reply(route, data) { return route.fulfill({ json: { status: 200, msg: "", data }, headers: { "Access-Control-Allow-Origin": appOrigin }, }); } async function financeLists(page) { const paths = [ "/transaction", "/credit", "/withdraw", "/order", "/refund/pending/amount", "/account/credit/freeze", ]; await page.route( (url) => url.origin === backendOrigin && paths.includes(url.pathname.replace("/console/v1", "")), (route) => reply( route, new URL(route.request().url()).pathname.endsWith( "/refund/pending/amount", ) ? { amount: "0.00" } : { list: [], count: 0, total_amount: "0.00" }, ), ); } function ticket(content, replies = []) { return { id: 23, ticket_num: "LOCAL-23", title: "内容契约验收", content, content_format: "text", attachment: [], replies, status: "待回复", can_operate: 1, create_time: 1730000000, username: "audit-user", client_id: 1, color: "#777", }; } test( "ticket creation submits literal text and its one effective department", { timeout: 30000 }, (t) => scenario(t, { loginMode: "password-only" }, async (page) => { const content = " 用户代码\n 第二行 <tag>\n尾行 "; const writes = []; await page.route( (url) => url.origin === backendOrigin && url.pathname.startsWith("/console/v1/ticket"), async (route) => { const request = route.request(); const path = new URL(request.url()).pathname.replace( "/console/v1", "", ); if (path === "/ticket/config") return reply(route, { ticket_notice_open: 0 }); if (["/ticket/type", "/ticket/department"].includes(path)) return reply(route, { list: [ { id: 1, name: "账务支持" }, { id: 2, name: "网络支持" }, ], }); if (path === "/ticket" && request.method() === "POST") { writes.push(request.postDataJSON()); return reply(route, { id: 23 }); } if (path === "/ticket/23") return reply(route, { ticket: ticket(content) }); return route.fallback(); }, ); await login(page, "/tickets/create"); await page.getByRole("radio", { name: "网络支持", exact: true }).check(); await page .getByRole("textbox", { name: "工单标题", exact: true }) .fill("内容契约验收"); await page .getByRole("textbox", { name: "工单内容", exact: true }) .fill(content); await page.getByRole("button", { name: "创建工单", exact: true }).click(); await eventually( () => writes, (rows) => rows.length === 1, "工单必须提交一次", ); assert.equal(writes[0].ticket_type_id, 2); assert.equal(writes[0].content, content); assert.equal(Object.hasOwn(writes[0], "admin_role_id"), false); await page.waitForURL((url) => url.pathname === "/tickets/23"); assert.equal( await page.locator(".message-body").first().textContent(), content, ); return { effectiveDepartment: 2, contentKeptLiteral: true, createdTickets: 1, }; }), ); test( "ticket text, historical HTML, reply snapshots and reply drafts retain their declared format", { timeout: 30000 }, (t) => scenario(t, { loginMode: "password-only" }, async (page) => { const initial = " 字面代码\n 空格 <tag>"; const quoted = "字面引用\n下一行"; const replies = [ { id: 11, content: quoted, content_format: "text", type: "Client", client_name: "audit-user", create_time: 1730000001, }, { id: 12, content: "历史客服排版", content_format: "html", type: "Admin", admin_name: "本地客服", create_time: 1730000002, }, { id: 13, content: "快照回复", content_format: "text", type: "Client", client_name: "audit-user", create_time: 1730000003, quote_reply_id: 11, quote_info: { content: quoted, content_format: "text", sender_name: "audit-user", attachment: [], }, }, ]; const data = ticket(initial, replies); const writes = []; await page.route( (url) => url.origin === backendOrigin && url.pathname.startsWith("/console/v1/ticket/"), async (route) => { const request = route.request(); const path = new URL(request.url()).pathname.replace( "/console/v1", "", ); if (path === "/ticket/config") return reply(route, { ticket_notice_open: 0 }); if (path === "/ticket/23") return reply(route, { ticket: data }); if (path === "/ticket/23/reply") { const body = request.postDataJSON(); writes.push(body); data.replies.push({ id: 14, ...body, content_format: "text", type: "Client", client_name: "audit-user", create_time: 1730000004, quote_info: { content: quoted, content_format: "text", sender_name: "audit-user", }, }); return reply(route, {}); } return route.fallback(); }, ); await login(page, "/tickets/23"); const messages = page.locator(".thread .message"); await messages.nth(3).waitFor(); const first = messages.first().locator(".message-body"); assert.equal(await first.textContent(), initial); assert.equal(await first.locator("b").count(), 0); assert.equal( await first.evaluate((node) => getComputedStyle(node).whiteSpace), "pre-wrap", ); assert.equal( await messages.nth(2).locator(".message-body strong").textContent(), "历史客服排版", ); assert.equal( await messages.nth(3).locator(".message-quote div").textContent(), quoted, ); assert.equal( await messages.nth(3).locator(".message-quote i").count(), 0, ); await messages .nth(1) .getByRole("button", { name: "引用", exact: true }) .click(); assert.equal( await page.locator(".reply-quote > div > div").textContent(), quoted, ); const draft = " \n 保留缩进 "; await page .getByRole("textbox", { name: "回复内容", exact: true }) .fill(draft); await page.getByRole("button", { name: "提交回复", exact: true }).click(); await eventually( () => writes, (rows) => rows.length === 1, "回复必须提交一次", ); assert.equal(writes[0].content, draft); assert.equal(writes[0].quote_reply_id, 11); await messages.nth(4).waitFor(); assert.equal( await messages.nth(4).locator(".message-body").textContent(), draft, ); return { textAndSnapshotsKeptLiteral: true, historicalHtmlRendered: true, replyKeptWhitespace: true, quotedReply: 11, }; }), ); test( "full freeze lists paginate locally without duplicate rows or extra reads", { timeout: 30000 }, (t) => scenario(t, { loginMode: "password-only" }, async (page) => { await financeLists(page); let reads = 0; const list = Array.from({ length: 21 }, (_, index) => ({ id: index + 1, amount: "10.00", client_notes: `冻结记录-${index + 1}`, create_time: 1730000000, })); await page.route( (url) => url.origin === backendOrigin && url.pathname === "/console/v1/account/credit/freeze", (route) => { reads++; return reply(route, { list, count: list.length }); }, ); await login(page, "/finance?tab=freeze"); const panel = page.getByRole("tabpanel").filter({ visible: true }); await panel.getByText("冻结记录-1", { exact: true }).waitFor(); assert.equal(await panel.locator("tbody tr").count(), 20); await panel.locator(".p-paginator-next").click(); await panel.getByText("冻结记录-21", { exact: true }).waitFor(); assert.equal(await panel.locator("tbody tr").count(), 1); assert.equal( await panel.getByText("冻结记录-1", { exact: true }).count(), 0, ); assert.equal(reads, 1); return { firstPageRows: 20, secondPageRows: 1, totalReads: reads }; }), ); for (const mode of ["enabled", "disabled", "missing", "failure"]) { test( `balance reminders follow the ${mode} site capability`, { timeout: 30000 }, (t) => scenario(t, { loginMode: "password-only" }, async (page) => { const writes = []; await financeLists(page); // 配置失败场景先经真实表单建立会话,再整页刷新触发新的公共配置读取。 if (mode === "failure") await login(page, "/finance"); await page.route( (url) => url.origin === backendOrigin && url.pathname === "/console/v1/common", async (route) => { if (mode === "failure") return route.fulfill({ json: { status: 400, msg: "本地配置暂不可用", data: {} }, headers: { "Access-Control-Allow-Origin": appOrigin }, }); const response = await route.fetch(); const payload = await response.json(); if (mode === "missing") delete payload.data.balance_notice_show; else payload.data.balance_notice_show = mode === "enabled" ? 1 : 0; return route.fulfill({ response, json: payload }); }, ); await page.route( (url) => url.origin === backendOrigin && url.pathname === "/console/v1/account/credit/remind", (route) => { writes.push(route.request().postDataJSON()); return reply(route, {}); }, ); if (mode === "failure") await page.goto(`${appOrigin}/finance`); else await login(page, "/finance"); const panel = page.locator(".reminder-panel"); const save = panel.getByRole("button", { name: "保存", exact: true }); await save.waitFor(); if (mode === "enabled") { await panel.locator("#credit-reminder-enabled").check(); await panel.locator("#credit-reminder-amount").fill("25.00"); await save.click(); await eventually( () => writes, (rows) => rows.length === 1, "开启能力后可保存提醒", ); assert.deepEqual(writes[0], { credit_remind: 1, credit_remind_amount: 25, }); } else { assert.equal(await save.isDisabled(), true); assert.equal( await panel.locator("#credit-reminder-enabled").isDisabled(), true, ); assert.equal( await panel.locator("#credit-reminder-amount").isDisabled(), true, ); assert.equal(await panel.getByRole("status").isVisible(), true); assert.equal(writes.length, 0); } return { capability: mode, saved: mode === "enabled", writes: writes.length, }; }), ); } test( "balance reminders stay disabled while a failed startup configuration is being reloaded", { timeout: 30000 }, (t) => scenario(t, { loginMode: "password-only" }, async (page) => { await financeLists(page); await login(page, "/finance"); let reads = 0; let release; const held = new Promise((resolve) => { release = resolve; }); await page.route( (url) => url.origin === backendOrigin && url.pathname === "/console/v1/common", async (route) => { if (++reads === 1) return route.fulfill({ json: { status: 400, msg: "本地首次配置读取失败", data: {} }, headers: { "Access-Control-Allow-Origin": appOrigin }, }); await held; const response = await route.fetch(); const payload = await response.json(); payload.data.balance_notice_show = 1; return route.fulfill({ response, json: payload }); }, ); try { await page.goto(`${appOrigin}/finance`); const panel = page.locator(".reminder-panel"); const save = panel.getByRole("button", { name: "保存", exact: true }); await save.waitFor(); assert.equal(await save.isDisabled(), true); assert.equal( await panel.locator("#credit-reminder-enabled").isDisabled(), true, ); assert.equal(await panel.getByRole("status").isVisible(), true); release(); await eventually( () => save.isEnabled(), Boolean, "配置确认开启后才允许保存", ); return { pendingCapabilityDisabled: true, enabledAfterReload: true }; } finally { release(); } }), );