|
|
@@ -0,0 +1,274 @@
|
|
|
+/**
|
|
|
+ * 前端 `/update` 入口的执行级验证台。
|
|
|
+ *
|
|
|
+ * 形状抄自同目录 `task-9-fix1-harness.mjs`(那里有为什么必须这么搭的两条依据):
|
|
|
+ * `@vue/compiler-sfc` 把页面**真**的 `<script setup>` 编译出来 → 用 `frontend/node_modules`
|
|
|
+ * 里那份**真** `vue` 跑 `setup()` → 驱动**真函数** → 断言落在**真状态**上。
|
|
|
+ *
|
|
|
+ * ★ 量具与被测量物:`vue` / `@vue/compiler-sfc` / `typescript` 都是 frontend 里那一份(真品);
|
|
|
+ * `@/api/...` 与上传组件是桩(这一步没有后端可打);模板**不渲染**(`inlineTemplate: false`)
|
|
|
+ * ⇒ 本台证明的是「这些函数的判定逻辑」,**不是**「浏览器里长什么样」。
|
|
|
+ * ★ 因此 `formRef.value?.validate()` 那一步因 `formRef.value === undefined` 被 `?.` 跳过 ——
|
|
|
+ * 本台验不到 el-form 的校验行为,那部分只能靠 `vue-tsc` 与人工。
|
|
|
+ *
|
|
|
+ * ★ 本台**不设** `process.exitCode`(与 T9 那台一致)⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
|
|
|
+ *
|
|
|
+ * 用法:node fe-update-harness.mjs <index.vue 路径> [标签]
|
|
|
+ */
|
|
|
+import fs from "node:fs";
|
|
|
+import { createRequire } from "node:module";
|
|
|
+
|
|
|
+const FE = "D:/project2/payment-platform/frontend/";
|
|
|
+const require = createRequire(FE + "package.json");
|
|
|
+const vue = require("vue");
|
|
|
+const { parse, compileScript } = require("@vue/compiler-sfc");
|
|
|
+
|
|
|
+const SRC = process.argv[2];
|
|
|
+const LABEL = process.argv[3] || SRC;
|
|
|
+
|
|
|
+/** 把 SFC 编译成可 `new Function` 的普通脚本(import 换成对 `globalThis.__U` 的取值)。 */
|
|
|
+function compileSfc(file) {
|
|
|
+ const src = fs.readFileSync(file, "utf8");
|
|
|
+ const { descriptor, errors } = parse(src, { filename: file });
|
|
|
+ if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
|
|
|
+ let code = compileScript(descriptor, { id: "uprobe", inlineTemplate: false }).content;
|
|
|
+
|
|
|
+ const stripTypes = (names) => names.replace(/\btype\s+[\w$.]+\s*,?/g, "");
|
|
|
+ const deAlias = (names) => names.replace(/\bas\b/g, ":");
|
|
|
+ const braceImport = (spec) =>
|
|
|
+ new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
|
|
|
+ code = code
|
|
|
+ // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
|
|
|
+ // ⇒ 正则必须带 `g`,且 `as` 别名要转成解构的 `:`。
|
|
|
+ .replace(braceImport("vue"), (_m, n) => `const {${deAlias(n)}} = globalThis.__U.vue;`)
|
|
|
+ .replace(
|
|
|
+ braceImport("@/api/module_payment/onboarding"),
|
|
|
+ (_m, n) => `const {${stripTypes(n)}} = globalThis.__U.api;`
|
|
|
+ )
|
|
|
+ .replace(
|
|
|
+ /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
|
|
|
+ (_m, n) => `const ${n} = globalThis.__U.SingleImageUpload;`
|
|
|
+ );
|
|
|
+
|
|
|
+ if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
|
|
|
+
|
|
|
+ // ★ `compileScript` **不**剥 TS ⇒ 自己过一遍单文件转译,否则 `interface Field {` 是 SyntaxError。
|
|
|
+ const ts = require("typescript");
|
|
|
+ code = ts.transpileModule(code, {
|
|
|
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
|
|
|
+ fileName: "index.ts",
|
|
|
+ }).outputText;
|
|
|
+ code = code.replace(/export\s+default/, "const __sfc =");
|
|
|
+ if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
|
|
|
+ code += "\nglobalThis.__U.sfc = __sfc;";
|
|
|
+ return code;
|
|
|
+}
|
|
|
+
|
|
|
+const code = compileSfc(SRC);
|
|
|
+
|
|
|
+// ---------- 桩 ----------
|
|
|
+const ok = (d) => ({ data: { code: 0, data: d } });
|
|
|
+
|
|
|
+function makeApi(state) {
|
|
|
+ return {
|
|
|
+ MERCHANT_CATEGORY_OPTIONS: [],
|
|
|
+ getOnboardingAreas: async () => ok([]),
|
|
|
+ getOnboardingMcc: async () => ok([]),
|
|
|
+ uploadOnboardingImage: async () => ok(""),
|
|
|
+ createOnboardingMerchant: async (body) => {
|
|
|
+ state.created = body;
|
|
|
+ return state.create(body);
|
|
|
+ },
|
|
|
+ updateOnboardingMerchant: async (body) => {
|
|
|
+ state.updated = body;
|
|
|
+ return state.update(body);
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/** 每个场景一套全新的 refs:重新求值一次编译产物即可。 */
|
|
|
+function mount(state) {
|
|
|
+ globalThis.__U = { vue, api: makeApi(state), SingleImageUpload: { name: "Stub" } };
|
|
|
+ new Function(code)();
|
|
|
+ return globalThis.__U.sfc.setup({}, { expose: () => {} });
|
|
|
+}
|
|
|
+
|
|
|
+const KEYS = (o) => (o ? Object.keys(o).sort() : null);
|
|
|
+
|
|
|
+// ---------- 断言 ----------
|
|
|
+let nPass = 0;
|
|
|
+const failed = [];
|
|
|
+function check(name, actual, expected) {
|
|
|
+ const pass = JSON.stringify(actual) === JSON.stringify(expected);
|
|
|
+ if (pass) nPass++;
|
|
|
+ else failed.push(name);
|
|
|
+ console.log(
|
|
|
+ ` ${pass ? "PASS" : "FAIL"} ${name} (actual=${JSON.stringify(actual)} want=${JSON.stringify(expected)})`
|
|
|
+ );
|
|
|
+}
|
|
|
+/** 断言「body 里**没有**这个键」—— 比 check(keys, [...]) 少写一坨期望值,失败时也更好读。 */
|
|
|
+function checkAbsent(name, body, key) {
|
|
|
+ check(name, body && Object.prototype.hasOwnProperty.call(body, key), false);
|
|
|
+}
|
|
|
+
|
|
|
+console.log(`=== ${LABEL} ===`);
|
|
|
+
|
|
|
+const fresh = () => ({ create: async () => ok(null), update: async () => ok(null) });
|
|
|
+
|
|
|
+// ---- U. 更新腿报文的键白名单(本台的主症:多一个键 = HTTP 500,少发 = 上游不改) ----
|
|
|
+{
|
|
|
+ const state = fresh();
|
|
|
+ const b = mount(state);
|
|
|
+ b.mode.value = "update";
|
|
|
+ b.form.mch_no = " Y52211090004 ";
|
|
|
+ b.form.account_card_num = "6222000000000000";
|
|
|
+ // ★★ 手动把三个**绝不该发**的键塞进表单模型 —— 只有塞进去,「白名单在挡」这句话才不是空的。
|
|
|
+ // 前两个的来路是「用户在建号模式填过,切模式不清表单」(`switchMode` 有意不清);
|
|
|
+ // 后两个的来路是「有人图省事把封盖字段接进了表单」。
|
|
|
+ b.form.merchant_name_business = "某某公司";
|
|
|
+ b.form.lease_contract = "https://oss/lease.png";
|
|
|
+ b.form.category = "MICRO";
|
|
|
+ b.form.tenant_id = "2";
|
|
|
+ b.form.rate_code = "RATE_X";
|
|
|
+
|
|
|
+ await b.handleSubmit();
|
|
|
+ const body = state.updated;
|
|
|
+
|
|
|
+ check("U1 更新报文**不含** merchant_name_business(发了就是 500)", body?.merchant_name_business, undefined);
|
|
|
+ checkAbsent("U1b …用 hasOwnProperty 再确认一次", body, "merchant_name_business");
|
|
|
+ checkAbsent("U2 更新报文**不含** lease_contract(发了就是 500)", body, "lease_contract");
|
|
|
+ checkAbsent("U3 更新报文**不含** category(更新 DTO 上没有它 ⇒ 500)", body, "category");
|
|
|
+ checkAbsent("U4 更新报文**不含** tenant_id(封盖 ⇒ 400)", body, "tenant_id");
|
|
|
+ checkAbsent("U5 更新报文**不含** rate_code(封盖 ⇒ 400)", body, "rate_code");
|
|
|
+ check("U6 更新报文**含** mch_no,且已 trim", body?.mch_no, "Y52211090004");
|
|
|
+ check("U7 增量:填了的那一项原样发出", body?.account_card_num, "6222000000000000");
|
|
|
+ check("U8 增量:**没填**的项一个都不发(恰好两个键)", KEYS(body), ["account_card_num", "mch_no"]);
|
|
|
+ check("U9 提交态已复位", b.submitting.value, false);
|
|
|
+ check("U10 成功提示用的是**请求里**那个商户号(响应里没有)", b.updatedMchNo.value, "Y52211090004");
|
|
|
+}
|
|
|
+
|
|
|
+// ---- V. 正对照:建号腿一个都没被改坏 ----
|
|
|
+{
|
|
|
+ const state = fresh();
|
|
|
+ const b = mount(state);
|
|
|
+ b.form.category = "MICRO";
|
|
|
+ b.form.merchant_name = "某某";
|
|
|
+ b.form.merchant_name_business = "某某公司";
|
|
|
+ b.form.lease_contract = "https://oss/lease.png";
|
|
|
+ b.form.mch_no = "不该出现在建号报文里"; // 切过模式留下的
|
|
|
+ await b.handleSubmit();
|
|
|
+ const body = state.created;
|
|
|
+ check("V1 正对照:建号报文**仍然带** category", body?.category, "MICRO");
|
|
|
+ check("V2 正对照:建号报文**仍然带** merchant_name_business", body?.merchant_name_business, "某某公司");
|
|
|
+ check("V3 正对照:建号报文**仍然带** lease_contract", body?.lease_contract, "https://oss/lease.png");
|
|
|
+ checkAbsent("V4 建号报文**不含** mch_no(它不在 CREATE_KEYS 里,是服务端产出)", body, "mch_no");
|
|
|
+ checkAbsent("V5 建号报文不含 tenant_id", body, "tenant_id");
|
|
|
+ checkAbsent("V6 建号报文不含 rate_code", body, "rate_code");
|
|
|
+}
|
|
|
+
|
|
|
+// ---- W. 渲染投影:那两格在更新模式下根本不渲染 ----
|
|
|
+{
|
|
|
+ const b = mount(fresh());
|
|
|
+ const nameSec = b.SECTIONS.find((s) => s.key === "name");
|
|
|
+ const matSec = b.SECTIONS.find((s) => s.key === "materials");
|
|
|
+
|
|
|
+ check("W1 建号模式:name 分区 3 个字段", b.visibleFields(nameSec).length, 3);
|
|
|
+ check("W2 建号模式:materials 分区 5 个字段", b.visibleFields(matSec).length, 5);
|
|
|
+ b.mode.value = "update";
|
|
|
+ check("W3 更新模式:name 分区只剩 2 个(少了 merchant_name_business)", b.visibleFields(nameSec).length, 2);
|
|
|
+ check("W4 更新模式:materials 分区只剩 4 个(少了 lease_contract)", b.visibleFields(matSec).length, 4);
|
|
|
+ check(
|
|
|
+ "W5 更新模式:被去掉的正是那两个键",
|
|
|
+ b.visibleFields(nameSec).map((f) => f.key).concat(b.visibleFields(matSec).map((f) => f.key)).filter((k) => k === "merchant_name_business" || k === "lease_contract"),
|
|
|
+ []
|
|
|
+ );
|
|
|
+ const addr = b.SECTIONS.find((s) => s.key === "address");
|
|
|
+ check("W6 更新模式:address 分区不受影响", b.visibleFields(addr).map((f) => f.key), ["address_street"]);
|
|
|
+ const mcc = b.SECTIONS.find((s) => s.key === "mcc");
|
|
|
+ check("W7 更新模式:mcc 分区不受影响(merchant_mcc_code 在更新 DTO 上)", b.visibleFields(mcc).map((f) => f.key), ["merchant_mcc_code"]);
|
|
|
+}
|
|
|
+
|
|
|
+// ---- X. 切模式 / 跳转入口 ----
|
|
|
+{
|
|
|
+ const b = mount(fresh());
|
|
|
+ b.form.merchant_name = "填了一半";
|
|
|
+ b.form.category = "MICRO";
|
|
|
+ b.createdMchNo.value = "M20260923";
|
|
|
+ b.switchMode();
|
|
|
+ check("X1 切模式**不清**已填的值(误点一下不能毁掉 41 个框)", b.form.merchant_name, "填了一半");
|
|
|
+ check("X2 切模式清掉上一次的建号结果提示", b.createdMchNo.value, "");
|
|
|
+ check("X3 切模式清掉「没回商户号」告警", b.submitNoMchNo.value, false);
|
|
|
+}
|
|
|
+{
|
|
|
+ const b = mount(fresh());
|
|
|
+ b.form.merchant_name = "建号时填的";
|
|
|
+ b.form.lease_contract = "https://oss/lease.png";
|
|
|
+ b.startUpdateFor("Y52211090004");
|
|
|
+ check("X4 跳转入口:模式切到 update", b.mode.value, "update");
|
|
|
+ check("X5 跳转入口:商户号已预填", b.form.mch_no, "Y52211090004");
|
|
|
+ check("X6 跳转入口**不**把建号表单的值带过去(否则等于按建号值再覆盖一遍)", b.form.merchant_name, "建号时填的");
|
|
|
+}
|
|
|
+{
|
|
|
+ const b = mount(fresh());
|
|
|
+ b.mode.value = "update";
|
|
|
+ check("X7 更新模式只钉商户号一条必填", Object.keys(b.rules.value), ["mch_no"]);
|
|
|
+ b.mode.value = "create";
|
|
|
+ check("X8 建号模式只钉商户类别一条必填(没被本次改动动过)", Object.keys(b.rules.value), ["category"]);
|
|
|
+}
|
|
|
+
|
|
|
+// ---- Y. 更新腿的失败/形状 ----
|
|
|
+{
|
|
|
+ const state = { create: async () => ok(null), update: async () => ok(null) };
|
|
|
+ const b = mount(state);
|
|
|
+ b.mode.value = "update";
|
|
|
+ b.form.mch_no = "Y1";
|
|
|
+ await b.handleSubmit();
|
|
|
+ check("Y1 更新腿成功**不**去读响应里的 mch_no(Result<Void>)", b.submitNoMchNo.value, false);
|
|
|
+ check("Y2 更新腿不碰建号腿的提示位", b.createdMchNo.value, "");
|
|
|
+}
|
|
|
+{
|
|
|
+ const state = {
|
|
|
+ create: async () => ok(null),
|
|
|
+ update: async () => {
|
|
|
+ throw new Error("信封 500(上游挂了)");
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const b = mount(state);
|
|
|
+ b.mode.value = "update";
|
|
|
+ b.form.mch_no = "Y2";
|
|
|
+ let escaped = false;
|
|
|
+ try {
|
|
|
+ await b.handleSubmit();
|
|
|
+ } catch {
|
|
|
+ escaped = true;
|
|
|
+ }
|
|
|
+ check("Y3 更新失败:rejection 不外逃(拦截器已弹过文案)", escaped, false);
|
|
|
+ check("Y4 更新失败:不留成功提示", b.updatedMchNo.value, "");
|
|
|
+ check("Y5 更新失败:提交态已复位", b.submitting.value, false);
|
|
|
+}
|
|
|
+
|
|
|
+// ---- Z. 键集:本台要把「39」这个数字**跑出来**,而不是从注释里抄 ----
|
|
|
+{
|
|
|
+ const b = mount(fresh());
|
|
|
+ check("Z1 建号键白名单 = 41(与页面注释里那个数字对撞)", b.CREATE_KEYS.length, 41);
|
|
|
+ check("Z2 更新键白名单 = 39(交付说明里写的那个数字)", b.UPDATE_KEYS.length, 39);
|
|
|
+ check("Z3 更新集是建号集的**真子集**(差的恰好 2)", b.CREATE_KEYS.length - b.UPDATE_KEYS.length, 2);
|
|
|
+ check(
|
|
|
+ "Z4 更新集里没有任何一个被排除的键",
|
|
|
+ b.UPDATE_KEYS.filter((k) => b.UPDATE_EXCLUDED_KEYS.includes(k)),
|
|
|
+ []
|
|
|
+ );
|
|
|
+ check(
|
|
|
+ "Z5 更新集里没有 category / tenant_id / rate_code(各自对应 500 / 400 / 400)",
|
|
|
+ b.UPDATE_KEYS.filter((k) => ["category", "tenant_id", "rate_code"].includes(k)),
|
|
|
+ []
|
|
|
+ );
|
|
|
+ check("Z6 建号集里也没有 tenant_id / rate_code", b.CREATE_KEYS.filter((k) => ["tenant_id", "rate_code"].includes(k)), []);
|
|
|
+}
|
|
|
+
|
|
|
+if (process.argv.includes("--dump-keys")) {
|
|
|
+ const b = mount(fresh());
|
|
|
+ console.log("KEYS_JSON " + JSON.stringify(b.UPDATE_KEYS));
|
|
|
+}
|
|
|
+
|
|
|
+console.log(`SUMMARY ${LABEL}: pass=${nPass} fail=${failed.length}${failed.length ? " -> " + failed.join(" | ") : ""}`);
|