Files
ShiErFeng/shierfeng-fastapi-app/tests/business-contract.test.mjs
T

334 lines
14 KiB
JavaScript

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
const appRoot = fileURLToPath(new URL("..", import.meta.url));
test("PDA 的 H5、App 和小程序统一使用 Sylvan 品牌标识", async () => {
const [config, loginPage, registerPage, aboutPage, indexHtml, fullLogo, markPng] =
await Promise.all([
readFile(path.join(appRoot, "src/config.js"), "utf8"),
readFile(path.join(appRoot, "src/pages/login.vue"), "utf8"),
readFile(path.join(appRoot, "src/pages/register.vue"), "utf8"),
readFile(path.join(appRoot, "src/pages/mine/about/index.vue"), "utf8"),
readFile(path.join(appRoot, "index.html"), "utf8"),
readFile(path.join(appRoot, "src/static/sylvan-logo.svg"), "utf8"),
readFile(path.join(appRoot, "src/static/sylvan-mark.png")),
]);
assert.match(config, /logo:\s*["']\/static\/sylvan-logo\.png["']/);
assert.match(loginPage, /globalConfig\.appInfo\.logo/);
assert.match(registerPage, /globalConfig\.appInfo\.logo/);
assert.match(aboutPage, /src=["']\/static\/sylvan-logo\.png["']/);
assert.match(indexHtml, /href=["']\/static\/favicon\.ico["']/);
assert.match(fullLogo, /viewBox="0 0 516\.1 194\.52"/);
assert.equal(markPng.subarray(1, 4).toString("ascii"), "PNG");
assert.doesNotMatch(`${config}\n${aboutPage}`, /\/static\/logo(?:200)?\.png/);
assert.doesNotMatch(fullLogo, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i);
});
const apiContracts = {
"src/api/business/inbound.js": [
["listInbound", "'/business/inbound/list'", "get"],
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
["scanInboundPallet", "'/business/inbound/scan/' + qrToken", "get"],
["getInbound", "'/business/inbound/' + inboundId", "get"],
["addInbound", "'/business/inbound'", "post"],
["updateInbound", "'/business/inbound'", "put"],
["confirmInbound", "'/business/inbound/' + inboundId + '/confirm'", "put"],
],
"src/api/business/filling.js": [
["listFilling", "'/business/filling/list'", "get"],
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
["scanFillingPallet", "'/business/filling/scan/' + qrToken", "get"],
["getFilling", "'/business/filling/' + fillingId", "get"],
["addFilling", "'/business/filling'", "post"],
],
"src/api/business/shipment.js": [
["listShipment", "'/business/shipment/list'", "get"],
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
["scanShipmentPallet", "'/business/shipment/scan/' + qrToken", "get"],
["getShipment", "'/business/shipment/' + shipmentId", "get"],
["addShipment", "'/business/shipment'", "post"],
["updateShipment", "'/business/shipment'", "put"],
["confirmShipment", "'/business/shipment/' + shipmentId + '/confirm'", "put"],
],
"src/api/business/usage.js": [
["listUsage", "'/business/usage/list'", "get"],
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
["scanUsagePallet", "'/business/usage/scan/' + qrToken", "get"],
["getUsage", "'/business/usage/' + usageId", "get"],
["addUsage", "'/business/usage'", "post"],
],
"src/api/business/return.js": [
["listReturn", "'/business/return/list'", "get"],
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
["scanReturnPallet", "'/business/return/scan/' + qrToken", "get"],
["requestReturn", "'/business/return/request'", "post"],
["getReturn", "'/business/return/' + returnId", "get"],
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
],
};
function sourceForFunction(source, functionName) {
const match = source.match(
new RegExp(`export function ${escapeRegExp(functionName)}\\s*\\(`),
);
const start = match?.index ?? -1;
assert.notEqual(start, -1, `${functionName} 导出不存在`);
const end = source.indexOf("\n}", start);
return source.slice(start, end === -1 ? source.length : end + 2);
}
test("五个 PDA API 模块使用现有业务路径和 HTTP 方法", async (t) => {
for (const [relativePath, contracts] of Object.entries(apiContracts)) {
await t.test(relativePath, async () => {
const source = await readFile(path.join(appRoot, relativePath), "utf8");
for (const [functionName, url, method] of contracts) {
const functionSource = sourceForFunction(source, functionName);
const normalizedFunctionSource = functionSource.replaceAll("'", '"');
const normalizedUrl = url.replaceAll("'", '"');
assert.match(
normalizedFunctionSource,
new RegExp(`url:\\s*${escapeRegExp(normalizedUrl)}`),
);
assert.match(functionSource, new RegExp(`method:\\s*["']${method}["']`));
}
});
}
});
test("扫码值只去除首尾空白并拒绝空值", async () => {
const { normalizeScanValue } = await importSource("src/utils/palletScan.js");
assert.equal(normalizeScanValue(" QR-001\n"), "QR-001");
assert.throws(() => normalizeScanValue(" \n\t "), /二维码不能为空/);
});
test("同一扫码值只在短时窗口内被识别为重复", async () => {
const { isDuplicateScan } = await importSource("src/utils/palletScan.js");
assert.equal(isDuplicateScan("QR-001", "QR-001", 1200, 1000, 500), true);
assert.equal(isDuplicateScan("QR-001", "QR-002", 1200, 1000, 500), false);
assert.equal(isDuplicateScan("QR-001", "QR-001", 1801, 1000, 500), false);
});
test("幂等键优先使用 randomUUID,回退时使用 UUID v4 安全随机字节", async () => {
const { createIdempotencyKey } = await importSource("src/utils/palletScan.js");
const expected = "12345678-1234-4123-8123-123456789abc";
assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected);
assert.equal(
createIdempotencyKey({
getRandomValues(bytes) {
bytes.fill(0);
return bytes;
},
}),
"00000000-0000-4000-8000-000000000000",
);
});
test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async () => {
const source = await readFile(
path.join(appRoot, "src/components/business/PalletScanInput.vue"),
"utf8",
);
assert.match(source, /@confirm=["']handleSubmit["']/);
assert.match(source, /normalizeScanValue/);
assert.match(source, /isDuplicateScan/);
assert.match(source, /emit\(["']scan["']/);
assert.match(source, /emit\(["']invalid["']/);
assert.match(source, /emit\(["']duplicate["']/);
assert.match(source, /loading/);
assert.doesNotMatch(source, /uni\.scanCode|camera|摄像头/);
});
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
const pages = JSON.parse(
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
);
const pagePaths = new Set(pages.pages.map((page) => page.path));
for (const pagePath of [
"pages/business/inbound/index",
"pages/business/filling/index",
"pages/business/shipment/index",
"pages/business/usage/index",
"pages/business/return/index",
]) {
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
}
const workSource = await readFile(
path.join(appRoot, "src/pages/work/index.vue"),
"utf8",
);
assert.match(workSource, /hasPermiOr/);
assert.match(workSource, /business:inbound/);
assert.match(workSource, /business:filling/);
assert.match(workSource, /business:shipment/);
assert.match(workSource, /business:usage/);
assert.match(workSource, /business:return/);
assert.match(workSource, /pages\/business\/inbound\/index/);
assert.match(workSource, /pages\/business\/filling\/index/);
assert.match(workSource, /pages\/business\/shipment\/index/);
assert.match(workSource, /pages\/business\/usage\/index/);
assert.match(workSource, /pages\/business\/return\/index/);
});
test("PDA 测试模式附加批次请求头并在五个业务页持续提示", async () => {
const requestSource = await readFile(
path.join(appRoot, "src/utils/request.js"),
"utf8",
);
assert.match(requestSource, /X-Test-Batch-Id/);
assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/);
assert.match(requestSource, /uni\.\$emit\(["']test-batch-unavailable["']/);
assert.match(requestSource, /reLaunch\(\{\s*url:\s*["']\/pages\/work\/index["']/);
for (const page of ["inbound", "filling", "shipment", "usage", "return"]) {
const source = await readFile(
path.join(appRoot, `src/pages/business/${page}/index.vue`),
"utf8",
);
assert.match(source, /TestBatchBanner/);
}
});
test("PDA 不再暴露入库和发货草稿物理删除", async () => {
for (const file of ["src/api/business/inbound.js", "src/api/business/shipment.js"]) {
const source = await readFile(path.join(appRoot, file), "utf8");
assert.doesNotMatch(source, /delInbound|delShipment|method:\s*["']delete["']/);
}
for (const page of ["inbound", "shipment"]) {
const source = await readFile(
path.join(appRoot, `src/pages/business/${page}/index.vue`),
"utf8",
);
assert.doesNotMatch(source, /deleteDraft|deleteLoading|business:(?:inbound|shipment):remove/);
}
});
test("登录页只预填账号,不把管理员密码写入源码", async () => {
const source = await readFile(path.join(appRoot, "src/pages/login.vue"), "utf8");
assert.match(source, /username:\s*["']admin["']/);
assert.match(source, /password:\s*["']["']/);
assert.doesNotMatch(source, /password:\s*["'][^"']+["']/);
});
test("PDA 后端地址支持 VITE_APP_BASE_API 并保留本地默认值", async () => {
const source = await readFile(path.join(appRoot, "src/config.js"), "utf8");
assert.match(source, /import\.meta\.env\.VITE_APP_BASE_API/);
assert.match(source, /VITE_APP_BASE_API\s*\|\|\s*["']http:\/\/localhost:9099["']/);
});
test("入库页面遵循 UNSTOCKED 扫码、草稿和幂等确认契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/inbound/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanInboundPallet/);
assert.match(source, /UNSTOCKED/);
assert.match(source, /warehouseId/);
assert.match(source, /palletIds/);
assert.match(source, /getInbound\(/);
assert.match(source, /updateInbound/);
assert.match(source, /confirmInbound/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:inbound:(?:query|add|edit|confirm)/);
});
test("发货页面遵循来源仓库、目标现场和整单确认契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/shipment/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanShipmentPallet/);
assert.match(source, /FILLED_IN_WAREHOUSE/);
assert.match(source, /remainingPackages/);
assert.match(source, /sourceWarehouseId/);
assert.match(source, /targetSiteId/);
assert.match(source, /palletIds/);
assert.match(source, /getShipment\(/);
assert.match(source, /updateShipment/);
assert.match(source, /confirmShipment/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:shipment:(?:query|add|edit|confirm)/);
});
test("装填页面遵循空托盘状态和 FillingCreateModel 字段契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/filling/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanFillingPallet/);
assert.match(source, /EMPTY_IN_WAREHOUSE/);
for (const field of [
"qrToken",
"warehouseId",
"strainId",
"batchNo",
"packageSpec",
"initialPackages",
"idempotencyKey",
]) {
assert.match(source, new RegExp(field));
}
assert.match(source, /addFilling/);
assert.match(source, /business:filling:(?:query|add)/);
});
test("现场使用页面遵循产线绑定、余量和使用结果契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/usage/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanUsagePallet/);
assert.match(source, /AT_SITE_UNUSED/);
assert.match(source, /AT_SITE_PARTIAL/);
assert.match(source, /currentLineId/);
assert.match(source, /getUsageLineOptions/);
assert.match(source, /usedPackages/);
assert.match(source, /remainingPackages/);
assert.match(source, /flowState/);
assert.match(source, /addUsage/);
assert.match(source, /idempotencyKey/);
assert.match(source, /business:usage:(?:query|add)/);
});
test("退回页面实现申请与确认两阶段的集合和幂等契约", async () => {
const source = await readFile(
path.join(appRoot, "src/pages/business/return/index.vue"),
"utf8",
);
assert.match(source, /PalletScanInput/);
assert.match(source, /scanReturnPallet/);
assert.match(source, /mode:\s*["']request["']/);
assert.match(source, /mode:\s*["']confirm["']/);
assert.match(source, /sourceSiteId/);
assert.match(source, /targetWarehouseId/);
assert.match(source, /palletIds/);
assert.match(source, /scannedPalletIds/);
assert.match(source, /requestReturn/);
assert.match(source, /getReturn\(/);
assert.match(source, /confirmReturn/);
assert.match(source, /sameIdSet/);
assert.match(source, /REQUESTED/);
assert.match(source, /requestIdempotencyKey/);
assert.match(source, /confirmIdempotencyKey/);
assert.match(source, /business:return:(?:query|request|confirm)/);
});
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function importSource(relativePath) {
const source = await readFile(path.join(appRoot, relativePath), "utf8");
return import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
}