1 parent
e27f08e36e
commit
b788f890f8
5 files changed
+288
-59
No files matched your search
@@ -1,5 +1,6 @@
|
||||
/** 购买回归走真实配置页、实例动作和支付界面;只替换已配置后端的 HTTP 响应。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
@@ -10,7 +11,10 @@ import {
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function purchaseBoundary(page, { savedCascade } = {}) {
|
||||
async function purchaseBoundary(
|
||||
page,
|
||||
{ savedCascade, missingCartRevision = false, rejectCartWrites = 0 } = {},
|
||||
) {
|
||||
const state = {
|
||||
authorized: false,
|
||||
orders: new Map(),
|
||||
@@ -19,7 +23,29 @@ async function purchaseBoundary(page, { savedCascade } = {}) {
|
||||
carts: [],
|
||||
settlements: [],
|
||||
requests: [],
|
||||
cartReads: 0,
|
||||
cartRevisions: [],
|
||||
cartWrites: [],
|
||||
cartRows:
|
||||
savedCascade === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
config_options: {
|
||||
cycle: "free",
|
||||
cascade_configoption: {
|
||||
5: { item_id: savedCascade, quantity: 1 },
|
||||
},
|
||||
},
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const revision = () =>
|
||||
createHash("sha256").update(JSON.stringify(state.cartRows)).digest("hex");
|
||||
const tree = [
|
||||
{
|
||||
item_name: "隐藏区域",
|
||||
@@ -179,26 +205,44 @@ async function purchaseBoundary(page, { savedCascade } = {}) {
|
||||
state.carts.push(body);
|
||||
return respond({});
|
||||
}
|
||||
if (path === "/cart" && method === "GET")
|
||||
if (path === "/cart" && method === "GET") {
|
||||
state.cartReads++;
|
||||
state.cartRevisions.push(revision());
|
||||
return respond({
|
||||
list:
|
||||
savedCascade === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
config_options: {
|
||||
cycle: "free",
|
||||
cascade_configoption: {
|
||||
5: { item_id: savedCascade, quantity: 1 },
|
||||
},
|
||||
},
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
},
|
||||
],
|
||||
list: state.cartRows.map((row) => ({ ...row, name: "本地级联配置" })),
|
||||
...(missingCartRevision ? {} : { revision: revision() }),
|
||||
});
|
||||
}
|
||||
if (method === "PUT" && /^\/cart\/\d+(\/qty)?$/.test(path)) {
|
||||
const currentRevision = revision();
|
||||
state.cartWrites.push({
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
cartReads: state.cartReads,
|
||||
revision: currentRevision,
|
||||
});
|
||||
const status =
|
||||
rejectCartWrites ||
|
||||
(!body?.expected_revision
|
||||
? 428
|
||||
: body.expected_revision !== currentRevision
|
||||
? 409
|
||||
: 200);
|
||||
if (status !== 200)
|
||||
return respond({}, { status, msg: "购物车状态待重新确认" });
|
||||
const position = Number(path.split("/")[2]);
|
||||
state.cartRows[position] = path.endsWith("/qty")
|
||||
? { ...state.cartRows[position], qty: body.qty }
|
||||
: {
|
||||
product_id: body.product_id,
|
||||
qty: body.qty,
|
||||
config_options: body.config_options,
|
||||
customfield: body.customfield,
|
||||
self_defined_field: body.self_defined_field,
|
||||
};
|
||||
return respond({ revision: revision() });
|
||||
}
|
||||
if (path === "/product/settle") {
|
||||
state.settlements.push(body);
|
||||
return respond({ order_id: 42, amount: "0.00" });
|
||||
@@ -440,3 +484,175 @@ test(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"cart configuration keeps the original revision without fetching a replacement before saving",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, { savedCascade: 20 });
|
||||
await login(page, "/products/123?edit_position=0");
|
||||
const save = page.getByRole("button", { name: "保存配置", exact: true });
|
||||
await eventually(
|
||||
() => save.isEnabled(),
|
||||
Boolean,
|
||||
"编辑配置必须先取得有效报价",
|
||||
);
|
||||
await save.click();
|
||||
await eventually(
|
||||
() => state.cartWrites.length,
|
||||
(count) => count === 1,
|
||||
"配置只能提交一次",
|
||||
);
|
||||
const write = state.cartWrites[0];
|
||||
assert.equal(write.body.expected_revision, write.revision);
|
||||
assert.equal(write.cartReads, 1);
|
||||
assert.equal(Object.hasOwn(write.body, "expected_item"), false);
|
||||
assert.equal(Object.hasOwn(write.body, "expected_cart"), false);
|
||||
await page.waitForURL((url) => url.pathname === "/cart");
|
||||
return {
|
||||
originalRevisionSubmitted: true,
|
||||
readsBeforeSave: write.cartReads,
|
||||
configurationWrites: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const status of [409, 428]) {
|
||||
test(
|
||||
`cart editing stops after server ${status} and requires returning to the cart`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, {
|
||||
savedCascade: 20,
|
||||
rejectCartWrites: status === 428 ? status : 0,
|
||||
});
|
||||
await login(page, "/products/123?edit_position=0");
|
||||
const save = page.getByRole("button", {
|
||||
name: "保存配置",
|
||||
exact: true,
|
||||
});
|
||||
await eventually(
|
||||
() => save.isEnabled(),
|
||||
Boolean,
|
||||
"编辑配置必须先取得有效报价",
|
||||
);
|
||||
const originalRevision = state.cartRevisions[0];
|
||||
// 另一会话只改动兄弟项,当前商品仍相同,也不能把新版本套在旧位置上。
|
||||
if (status === 409)
|
||||
state.cartRows.push({ ...state.cartRows[0], qty: 2 });
|
||||
await save.click();
|
||||
await eventually(
|
||||
() => state.cartWrites.length,
|
||||
(count) => count === 1,
|
||||
"冲突只允许一次写入尝试",
|
||||
);
|
||||
await page.locator(".p-message-error").waitFor();
|
||||
assert.equal(new URL(page.url()).pathname, "/products/123");
|
||||
assert.equal(state.cartReads, 1);
|
||||
assert.equal(
|
||||
state.cartWrites[0].body.expected_revision,
|
||||
originalRevision,
|
||||
);
|
||||
if (status === 409)
|
||||
assert.notEqual(state.cartWrites[0].revision, originalRevision);
|
||||
await page
|
||||
.locator(".product-links")
|
||||
.getByRole("link", { name: "购物车", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/cart");
|
||||
await eventually(
|
||||
() => state.cartReads,
|
||||
(count) => count === 2,
|
||||
"返回购物车后重新读取可确认的列表",
|
||||
);
|
||||
assert.equal(state.cartWrites.length, 1);
|
||||
return {
|
||||
rejectedStatus: status,
|
||||
configurationWrites: 1,
|
||||
readsOnlyAfterReturning: 2,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
`cart server ${status} clears the selection without retrying a positional mutation`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, {
|
||||
savedCascade: 20,
|
||||
rejectCartWrites: status,
|
||||
});
|
||||
await login(page, "/cart");
|
||||
const selected = page.getByRole("checkbox", {
|
||||
name: "选择 本地级联配置",
|
||||
exact: true,
|
||||
});
|
||||
await selected.waitFor();
|
||||
assert.equal(await selected.isChecked(), true);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: "增加 本地级联配置 的数量",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.cartWrites.length,
|
||||
(count) => count === 1,
|
||||
"修改数量只提交一次",
|
||||
);
|
||||
await eventually(
|
||||
() => selected.isChecked(),
|
||||
(checked) => !checked,
|
||||
"冲突后必须重新选择商品确认",
|
||||
);
|
||||
assert.equal(
|
||||
state.cartWrites[0].body.expected_revision,
|
||||
state.cartWrites[0].revision,
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(state.cartWrites[0].body, "expected_cart"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "提交订单", exact: true })
|
||||
.isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(state.cartWrites.length, 1);
|
||||
return {
|
||||
rejectedStatus: status,
|
||||
mutationAttempts: 1,
|
||||
selectionCleared: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"a cart response without a revision offers reload and cannot mutate old positions",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, {
|
||||
savedCascade: 20,
|
||||
missingCartRevision: true,
|
||||
});
|
||||
await login(page, "/cart");
|
||||
await page
|
||||
.getByRole("button", { name: "重新加载", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.locator(".cart-item").count(), 0);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole("button", { name: "提交订单", exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(state.cartWrites.length, 0);
|
||||
return { missingRevisionBlocked: true, mutationAttempts: 0 };
|
||||
}),
|
||||
);
|
||||
@@ -3,6 +3,7 @@
|
||||
* 所有内容来自生产已有的本地 HTTP 边界;尺寸断言读取实际 DOM,不检查源码形状。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { firefox } from "playwright";
|
||||
import {
|
||||
@@ -118,17 +119,22 @@ async function layoutBoundary(
|
||||
create_time: 1700000000,
|
||||
},
|
||||
});
|
||||
if (path === "/cart")
|
||||
if (path === "/cart") {
|
||||
const list = Array.from({ length: 14 }, (_, index) => ({
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
name: `云产品 ${index + 1}`,
|
||||
config_options: { cpu: index + 1 },
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
}));
|
||||
return reply({
|
||||
list: Array.from({ length: 14 }, (_, index) => ({
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
name: `云产品 ${index + 1}`,
|
||||
config_options: { cpu: index + 1 },
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
})),
|
||||
list,
|
||||
revision: createHash("sha256")
|
||||
.update(JSON.stringify(list))
|
||||
.digest("hex"),
|
||||
});
|
||||
}
|
||||
if (path === "/product/123/config_option")
|
||||
return reply({
|
||||
price_total: "10.00",
|
||||
|
||||
Reference in new issue
Block a user