437 lines
13 KiB
JavaScript
437 lines
13 KiB
JavaScript
/** 商品目录回归走公开页面及真实配置器,只在既有后端 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,
|
|
};
|
|
});
|
|
},
|
|
);
|