修复商品库存与网络摘要并校验订购编号
This commit is contained in:
1 parent
28894e0813
commit
d8224ec5b7
15 files changed
+529
-68
No files matched your search
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user