修复工单内容格式与财务页面接口契约
This commit is contained in:
1 parent
90989f8f8a
commit
d330ad01cc
8 files changed
+551
-149
No files matched your search
@@ -0,0 +1,423 @@
|
||||
/** 工单内容/部门与账务分页/能力开关的契约回归;只控制本地 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 = " <b>用户代码</b>\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 = " <b>字面代码</b>\n 空格 <tag>";
|
||||
const quoted = "<i>字面引用</i>\n下一行";
|
||||
const replies = [
|
||||
{
|
||||
id: 11,
|
||||
content: quoted,
|
||||
content_format: "text",
|
||||
type: "Client",
|
||||
client_name: "audit-user",
|
||||
create_time: 1730000001,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
content: "<strong>历史客服排版</strong>",
|
||||
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 = " <script>字面错误日志</script>\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();
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user