From d8224ec5b7267901d37fba0803cf0e5916120b68 Mon Sep 17 00:00:00 2001 From: CieanYau Date: Fri, 6 Nov 2026 04:46:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=95=86=E5=93=81=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=E7=BD=91=E7=BB=9C=E6=91=98=E8=A6=81=E5=B9=B6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E8=AE=A2=E8=B4=AD=E7=BC=96=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductConfigurationSummary.vue | 14 +- app/components/config/MfCloudConfig.vue | 9 +- app/components/config/MfDcimConfig.vue | 22 +- app/lib/product-description-data.js | 5 +- app/lib/product-id.js | 8 + app/lib/product-inventory.js | 14 + app/lib/product-order-module.js | 2 + app/lib/resource-option-value.js | 11 + app/pages/products/[id].vue | 5 +- app/pages/products/cloud-server.vue | 8 +- .../products/dedicated-server/customize.vue | 5 +- app/pages/products/dedicated-server/index.vue | 30 +- app/pages/products/index.vue | 25 +- app/utils/access.js | 3 +- tests/review-online-catalog.e2e.test.mjs | 436 ++++++++++++++++++ 15 files changed, 529 insertions(+), 68 deletions(-) create mode 100644 app/lib/product-id.js create mode 100644 app/lib/product-inventory.js create mode 100644 app/lib/resource-option-value.js create mode 100644 tests/review-online-catalog.e2e.test.mjs diff --git a/app/components/ProductConfigurationSummary.vue b/app/components/ProductConfigurationSummary.vue index caeef68..56a24f8 100644 --- a/app/components/ProductConfigurationSummary.vue +++ b/app/components/ProductConfigurationSummary.vue @@ -20,6 +20,8 @@ import { t as product_name_t } from "~/lib/product-name.js"; import { a as storage_capacity_a } from "~/lib/storage-capacity.js"; import { t as storage_capacity_t } from "~/lib/storage-capacity.js"; import { t as product_order_module_t } from "~/lib/product-order-module.js"; +import { resourceOptionValue as k } from "~/lib/resource-option-value.js"; +import { productId } from "~/lib/product-id.js"; var y = { timeoutMs: 5e3, }, @@ -142,14 +144,6 @@ function O(e) { : ``); return `${e.min}${t}`; } -function k(e) { - return ( - e && - (e.type === `step` || e.type === `total` - ? (e.min_value ?? e.value) - : e.value) - ); -} function A(e, t, n = ``) { return { label: e, @@ -158,7 +152,7 @@ function A(e, t, n = ``) { } // 按真实订单模块读取配置;含级联或高级限制且无法安全推导默认值时不生成摘要。 async function j(e, t) { - if (!product_name_t(t.name)) return []; + if (!product_name_t(t.name) || productId(t.id) === null) return []; let n = (t, n) => e.get(t, n, !1, y); try { let r = await product_order_module_t( @@ -252,7 +246,7 @@ async function j(e, t) { if (e.bill_type === `bw`) s.push(A(`带宽`, k(e.bw?.[0]), `Mbps`)); else if (e.bill_type === `flow`) { let t = e.flow?.[0]; - s.push(A(`流量`, t?.value, `GB`)); + s.push(A(`流量`, k(t), `GB`)); let n = t?.bw?.[0] ?? t?.other_config; n && s.push( diff --git a/app/components/config/MfCloudConfig.vue b/app/components/config/MfCloudConfig.vue index 9db1d8b..a1c93d6 100644 --- a/app/components/config/MfCloudConfig.vue +++ b/app/components/config/MfCloudConfig.vue @@ -60,14 +60,7 @@ import ConfigResourceOption from "~/components/config/ConfigResourceOption.vue"; import { t as password_t } from "~/lib/password-2.js"; import { n as order_image_continuation_n } from "~/lib/order-image-continuation.js"; import { t as order_image_continuation_t } from "~/lib/order-image-continuation.js"; -function I(e) { - if (e) - return Number( - e.type === `step` || e.type === `total` - ? (e.min_value ?? e.value) - : e.value, - ); -} +import { resourceOptionValue as I } from "~/lib/resource-option-value.js"; function L(e, t) { // 区间内的值还必须落在步长网格上,不能仅凭最小/最大值认定配置可购买。 if (t == null || t === ``) return !1; diff --git a/app/components/config/MfDcimConfig.vue b/app/components/config/MfDcimConfig.vue index 89ce076..e629700 100644 --- a/app/components/config/MfDcimConfig.vue +++ b/app/components/config/MfDcimConfig.vue @@ -51,6 +51,8 @@ import { o as cart_edit_o } from "~/lib/cart-edit.js"; import { r as cart_edit_r } from "~/lib/cart-edit.js"; import ConfigDiskCard from "~/components/config/ConfigDiskCard.vue"; import slider from "primevue/slider"; +import { productStock } from "~/lib/product-inventory.js"; +import { resourceOptionValue } from "~/lib/resource-option-value.js"; function z(e, t = {}, n = `mf_dcim`, r = {}) { // 商品库存与型号同步库存可能同时生效;可购数量取所有有效限制中的最小值。 let i = useApi(), @@ -108,17 +110,7 @@ function z(e, t = {}, n = `mf_dcim`, r = {}) { h.value?.model_config.find((e) => e.id === D.value.model_config_id), ), ne = computed(() => A.value.find((e) => e.id === D.value.line_id)), - re = computed(() => { - let e = [ - m.value?.stock_control === 1 ? m.value.qty : void 0, - j.value?.auto_sync_dcim_stock === 1 ? j.value.qty : void 0, - ] - .filter( - (e) => e !== void 0 && Number.isFinite(Number(e)) && Number(e) >= 0, - ) - .map(Number); - return e.length ? Math.min(...e) : null; - }), + re = computed(() => productStock(m.value, j.value)), ie = computed(() => re.value === null || C.value <= re.value), F = computed(() => JSON.stringify({ @@ -188,12 +180,8 @@ function z(e, t = {}, n = `mf_dcim`, r = {}) { })); let q = (e) => !V && e === z, le = (e) => { - let t = e?.[0]; - return t - ? String( - t.type && t.type !== `radio` ? (t.min_value ?? t.value) : t.value, - ) - : ``; + const value = resourceOptionValue(e?.[0]); + return value === undefined ? `` : String(value); }; function ue() { // 报价和提交共用前置校验,型号/线路/镜像与正整数数量缺一不可。 diff --git a/app/lib/product-description-data.js b/app/lib/product-description-data.js index 850397e..5013475 100644 --- a/app/lib/product-description-data.js +++ b/app/lib/product-description-data.js @@ -1,8 +1,9 @@ /** 优先使用商品已有描述;缺失时按商品 id 补查,最多等待 5 秒,失败不阻塞配置页。 */ +import { productId } from "~/lib/product-id.js"; async function e(e, t) { if (typeof t.description == `string`) return t.description; - let n = Number(t.id); - if (!Number.isSafeInteger(n) || n <= 0) return ``; + let n = productId(t.id); + if (n === null) return ``; try { let t = await e.get( `/product`, diff --git a/app/lib/product-id.js b/app/lib/product-id.js new file mode 100644 index 0000000..5d5249d --- /dev/null +++ b/app/lib/product-id.js @@ -0,0 +1,8 @@ +/** 商品接口使用有符号 32 位正整数;在拼接后端路径之前拒绝数组、精度丢失和超限编号。 */ +export function productId(value) { + if (typeof value !== "string" && typeof value !== "number") return null; + const text = String(value); + if (!/^[1-9]\d{0,9}$/.test(text)) return null; + const id = Number(text); + return id <= 2147483647 ? id : null; +} diff --git a/app/lib/product-inventory.js b/app/lib/product-inventory.js new file mode 100644 index 0000000..23e0c10 --- /dev/null +++ b/app/lib/product-inventory.js @@ -0,0 +1,14 @@ +/** 只有已开启控制且数量有效的库存才约束购买;null 表示没有可展示的确定数量,不代表无限库存。 */ +export function productStock(product, model) { + const quantities = [ + [product?.stock_control, product?.qty], + [model?.auto_sync_dcim_stock, model?.qty], + ] + .filter( + ([enabled, quantity]) => + Number(enabled) === 1 && quantity != null && quantity !== "", + ) + .map(([, quantity]) => Number(quantity)) + .filter((quantity) => Number.isFinite(quantity) && quantity >= 0); + return quantities.length ? Math.min(...quantities) : null; +} diff --git a/app/lib/product-order-module.js b/app/lib/product-order-module.js index 090cd09..aa2a0e8 100644 --- a/app/lib/product-order-module.js +++ b/app/lib/product-order-module.js @@ -1,4 +1,5 @@ /** 下单前确认真实配置模块;本地 DCIM 与转售 DCIM 使用不同配置组件,不能仅看商品名。 */ +import { productId } from "~/lib/product-id.js"; function e(e) { let t = typeof e.module == `string` ? e.module.trim() : ``, n = typeof e.res_module == `string` ? e.res_module.trim() : ``; @@ -7,6 +8,7 @@ function e(e) { } // 已知非 mf_cloud 模块可直接使用;缺失/含糊时读取配置接口,取不到模块就明确失败。 async function t(t, n) { + if (productId(n.id) === null) throw Error(`商品编号无效`); let r = typeof n.module == `string` ? n.module.trim() : ``, i = e(n); if (i) return i; diff --git a/app/lib/resource-option-value.js b/app/lib/resource-option-value.js new file mode 100644 index 0000000..6e5d0b7 --- /dev/null +++ b/app/lib/resource-option-value.js @@ -0,0 +1,11 @@ +/** radio 的 value 是可选值;step/total 的 value 可能只是占位,默认值应从区间下限读取。 */ +export function resourceOptionValue(option) { + if (!option) return; + const value = + option.type === "step" || option.type === "total" + ? (option.min_value ?? option.value) + : option.value; + if (value == null || value === "") return; + const number = Number(value); + return Number.isFinite(number) ? number : undefined; +} diff --git a/app/pages/products/[id].vue b/app/pages/products/[id].vue index a0403f1..e7b1a98 100644 --- a/app/pages/products/[id].vue +++ b/app/pages/products/[id].vue @@ -58,6 +58,7 @@ import { r as order_image_continuation_r } from "~/lib/order-image-continuation. import MfDcimConfig from "~/components/config/MfDcimConfig.vue"; import { t as composables_t } from "~/lib/composables-2.js"; import { t as product_description_data_t } from "~/lib/product-description-data.js"; +import { productId } from "~/lib/product-id.js"; var j = { class: `page product-order-page`, }, @@ -83,7 +84,7 @@ var j = { let b = useRoute(), I = useRouter(), L = useApi(), - R = String(b.params.id ?? b.query.id ?? b.query.product_id ?? ``), + R = b.params.id ?? b.query.id ?? b.query.product_id ?? ``, z = order_image_continuation_r(b.query.order_image), B = b.fullPath; composables_t({ @@ -132,7 +133,7 @@ var j = { let e = ++Z; ((W.value = !0), (G.value = ``), (H.value = null), (U.value = null)); try { - if (!/^[1-9]\d*$/.test(R)) throw Error(`商品编号无效`); + if (productId(R) === null) throw Error(`商品编号无效`); let t = await L.get(`/product/${R}`); if (e !== Z) return; let n = t?.product ?? t; diff --git a/app/pages/products/cloud-server.vue b/app/pages/products/cloud-server.vue index 1dfd79d..4c9beda 100644 --- a/app/pages/products/cloud-server.vue +++ b/app/pages/products/cloud-server.vue @@ -60,6 +60,7 @@ import KkyEcsConfig from "~/components/config/KkyEcsConfig.vue"; import MfCloudConfig from "~/components/config/MfCloudConfig.vue"; import { t as composables_t } from "~/lib/composables-2.js"; import { t as product_description_data_t } from "~/lib/product-description-data.js"; +import { productId } from "~/lib/product-id.js"; var ge = { class: `cloud-order-page`, }, @@ -135,6 +136,7 @@ var ge = { ? `库存待确认` : `暂时缺货`, G = (e) => + productId(e.id) !== null && Number(e.hidden ?? 0) !== 1 && (e.product_group_id_second == null || Number(e.product_group_id_second) === T.value); @@ -208,7 +210,7 @@ var ge = { ((E.value = (t.list ?? []).filter(G)), (I.value = Number(t.count) || 0), (A.value = !1)); - let n = Number(C.query.id ?? C.query.product_id), + let n = productId(C.query.id ?? C.query.product_id), r = E.value.find((e) => e.id === n && !U(e)) ?? E.value.find((e) => !U(e)); @@ -238,6 +240,10 @@ var ge = { (j.value = !1), (A.value = !1)); try { + // 显式指定的商品编号无效时就地提示,不改选首件商品,更不把超限值交给后端。 + const requestedId = C.query.id ?? C.query.product_id; + if (requestedId !== undefined && productId(requestedId) === null) + throw Error(`商品编号无效`); let [t, n] = await Promise.all([ S.get(`/product/group/first`), S.get(`/product/group/second`), diff --git a/app/pages/products/dedicated-server/customize.vue b/app/pages/products/dedicated-server/customize.vue index f1bc2e2..f84735e 100644 --- a/app/pages/products/dedicated-server/customize.vue +++ b/app/pages/products/dedicated-server/customize.vue @@ -43,6 +43,7 @@ import MfDcimConfig from "~/components/config/MfDcimConfig.vue"; import { t as composables_t } from "~/lib/composables-2.js"; import { t as product_description_data_t } from "~/lib/product-description-data.js"; import index from "~/pages/products/dedicated-server/index.vue"; +import { productId } from "~/lib/product-id.js"; var A = { key: 1, class: `dedicated-customize`, @@ -65,7 +66,7 @@ var A = { let x = useRoute(), P = useRouter(), F = useApi(), - I = computed(() => String(x.query.id ?? x.query.product_id ?? ``)); + I = computed(() => x.query.id ?? x.query.product_id ?? ``); composables_t({ key: (e) => e.fullPath, }); @@ -85,7 +86,7 @@ var A = { t = I.value; ((R.value = !0), (z.value = ``), (L.value = null)); try { - if (!/^[1-9]\d*$/.test(t)) throw Error(`商品编号无效`); + if (productId(t) === null) throw Error(`商品编号无效`); let n = await F.get(`/product/${t}`); if (e !== U) return; let r = n?.product ?? n; diff --git a/app/pages/products/dedicated-server/index.vue b/app/pages/products/dedicated-server/index.vue index ce14d0f..d076bf7 100644 --- a/app/pages/products/dedicated-server/index.vue +++ b/app/pages/products/dedicated-server/index.vue @@ -32,6 +32,9 @@ import { n as product_name_n } from "~/lib/product-name.js"; import inputnumber from "primevue/inputnumber"; import tag from "primevue/tag"; import skeleton from "primevue/skeleton"; +import { productStock } from "~/lib/product-inventory.js"; +import { resourceOptionValue } from "~/lib/resource-option-value.js"; +import { productId } from "~/lib/product-id.js"; // 只识别本地或资源模块映射到 mf_dcim 的物理商品;未知模块不强行套用配置器。 function O(e) { let t = e.module?.trim() || ``; @@ -122,14 +125,8 @@ function k() { } // 商品库存与自动同步的机型库存都满足时才允许选购。 function w(e) { - return ( - !(e.product.stock_control === 1 && Number(e.product.qty) <= 0) && - !( - e.model.auto_sync_dcim_stock === 1 && - e.model.qty !== void 0 && - Number(e.model.qty) <= 0 - ) - ); + const stock = productStock(e.product, e.model); + return stock === null || stock > 0; } // 分页收集商品后并行加载物理模块配置;单个商品失败保留其余可用项并提示部分加载失败。 async function T() { @@ -158,7 +155,7 @@ function k() { } let o = t.flatMap((e) => { let t = O(e); - return e.hidden !== 1 && t + return e.hidden !== 1 && productId(e.id) !== null && t ? [ { product: { @@ -851,15 +848,20 @@ var te = { ), t.lines.length ? unref(s).inStock(t) - ? t.model.qty !== void 0 && - t.model.qty >= 0 + ? productStock( + t.product, + t.model, + ) !== null ? (openBlock(), createElementBlock( `span`, B, `剩余 ` + toDisplayString( - t.model.qty, + productStock( + t.product, + t.model, + ), ) + ` 台`, 1, @@ -1100,8 +1102,8 @@ var te = { toDisplayString( unref(H)[t.id] ?.bill_type === `flow` - ? `${unref(H)[t.id]?.flow?.[0]?.value ?? `—`} GB` - : `${unref(H)[t.id]?.bw?.[0]?.value ?? `—`} Mbps`, + ? `${resourceOptionValue(unref(H)[t.id]?.flow?.[0]) ?? `—`} GB` + : `${resourceOptionValue(unref(H)[t.id]?.bw?.[0]) ?? `—`} Mbps`, ), 1, ), diff --git a/app/pages/products/index.vue b/app/pages/products/index.vue index 2dc79bf..0c22ddf 100644 --- a/app/pages/products/index.vue +++ b/app/pages/products/index.vue @@ -51,6 +51,7 @@ import { n as ProductConfigurationSummary_n } from "~/components/ProductConfigur import ProductConfigurationSummary from "~/components/ProductConfigurationSummary.vue"; import ProductDescription from "~/components/ProductDescription.vue"; import ProductGroupLabel from "~/components/ProductGroupLabel.vue"; +import { productId } from "~/lib/product-id.js"; var j = rolldown_runtime_i(getCrypto(), 1), M = 2592e6, N = 100, @@ -613,17 +614,19 @@ var me = { a || ((T.value = []), (E.value = 0), (A.value = !1)); let s = await t.get(`/product`, r); if (n !== W) return; - ((T.value = (s.list ?? []).map((t) => ({ - ...t, - startingPrice: q(t) - ? (o.get(t.id)?.startingPrice ?? null) - : void 0, - configurationSummary: product_name_t(t.name) - ? e - ? o.get(t.id)?.configurationSummary - : y.peek(t) - : [], - }))), + ((T.value = (s.list ?? []) + .filter((t) => productId(t.id) !== null) + .map((t) => ({ + ...t, + startingPrice: q(t) + ? (o.get(t.id)?.startingPrice ?? null) + : void 0, + configurationSummary: product_name_t(t.name) + ? e + ? o.get(t.id)?.configurationSummary + : y.peek(t) + : [], + }))), (E.value = Number(s.count) || 0), (K = i), (A.value = !0)); diff --git a/app/utils/access.js b/app/utils/access.js index ad77329..6caaa54 100644 --- a/app/utils/access.js +++ b/app/utils/access.js @@ -21,7 +21,8 @@ const shopPages = new Set([ export function isPublicShop(route) { if (Object.hasOwn(route.query || {}, "edit_position")) return false; const path = route.path.replace(/\/+$/, "") || "/"; - return shopPages.has(path) || /^\/products\/[1-9]\d*$/.test(path); + // 动态商品入口由页面统一校验编号;非法编号也应显示公开错误,而不是被误送到登录页。 + return shopPages.has(path) || /^\/products\/[^/]+$/.test(path); } export function isAuthPage(path) { return authPages.has(path.replace(/\/+$/, "") || "/"); diff --git a/tests/review-online-catalog.e2e.test.mjs b/tests/review-online-catalog.e2e.test.mjs new file mode 100644 index 0000000..97415cc --- /dev/null +++ b/tests/review-online-catalog.e2e.test.mjs @@ -0,0 +1,436 @@ +/** 商品目录回归走公开页面及真实配置器,只在既有后端 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, + }; + }); + }, +);