修复购物车位置操作的版本绑定与冲突确认
前端验证 / verify (push) Failing after 15m3s

This commit is contained in:
cieanyau committed 2026-11-07 08:27:36 +08:00
1 parent e27f08e36e
commit b788f890f8
5 files changed
+288 -59

No files matched your search

+235 -19
View File
@@ -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 };
}),
);
+15 -9
View File
@@ -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",