/** 商品目录回归走公开页面及真实配置器,只在既有后端 HTTP 边界提供生产形状数据。 */ import assert from "node:assert/strict"; import test from "node:test"; import { appOrigin, backendOrigin, eventually, scenario, } from "./helpers/console-browser.mjs"; async function catalogBoundary(page) { const state = { productReads: [], quotes: [] }; const variants = [ { id: 201, name: "未控库存", stock_control: 0, qty: 0, sync: 0, modelQty: 0, }, { id: 202, name: "商品售罄", stock_control: 1, qty: 0, sync: 0, modelQty: 8, }, { id: 203, name: "共同约束", stock_control: 1, qty: 8, sync: 1, modelQty: 3, }, { id: 204, name: "型号约束", stock_control: 0, qty: 0, sync: 1, modelQty: 4, }, { id: 205, name: "型号售罄", stock_control: 1, qty: 8, sync: 1, modelQty: 0, }, { id: 206, name: "库存待确认", stock_control: 1, qty: null, sync: 0, modelQty: 8, }, { id: 207, name: "商品限制更小", stock_control: 1, qty: 2, sync: 1, modelQty: 9, }, { id: 208, name: "仅商品限制", stock_control: 1, qty: 5, sync: 0, modelQty: 0, }, ]; const product = (entry) => ({ ...entry, module: "mf_dcim", description: "", price: "350.00", cycle: "月", product_group_id_second: 7, }); const lineOptions = [ { id: 11, name: "阶梯带宽", bill_type: "bw", bw: [ { type: "step", value: "0", min_value: 30, max_value: 30, step: 1 }, { type: "step", value: "0", min_value: 31, max_value: 1000, step: 1 }, ], }, { id: 12, name: "固定带宽", bill_type: "bw", bw: [{ type: "radio", value: "100", min_value: 999, max_value: 999 }], }, { id: 13, name: "区间流量", bill_type: "flow", flow: [ { type: "total", value: "0", min_value: 500, max_value: 2000, step: 100, }, ], }, ]; // 每个请求仍经过 useApi 的真实配置/解析链,未替换任何项目内部模块。 await page .context() .route(`${backendOrigin}/console/v1/**`, async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname.replace(/^\/console\/v1/, ""); const reply = (data) => route.fulfill({ json: { status: 200, msg: "", data }, headers: { "Access-Control-Allow-Origin": "*" }, }); if (path === "/product/group/first") return reply({ list: [{ id: 6, name: "国内云服务器" }] }); if (path === "/product/group/second") return reply({ list: [{ id: 7, parent_id: 6, name: "测试线路" }] }); if (path === "/product") return reply({ list: variants.map(product), count: variants.length }); const match = /^\/product\/([^/]+)(.*)$/.exec(path); if (!match) return route.fallback(); const id = Number(match[1]); const suffix = match[2]; if (request.method() === "GET") state.productReads.push(path); if (!suffix) { const entry = variants.find((entry) => entry.id === id); return reply({ product: entry ? product(entry) : { id, module: "mf_dcim", order_allowed: false, order_block_reason: "边界商品已停止订购", }, }); } const entry = variants.find((entry) => entry.id === id) ?? variants[0]; if (suffix === "/mf_dcim/order_page") return reply({ model_config: [ { id, name: `${entry.name}机型`, cpu: "8核", memory: "16G", disk: "480G SSD", qty: entry.modelQty, auto_sync_dcim_stock: entry.sync, }, ], data_center: [ { id: 1, name: "中国", city: [ { id: 2, name: "测试地区", area: [ { id: 3, name: "测试机房", line: lineOptions.map(({ id, name, bill_type }) => ({ id, name, bill_type, data_center_id: 1, })), }, ], }, ], }, ], }); const line = /^\/mf_dcim\/line\/(\d+)$/.exec(suffix); if (line) return reply({ ...lineOptions.find((entry) => entry.id === Number(line[1])), ip: [{ type: "radio", value: "1", desc: "1个" }], defence: [{ type: "radio", value: "0", desc: "基础防护" }], }); if (suffix === "/mf_dcim/image") return reply({ list: [ { id: 1, name: "Ubuntu", image: [ { id: 10, name: "Ubuntu 24.04", image_group_id: 1, price: "0.00", }, ], }, ], }); if (suffix === "/self_defined_field/order_page") return reply({ data: [] }); if (suffix === "/mf_dcim/duration") return reply([{ id: 1, name: "月付", price: "350.00", is_default: 1 }]); if (suffix === "/config_option") { state.quotes.push(request.postDataJSON()); return reply({ price_total: "350.00" }); } return route.fallback(); }); return state; } test( "catalog stock labels and eligibility share effective product and model limits", { timeout: 60000 }, async (t) => { await scenario(t, {}, async (page) => { const state = await catalogBoundary(page); await page.goto(`${appOrigin}/products/dedicated-server`); const card = (name) => page.locator(".hardware-card").filter({ has: page.getByRole("heading", { name: `${name}机型`, exact: true, }), }); await card("未控库存").waitFor(); assert.equal( await card("未控库存").getByText("剩余 0 台", { exact: true }).count(), 0, "Uncontrolled placeholder zero is not an available-stock count", ); assert.equal( await card("未控库存") .getByRole("button", { name: "选择线路", exact: true }) .isEnabled(), true, ); for (const name of ["商品售罄", "型号售罄"]) assert.equal( await card(name) .getByRole("button", { name: "库存不足", exact: true }) .isDisabled(), true, ); await card("共同约束").getByText("剩余 3 台", { exact: true }).waitFor(); await card("型号约束").getByText("剩余 4 台", { exact: true }).waitFor(); await card("商品限制更小") .getByText("剩余 2 台", { exact: true }) .waitFor(); await card("仅商品限制") .getByText("剩余 5 台", { exact: true }) .waitFor(); assert.equal( await card("库存待确认") .getByText("剩余 0 台", { exact: true }) .count(), 0, "Missing quantity must not be coerced into advertised zero stock", ); await page.goto( `${appOrigin}/products/dedicated-server/customize?id=203&model_id=203&line_id=11`, ); await page.getByText("当前库存 3 台", { exact: true }).waitFor(); assert.equal( await page .getByLabel("购买数量", { exact: true }) .getAttribute("aria-valuemax"), "3", ); const beforeSoldOut = state.quotes.length; await page.goto( `${appOrigin}/products/dedicated-server/customize?id=202&model_id=202&line_id=11`, ); await page .getByText("所选型号库存不足,请减少数量或选择其他型号") .first() .waitFor(); assert.equal( state.quotes.length, beforeSoldOut, "Direct configuration of controlled zero stock must not request a quote", ); return { uncontrolledZeroNotAdvertised: true, controlledZeroBlocked: true, jointMinimumStock: 3, modelOnlyStock: 4, productSmallerStock: 2, productOnlyStock: 5, unknownNotTreatedAsZero: true, configurationMatchesCatalog: true, directSoldOutConfigurationDoesNotQuote: true, }; }); }, ); test( "catalog radio and ranged network summaries agree with configurator defaults", { timeout: 60000 }, async (t) => { await scenario(t, {}, async (page) => { const state = await catalogBoundary(page); const observations = []; for (const [lineId, name, expected, field, value] of [ [11, "阶梯带宽", "30 Mbps", "bw", "30"], [12, "固定带宽", "100 Mbps", "bw", "100"], [13, "区间流量", "500 GB", "flow", "500"], ]) { await page.goto(`${appOrigin}/products/dedicated-server`); await page .locator(".hardware-card") .filter({ has: page.getByRole("heading", { name: "未控库存机型", exact: true, }), }) .getByRole("button", { name: "选择线路", exact: true }) .click(); const line = page.locator(".line-card").filter({ has: page.locator(".line-tags").getByText(name, { exact: true }), }); await line.locator(".line-specs").waitFor(); assert.equal( (await line.innerText()).includes(expected), true, "Network summary must show a selectable value, not the range placeholder", ); const before = state.quotes.length; await line.getByRole("link", { name: "立即订购", exact: true }).click(); await eventually( () => state.quotes.slice(before), (quotes) => quotes.some( (quote) => quote.config_options.line_id === lineId && quote.config_options[field] === value, ), "Configurator quote must use the same valid network value as the catalog", ); observations.push({ option: name, summary: expected, configuredValue: value, }); } return { networkOptions: observations }; }); }, ); test( "invalid product identifiers show public errors before product API reads", { timeout: 90000 }, async (t) => { await scenario(t, {}, async (page) => { const state = await catalogBoundary(page); const invalid = [ "2147483648", "999999999999999999999999999999999", "0", "-1", "01", "not-a-product", ]; const observed = []; for (const id of invalid) { for (const path of [ `/products/${id}`, `/products/dedicated-server/customize?id=${id}`, `/products/cloud-server?id=${id}`, `/products/cvm?product_id=${id}`, ]) { const before = state.productReads.length; await page.goto(appOrigin + path); await eventually( async () => state.productReads.length > before || (await page.getByText("商品编号无效").first().isVisible()), Boolean, "Identifier must either be rejected visibly or reach the observed external boundary", ); assert.deepEqual( state.productReads.slice(before), [], "Invalid identifier must not enter a product endpoint", ); await page.getByText("商品编号无效").first().waitFor(); assert.equal( new URL(page.url()).pathname, path.split("?")[0], "Invalid public product identifiers must not redirect to login", ); assert.equal( state.productReads.length, before, "Invalid identifier must not enter a product endpoint", ); } observed.push(id); } await page.goto(`${appOrigin}/products/2147483647`); await page.getByText("边界商品已停止订购", { exact: true }).waitFor(); assert.equal( state.productReads.includes("/product/2147483647"), true, "Largest supported signed 32-bit product identifier remains valid", ); return { invalidIdentifiersRejected: observed, publicErrorNotLoginLoop: true, invalidProductApiReads: 0, largestValidIdentifierAccepted: true, }; }); }, );