@open-mercato/checkout 0.6.5-develop.5169.1.d0671533ca → 0.6.5-develop.5200.1.871eca3402
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/checkout/__integration__/TC-UNDO-001-no-undo-affordance.spec.js +40 -0
- package/dist/modules/checkout/__integration__/TC-UNDO-001-no-undo-affordance.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-UNDO-001-pay-link.spec.js +205 -0
- package/dist/modules/checkout/__integration__/TC-UNDO-001-pay-link.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-UNDO-001-template.spec.js +171 -0
- package/dist/modules/checkout/__integration__/TC-UNDO-001-template.spec.js.map +7 -0
- package/package.json +5 -5
- package/src/modules/checkout/__integration__/TC-UNDO-001-no-undo-affordance.spec.ts +51 -0
- package/src/modules/checkout/__integration__/TC-UNDO-001-pay-link.spec.ts +266 -0
- package/src/modules/checkout/__integration__/TC-UNDO-001-template.spec.ts +228 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:checkout] found
|
|
1
|
+
[build:checkout] found 156 entry points
|
|
2
2
|
[build:checkout] built successfully
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { extractOperation, skipIfUndoTestsDisabled } from "@open-mercato/core/helpers/integration/undoHarness";
|
|
4
|
+
import {
|
|
5
|
+
createCustomerData,
|
|
6
|
+
createFixedTemplateInput,
|
|
7
|
+
createLinkFixture,
|
|
8
|
+
createTemplateFixture,
|
|
9
|
+
deleteCheckoutEntityIfExists,
|
|
10
|
+
submitPayLink
|
|
11
|
+
} from "./helpers/fixtures.js";
|
|
12
|
+
test.describe("TC-UNDO-001 checkout \xA74 \u2014 non-undoable commands expose no undo token", () => {
|
|
13
|
+
test.beforeAll(() => {
|
|
14
|
+
skipIfUndoTestsDisabled();
|
|
15
|
+
});
|
|
16
|
+
test("public checkout submit creates a transaction with no undo token", async ({ request }) => {
|
|
17
|
+
const token = await getAuthToken(request, "admin");
|
|
18
|
+
let templateId = null;
|
|
19
|
+
let linkId = null;
|
|
20
|
+
try {
|
|
21
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: "draft" }));
|
|
22
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: "active", templateId }));
|
|
23
|
+
linkId = link.id;
|
|
24
|
+
const submitRes = await submitPayLink(request, link.slug, {
|
|
25
|
+
customerData: createCustomerData(),
|
|
26
|
+
acceptedLegalConsents: {},
|
|
27
|
+
amount: 49.99
|
|
28
|
+
});
|
|
29
|
+
expect(submitRes.status(), `submit status ${submitRes.status()}`).toBe(201);
|
|
30
|
+
expect(
|
|
31
|
+
extractOperation(submitRes),
|
|
32
|
+
"a financial checkout submit must expose no undo token (\xA74)"
|
|
33
|
+
).toBeNull();
|
|
34
|
+
} finally {
|
|
35
|
+
await deleteCheckoutEntityIfExists(request, token, "links", linkId);
|
|
36
|
+
await deleteCheckoutEntityIfExists(request, token, "templates", templateId);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
//# sourceMappingURL=TC-UNDO-001-no-undo-affordance.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/checkout/__integration__/TC-UNDO-001-no-undo-affordance.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { extractOperation, skipIfUndoTestsDisabled } from '@open-mercato/core/helpers/integration/undoHarness'\nimport {\n createCustomerData,\n createFixedTemplateInput,\n createLinkFixture,\n createTemplateFixture,\n deleteCheckoutEntityIfExists,\n submitPayLink,\n} from './helpers/fixtures'\n\n/**\n * TC-UNDO-001 (\u00A74 checkout) \u2014 non-undoable commands expose no undo affordance.\n *\n * Public checkout submissions create a CheckoutTransaction via the `checkout.transaction.*`\n * commands, which are intentionally NOT undoable (financial events). The mutating response\n * must therefore carry no `x-om-operation` undo envelope: `extractOperation(res) === null`.\n */\n\ntest.describe('TC-UNDO-001 checkout \u00A74 \u2014 non-undoable commands expose no undo token', () => {\n test.beforeAll(() => {\n skipIfUndoTestsDisabled()\n })\n\n test('public checkout submit creates a transaction with no undo token', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let templateId: string | null = null\n let linkId: string | null = null\n try {\n templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))\n const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'active', templateId }))\n linkId = link.id\n\n const submitRes = await submitPayLink(request, link.slug, {\n customerData: createCustomerData(),\n acceptedLegalConsents: {},\n amount: 49.99,\n })\n expect(submitRes.status(), `submit status ${submitRes.status()}`).toBe(201)\n\n expect(\n extractOperation(submitRes),\n 'a financial checkout submit must expose no undo token (\u00A74)',\n ).toBeNull()\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB,+BAA+B;AAC1D;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAUP,KAAK,SAAS,gFAAwE,MAAM;AAC1F,OAAK,UAAU,MAAM;AACnB,4BAAwB;AAAA,EAC1B,CAAC;AAED,OAAK,mEAAmE,OAAO,EAAE,QAAQ,MAAM;AAC7F,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAChC,QAAI,SAAwB;AAC5B,QAAI;AACF,mBAAa,MAAM,sBAAsB,SAAS,OAAO,yBAAyB,EAAE,QAAQ,QAAQ,CAAC,CAAC;AACtG,YAAM,OAAO,MAAM,kBAAkB,SAAS,OAAO,yBAAyB,EAAE,QAAQ,UAAU,WAAW,CAAC,CAAC;AAC/G,eAAS,KAAK;AAEd,YAAM,YAAY,MAAM,cAAc,SAAS,KAAK,MAAM;AAAA,QACxD,cAAc,mBAAmB;AAAA,QACjC,uBAAuB,CAAC;AAAA,QACxB,QAAQ;AAAA,MACV,CAAC;AACD,aAAO,UAAU,OAAO,GAAG,iBAAiB,UAAU,OAAO,CAAC,EAAE,EAAE,KAAK,GAAG;AAE1E;AAAA,QACE,iBAAiB,SAAS;AAAA,QAC1B;AAAA,MACF,EAAE,SAAS;AAAA,IACb,UAAE;AACA,YAAM,6BAA6B,SAAS,OAAO,SAAS,MAAM;AAClE,YAAM,6BAA6B,SAAS,OAAO,aAAa,UAAU;AAAA,IAC5E;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
expectOperation,
|
|
6
|
+
undoOk,
|
|
7
|
+
redoOk,
|
|
8
|
+
expectTokenConsumed,
|
|
9
|
+
skipIfUndoTestsDisabled
|
|
10
|
+
} from "@open-mercato/core/helpers/integration/undoHarness";
|
|
11
|
+
import {
|
|
12
|
+
createFixedTemplateInput,
|
|
13
|
+
createLinkFixture,
|
|
14
|
+
deleteCheckoutEntityIfExists,
|
|
15
|
+
deleteLink,
|
|
16
|
+
updateLink
|
|
17
|
+
} from "./helpers/fixtures.js";
|
|
18
|
+
const LINKS = "/api/checkout/links";
|
|
19
|
+
async function settle() {
|
|
20
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
21
|
+
}
|
|
22
|
+
async function getLink(request, token, id) {
|
|
23
|
+
const res = await apiRequest(request, "GET", `${LINKS}/${encodeURIComponent(id)}`, { token });
|
|
24
|
+
return { status: res.status(), body: await readJsonSafe(res) };
|
|
25
|
+
}
|
|
26
|
+
test.describe("TC-UNDO-001 checkout.pay-link undo/redo", () => {
|
|
27
|
+
test.beforeAll(() => {
|
|
28
|
+
skipIfUndoTestsDisabled();
|
|
29
|
+
});
|
|
30
|
+
test("create \u2192 undo soft-deletes (I3) + token consumed (I5)", async ({ request }) => {
|
|
31
|
+
const token = await getAuthToken(request, "admin");
|
|
32
|
+
let linkId = null;
|
|
33
|
+
try {
|
|
34
|
+
const createRes = await apiRequest(request, "POST", LINKS, {
|
|
35
|
+
token,
|
|
36
|
+
data: createFixedTemplateInput({ status: "draft" })
|
|
37
|
+
});
|
|
38
|
+
expect(createRes.status(), `create status ${createRes.status()}`).toBe(201);
|
|
39
|
+
const createOp = expectOperation(createRes, "checkout.link.create");
|
|
40
|
+
linkId = createOp.resourceId;
|
|
41
|
+
expect(linkId, "create returns a resource id").toBeTruthy();
|
|
42
|
+
expect((await getLink(request, token, linkId)).status, "pay-link exists after create").toBe(200);
|
|
43
|
+
await undoOk(request, token, createOp.undoToken, "undo create pay-link");
|
|
44
|
+
expect(
|
|
45
|
+
(await getLink(request, token, linkId)).status,
|
|
46
|
+
"pay-link is gone after undoing create (I3 \u2014 soft-deleted, not readable)"
|
|
47
|
+
).not.toBe(200);
|
|
48
|
+
await expectTokenConsumed(request, token, createOp.undoToken, "checkout.link.create double-undo (I5)");
|
|
49
|
+
} finally {
|
|
50
|
+
await deleteCheckoutEntityIfExists(request, token, "links", linkId);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
test("update \u2192 undo restores scalars (I1) \u2192 redo re-applies (I6)", async ({ request }) => {
|
|
54
|
+
const token = await getAuthToken(request, "admin");
|
|
55
|
+
let linkId = null;
|
|
56
|
+
try {
|
|
57
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: "draft" }));
|
|
58
|
+
linkId = link.id;
|
|
59
|
+
const before = await getLink(request, token, linkId);
|
|
60
|
+
const beforeName = before.body?.name;
|
|
61
|
+
expect(beforeName, "pay-link name readable before update").toBeTruthy();
|
|
62
|
+
await settle();
|
|
63
|
+
const changedName = `Renamed by undo test ${Date.now()}`;
|
|
64
|
+
const updateRes = await updateLink(request, token, linkId, { name: changedName });
|
|
65
|
+
expect(updateRes.status(), `update status ${updateRes.status()}`).toBe(200);
|
|
66
|
+
const updateOp = expectOperation(updateRes, "checkout.link.update");
|
|
67
|
+
expect((await getLink(request, token, linkId)).body?.name, "update changed the name").toBe(changedName);
|
|
68
|
+
await settle();
|
|
69
|
+
await undoOk(request, token, updateOp.undoToken, "undo update pay-link");
|
|
70
|
+
const afterUndo = await getLink(request, token, linkId);
|
|
71
|
+
expect(afterUndo.body?.name, "update\u2192undo restores the prior name (I1)").toBe(beforeName);
|
|
72
|
+
expect(typeof afterUndo.body?.updatedAt, "pay-link surfaces updatedAt").toBe("string");
|
|
73
|
+
expect(afterUndo.body?.updatedAt, "undo bumps updatedAt (I1)").not.toBe(before.body?.updatedAt);
|
|
74
|
+
await redoOk(request, token, updateOp.logId, "redo update pay-link");
|
|
75
|
+
expect((await getLink(request, token, linkId)).body?.name, "redo re-applies the renamed value (I6)").toBe(changedName);
|
|
76
|
+
} finally {
|
|
77
|
+
await deleteCheckoutEntityIfExists(request, token, "links", linkId);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
test("delete \u2192 undo re-materializes (I2) \u2192 redo re-deletes (I6)", async ({ request }) => {
|
|
81
|
+
const token = await getAuthToken(request, "admin");
|
|
82
|
+
let linkId = null;
|
|
83
|
+
try {
|
|
84
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: "draft" }));
|
|
85
|
+
linkId = link.id;
|
|
86
|
+
const beforeName = (await getLink(request, token, linkId)).body?.name;
|
|
87
|
+
await settle();
|
|
88
|
+
const deleteRes = await deleteLink(request, token, linkId);
|
|
89
|
+
expect(deleteRes.ok(), `delete status ${deleteRes.status()}`).toBeTruthy();
|
|
90
|
+
const deleteOp = expectOperation(deleteRes, "checkout.link.delete");
|
|
91
|
+
expect((await getLink(request, token, linkId)).status, "gone after delete").not.toBe(200);
|
|
92
|
+
await undoOk(request, token, deleteOp.undoToken, "undo delete pay-link");
|
|
93
|
+
const afterUndo = await getLink(request, token, linkId);
|
|
94
|
+
expect(afterUndo.status, "delete\u2192undo re-materializes the row (I2)").toBe(200);
|
|
95
|
+
expect(afterUndo.body?.name, "re-materialized record keeps its scalars (I2)").toBe(beforeName);
|
|
96
|
+
await redoOk(request, token, deleteOp.logId, "redo delete pay-link");
|
|
97
|
+
expect((await getLink(request, token, linkId)).status, "gone again after redo delete (I6)").not.toBe(200);
|
|
98
|
+
} finally {
|
|
99
|
+
await deleteCheckoutEntityIfExists(request, token, "links", linkId);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test("create \u2192 undo \u2192 redo restores the SAME record (I6)", async ({ request }) => {
|
|
103
|
+
const token = await getAuthToken(request, "admin");
|
|
104
|
+
let linkId = null;
|
|
105
|
+
try {
|
|
106
|
+
const createRes = await apiRequest(request, "POST", LINKS, {
|
|
107
|
+
token,
|
|
108
|
+
data: createFixedTemplateInput({ status: "draft" })
|
|
109
|
+
});
|
|
110
|
+
const createOp = expectOperation(createRes, "checkout.link.create");
|
|
111
|
+
linkId = createOp.resourceId;
|
|
112
|
+
await settle();
|
|
113
|
+
const undoLogId = await undoOk(request, token, createOp.undoToken, "undo create pay-link");
|
|
114
|
+
expect((await getLink(request, token, linkId)).status, "gone after undo create").not.toBe(200);
|
|
115
|
+
await redoOk(request, token, undoLogId, "redo create pay-link");
|
|
116
|
+
const afterRedo = await getLink(request, token, linkId);
|
|
117
|
+
expect(afterRedo.status, "same pay-link restored after redo (I6)").toBe(200);
|
|
118
|
+
expect(afterRedo.body?.id, "redo preserves the original id (I6)").toBe(linkId);
|
|
119
|
+
} finally {
|
|
120
|
+
await deleteCheckoutEntityIfExists(request, token, "links", linkId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
test("custom fields revert on undo and re-apply on redo (I4)", async ({ request }) => {
|
|
124
|
+
const token = await getAuthToken(request, "admin");
|
|
125
|
+
let linkId = null;
|
|
126
|
+
try {
|
|
127
|
+
const beforeValue = `before ${Date.now()}`;
|
|
128
|
+
const link = await createLinkFixture(
|
|
129
|
+
request,
|
|
130
|
+
token,
|
|
131
|
+
createFixedTemplateInput({
|
|
132
|
+
status: "draft",
|
|
133
|
+
customFieldsetCode: "service_package",
|
|
134
|
+
customFields: { delivery_timeline: beforeValue }
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
linkId = link.id;
|
|
138
|
+
expect(
|
|
139
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
140
|
+
"custom field persisted on create"
|
|
141
|
+
).toBe(beforeValue);
|
|
142
|
+
await settle();
|
|
143
|
+
const afterValue = `after ${Date.now()}`;
|
|
144
|
+
const updateRes = await updateLink(request, token, linkId, { customFields: { delivery_timeline: afterValue } });
|
|
145
|
+
const updateOp = expectOperation(updateRes, "checkout.link.update");
|
|
146
|
+
expect(
|
|
147
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
148
|
+
"update changed the custom field"
|
|
149
|
+
).toBe(afterValue);
|
|
150
|
+
await settle();
|
|
151
|
+
await undoOk(request, token, updateOp.undoToken, "undo update custom field");
|
|
152
|
+
expect(
|
|
153
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
154
|
+
"custom field reverts on undo (I4)"
|
|
155
|
+
).toBe(beforeValue);
|
|
156
|
+
await redoOk(request, token, updateOp.logId, "redo update custom field");
|
|
157
|
+
expect(
|
|
158
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
159
|
+
"custom field re-applies on redo (I4)"
|
|
160
|
+
).toBe(afterValue);
|
|
161
|
+
} finally {
|
|
162
|
+
await deleteCheckoutEntityIfExists(request, token, "links", linkId);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
test("null gatewayProviderKey edit \u2192 undo restores the prior gateway (#2540, I1)", async ({ request }) => {
|
|
166
|
+
const token = await getAuthToken(request, "admin");
|
|
167
|
+
let linkId = null;
|
|
168
|
+
try {
|
|
169
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: "draft" }));
|
|
170
|
+
linkId = link.id;
|
|
171
|
+
const before = await getLink(request, token, linkId);
|
|
172
|
+
expect(before.body?.gatewayProviderKey, "pay-link starts with the mock gateway").toBe("mock");
|
|
173
|
+
await settle();
|
|
174
|
+
const updateRes = await updateLink(request, token, linkId, {
|
|
175
|
+
name: "Pay link (no gateway)",
|
|
176
|
+
pricingMode: "fixed",
|
|
177
|
+
fixedPriceAmount: 49.99,
|
|
178
|
+
fixedPriceCurrencyCode: "USD",
|
|
179
|
+
status: "draft",
|
|
180
|
+
gatewayProviderKey: null
|
|
181
|
+
});
|
|
182
|
+
expect(
|
|
183
|
+
updateRes.ok(),
|
|
184
|
+
`clearing the gateway on a draft pay-link should succeed: ${updateRes.status()}`
|
|
185
|
+
).toBeTruthy();
|
|
186
|
+
const updateOp = expectOperation(updateRes, "checkout.link.update (null gateway)");
|
|
187
|
+
const afterUpdate = await getLink(request, token, linkId);
|
|
188
|
+
expect(afterUpdate.body?.gatewayProviderKey ?? null, "gateway cleared to null after edit").toBeNull();
|
|
189
|
+
expect(afterUpdate.body?.name, "name changed by edit").toBe("Pay link (no gateway)");
|
|
190
|
+
await settle();
|
|
191
|
+
await undoOk(request, token, updateOp.undoToken, "undo null-gateway edit");
|
|
192
|
+
const afterUndo = await getLink(request, token, linkId);
|
|
193
|
+
expect(afterUndo.body?.gatewayProviderKey, "undo restores the prior gateway (#2540, I1)").toBe("mock");
|
|
194
|
+
expect(afterUndo.body?.name, "undo restores the prior name (I1)").toBe(before.body?.name);
|
|
195
|
+
await redoOk(request, token, updateOp.logId, "redo null-gateway edit");
|
|
196
|
+
expect(
|
|
197
|
+
(await getLink(request, token, linkId)).body?.gatewayProviderKey ?? null,
|
|
198
|
+
"redo re-clears the gateway to null (I6)"
|
|
199
|
+
).toBeNull();
|
|
200
|
+
} finally {
|
|
201
|
+
await deleteCheckoutEntityIfExists(request, token, "links", linkId);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
//# sourceMappingURL=TC-UNDO-001-pay-link.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/checkout/__integration__/TC-UNDO-001-pay-link.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n expectOperation,\n undoOk,\n redoOk,\n expectTokenConsumed,\n skipIfUndoTestsDisabled,\n} from '@open-mercato/core/helpers/integration/undoHarness'\nimport {\n createFixedTemplateInput,\n createLinkFixture,\n deleteCheckoutEntityIfExists,\n deleteLink,\n updateLink,\n} from './helpers/fixtures'\n\n/**\n * TC-UNDO-001 (\u00A73 checkout.pay-link) \u2014 Undo/Redo correctness for the checkout pay-link\n * command bus, driven through the real `/api/checkout/links` routes plus the\n * `/api/audit_logs/audit-logs/actions/undo|redo` endpoints.\n *\n * Invariants asserted: I1 (update\u2192undo restores), I2 (delete\u2192undo re-materializes),\n * I3 (create\u2192undo soft-deletes), I4 (custom fields restore), I5 (token consumed),\n * I6 (redo reproduces post-state). checkout.link.create ships an id-preserving redo\n * handler, so the create\u2192undo\u2192redo SAME-id leg is asserted as corrected behaviour.\n *\n * Also covers the #2540 fix: a draft pay-link may persist a null gatewayProviderKey,\n * and editing it to null then undoing must restore the prior gateway.\n */\n\nconst LINKS = '/api/checkout/links'\n\ntype LinkRecord = {\n id?: string\n name?: string\n slug?: string\n updatedAt?: string\n gatewayProviderKey?: string | null\n customFields?: Record<string, unknown>\n}\n\n// The undo endpoint resolves the *latest* undoable log for a resource ordered by\n// millisecond-precision created_at, so consecutive mutations issued within the same\n// millisecond can tie. A short settle keeps each round-trip deterministic.\nasync function settle(): Promise<void> {\n await new Promise((resolve) => setTimeout(resolve, 50))\n}\n\nasync function getLink(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<{ status: number; body: LinkRecord | null }> {\n const res = await apiRequest(request, 'GET', `${LINKS}/${encodeURIComponent(id)}`, { token })\n return { status: res.status(), body: await readJsonSafe<LinkRecord>(res) }\n}\n\ntest.describe('TC-UNDO-001 checkout.pay-link undo/redo', () => {\n test.beforeAll(() => {\n skipIfUndoTestsDisabled()\n })\n\n test('create \u2192 undo soft-deletes (I3) + token consumed (I5)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let linkId: string | null = null\n try {\n const createRes = await apiRequest(request, 'POST', LINKS, {\n token,\n data: createFixedTemplateInput({ status: 'draft' }),\n })\n expect(createRes.status(), `create status ${createRes.status()}`).toBe(201)\n const createOp = expectOperation(createRes, 'checkout.link.create')\n linkId = createOp.resourceId\n expect(linkId, 'create returns a resource id').toBeTruthy()\n\n expect((await getLink(request, token, linkId as string)).status, 'pay-link exists after create').toBe(200)\n\n await undoOk(request, token, createOp.undoToken, 'undo create pay-link')\n expect(\n (await getLink(request, token, linkId as string)).status,\n 'pay-link is gone after undoing create (I3 \u2014 soft-deleted, not readable)',\n ).not.toBe(200)\n\n await expectTokenConsumed(request, token, createOp.undoToken, 'checkout.link.create double-undo (I5)')\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n }\n })\n\n test('update \u2192 undo restores scalars (I1) \u2192 redo re-applies (I6)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let linkId: string | null = null\n try {\n const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))\n linkId = link.id\n const before = await getLink(request, token, linkId)\n const beforeName = before.body?.name\n expect(beforeName, 'pay-link name readable before update').toBeTruthy()\n\n await settle()\n const changedName = `Renamed by undo test ${Date.now()}`\n const updateRes = await updateLink(request, token, linkId, { name: changedName })\n expect(updateRes.status(), `update status ${updateRes.status()}`).toBe(200)\n const updateOp = expectOperation(updateRes, 'checkout.link.update')\n expect((await getLink(request, token, linkId)).body?.name, 'update changed the name').toBe(changedName)\n\n await settle()\n await undoOk(request, token, updateOp.undoToken, 'undo update pay-link')\n const afterUndo = await getLink(request, token, linkId)\n expect(afterUndo.body?.name, 'update\u2192undo restores the prior name (I1)').toBe(beforeName)\n expect(typeof afterUndo.body?.updatedAt, 'pay-link surfaces updatedAt').toBe('string')\n expect(afterUndo.body?.updatedAt, 'undo bumps updatedAt (I1)').not.toBe(before.body?.updatedAt)\n\n await redoOk(request, token, updateOp.logId, 'redo update pay-link')\n expect((await getLink(request, token, linkId)).body?.name, 'redo re-applies the renamed value (I6)').toBe(changedName)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n }\n })\n\n test('delete \u2192 undo re-materializes (I2) \u2192 redo re-deletes (I6)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let linkId: string | null = null\n try {\n const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))\n linkId = link.id\n const beforeName = (await getLink(request, token, linkId)).body?.name\n\n await settle()\n const deleteRes = await deleteLink(request, token, linkId)\n expect(deleteRes.ok(), `delete status ${deleteRes.status()}`).toBeTruthy()\n const deleteOp = expectOperation(deleteRes, 'checkout.link.delete')\n expect((await getLink(request, token, linkId)).status, 'gone after delete').not.toBe(200)\n\n await undoOk(request, token, deleteOp.undoToken, 'undo delete pay-link')\n const afterUndo = await getLink(request, token, linkId)\n expect(afterUndo.status, 'delete\u2192undo re-materializes the row (I2)').toBe(200)\n expect(afterUndo.body?.name, 're-materialized record keeps its scalars (I2)').toBe(beforeName)\n\n await redoOk(request, token, deleteOp.logId, 'redo delete pay-link')\n expect((await getLink(request, token, linkId)).status, 'gone again after redo delete (I6)').not.toBe(200)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n }\n })\n\n test('create \u2192 undo \u2192 redo restores the SAME record (I6)', async ({ request }) => {\n // checkout.link.create registers an id-preserving redo handler, so redo must restore\n // the original soft-deleted row \u2014 not mint a new id (the #2468 customers.people defect).\n const token = await getAuthToken(request, 'admin')\n let linkId: string | null = null\n try {\n const createRes = await apiRequest(request, 'POST', LINKS, {\n token,\n data: createFixedTemplateInput({ status: 'draft' }),\n })\n const createOp = expectOperation(createRes, 'checkout.link.create')\n linkId = createOp.resourceId\n\n await settle()\n const undoLogId = await undoOk(request, token, createOp.undoToken, 'undo create pay-link')\n expect((await getLink(request, token, linkId as string)).status, 'gone after undo create').not.toBe(200)\n\n await redoOk(request, token, undoLogId, 'redo create pay-link')\n const afterRedo = await getLink(request, token, linkId as string)\n expect(afterRedo.status, 'same pay-link restored after redo (I6)').toBe(200)\n expect(afterRedo.body?.id, 'redo preserves the original id (I6)').toBe(linkId)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n }\n })\n\n test('custom fields revert on undo and re-apply on redo (I4)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let linkId: string | null = null\n try {\n const beforeValue = `before ${Date.now()}`\n const link = await createLinkFixture(\n request,\n token,\n createFixedTemplateInput({\n status: 'draft',\n customFieldsetCode: 'service_package',\n customFields: { delivery_timeline: beforeValue },\n }),\n )\n linkId = link.id\n expect(\n (await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,\n 'custom field persisted on create',\n ).toBe(beforeValue)\n\n await settle()\n const afterValue = `after ${Date.now()}`\n const updateRes = await updateLink(request, token, linkId, { customFields: { delivery_timeline: afterValue } })\n const updateOp = expectOperation(updateRes, 'checkout.link.update')\n expect(\n (await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,\n 'update changed the custom field',\n ).toBe(afterValue)\n\n await settle()\n await undoOk(request, token, updateOp.undoToken, 'undo update custom field')\n expect(\n (await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,\n 'custom field reverts on undo (I4)',\n ).toBe(beforeValue)\n\n await redoOk(request, token, updateOp.logId, 'redo update custom field')\n expect(\n (await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,\n 'custom field re-applies on redo (I4)',\n ).toBe(afterValue)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n }\n })\n\n test('null gatewayProviderKey edit \u2192 undo restores the prior gateway (#2540, I1)', async ({ request }) => {\n // #2540: a draft pay-link may persist a null gatewayProviderKey. Editing the gateway to\n // null must round-trip, and undoing the edit must restore the prior gateway value.\n const token = await getAuthToken(request, 'admin')\n let linkId: string | null = null\n try {\n const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))\n linkId = link.id\n const before = await getLink(request, token, linkId)\n expect(before.body?.gatewayProviderKey, 'pay-link starts with the mock gateway').toBe('mock')\n\n await settle()\n const updateRes = await updateLink(request, token, linkId, {\n name: 'Pay link (no gateway)',\n pricingMode: 'fixed',\n fixedPriceAmount: 49.99,\n fixedPriceCurrencyCode: 'USD',\n status: 'draft',\n gatewayProviderKey: null,\n })\n expect(\n updateRes.ok(),\n `clearing the gateway on a draft pay-link should succeed: ${updateRes.status()}`,\n ).toBeTruthy()\n const updateOp = expectOperation(updateRes, 'checkout.link.update (null gateway)')\n\n const afterUpdate = await getLink(request, token, linkId)\n expect(afterUpdate.body?.gatewayProviderKey ?? null, 'gateway cleared to null after edit').toBeNull()\n expect(afterUpdate.body?.name, 'name changed by edit').toBe('Pay link (no gateway)')\n\n await settle()\n await undoOk(request, token, updateOp.undoToken, 'undo null-gateway edit')\n const afterUndo = await getLink(request, token, linkId)\n expect(afterUndo.body?.gatewayProviderKey, 'undo restores the prior gateway (#2540, I1)').toBe('mock')\n expect(afterUndo.body?.name, 'undo restores the prior name (I1)').toBe(before.body?.name)\n\n await redoOk(request, token, updateOp.logId, 'redo null-gateway edit')\n expect(\n (await getLink(request, token, linkId)).body?.gatewayProviderKey ?? null,\n 'redo re-clears the gateway to null (I6)',\n ).toBeNull()\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'links', linkId)\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
expectOperation,
|
|
6
|
+
undoOk,
|
|
7
|
+
redoOk,
|
|
8
|
+
expectTokenConsumed,
|
|
9
|
+
skipIfUndoTestsDisabled
|
|
10
|
+
} from "@open-mercato/core/helpers/integration/undoHarness";
|
|
11
|
+
import {
|
|
12
|
+
createFixedTemplateInput,
|
|
13
|
+
createTemplateFixture,
|
|
14
|
+
deleteCheckoutEntityIfExists,
|
|
15
|
+
deleteTemplate,
|
|
16
|
+
updateTemplate
|
|
17
|
+
} from "./helpers/fixtures.js";
|
|
18
|
+
const TEMPLATES = "/api/checkout/templates";
|
|
19
|
+
async function settle() {
|
|
20
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
21
|
+
}
|
|
22
|
+
async function getTemplate(request, token, id) {
|
|
23
|
+
const res = await apiRequest(request, "GET", `${TEMPLATES}/${encodeURIComponent(id)}`, { token });
|
|
24
|
+
return { status: res.status(), body: await readJsonSafe(res) };
|
|
25
|
+
}
|
|
26
|
+
test.describe("TC-UNDO-001 checkout.template undo/redo", () => {
|
|
27
|
+
test.beforeAll(() => {
|
|
28
|
+
skipIfUndoTestsDisabled();
|
|
29
|
+
});
|
|
30
|
+
test("create \u2192 undo soft-deletes (I3) + token consumed (I5)", async ({ request }) => {
|
|
31
|
+
const token = await getAuthToken(request, "admin");
|
|
32
|
+
let templateId = null;
|
|
33
|
+
try {
|
|
34
|
+
const createRes = await apiRequest(request, "POST", TEMPLATES, {
|
|
35
|
+
token,
|
|
36
|
+
data: createFixedTemplateInput({ status: "draft" })
|
|
37
|
+
});
|
|
38
|
+
expect(createRes.status(), `create status ${createRes.status()}`).toBe(201);
|
|
39
|
+
const createOp = expectOperation(createRes, "checkout.template.create");
|
|
40
|
+
templateId = createOp.resourceId;
|
|
41
|
+
expect(templateId, "create returns a resource id").toBeTruthy();
|
|
42
|
+
expect((await getTemplate(request, token, templateId)).status, "template exists after create").toBe(200);
|
|
43
|
+
await undoOk(request, token, createOp.undoToken, "undo create template");
|
|
44
|
+
expect(
|
|
45
|
+
(await getTemplate(request, token, templateId)).status,
|
|
46
|
+
"template is gone after undoing create (I3 \u2014 soft-deleted, not readable)"
|
|
47
|
+
).not.toBe(200);
|
|
48
|
+
await expectTokenConsumed(request, token, createOp.undoToken, "checkout.template.create double-undo (I5)");
|
|
49
|
+
} finally {
|
|
50
|
+
await deleteCheckoutEntityIfExists(request, token, "templates", templateId);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
test("update \u2192 undo restores scalars (I1) \u2192 redo re-applies (I6)", async ({ request }) => {
|
|
54
|
+
const token = await getAuthToken(request, "admin");
|
|
55
|
+
let templateId = null;
|
|
56
|
+
try {
|
|
57
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: "draft" }));
|
|
58
|
+
const before = await getTemplate(request, token, templateId);
|
|
59
|
+
const beforeName = before.body?.name;
|
|
60
|
+
expect(beforeName, "template name readable before update").toBeTruthy();
|
|
61
|
+
await settle();
|
|
62
|
+
const changedName = `Renamed by undo test ${Date.now()}`;
|
|
63
|
+
const updateRes = await updateTemplate(request, token, templateId, { name: changedName });
|
|
64
|
+
expect(updateRes.status(), `update status ${updateRes.status()}`).toBe(200);
|
|
65
|
+
const updateOp = expectOperation(updateRes, "checkout.template.update");
|
|
66
|
+
expect((await getTemplate(request, token, templateId)).body?.name, "update changed the name").toBe(changedName);
|
|
67
|
+
await settle();
|
|
68
|
+
await undoOk(request, token, updateOp.undoToken, "undo update template");
|
|
69
|
+
const afterUndo = await getTemplate(request, token, templateId);
|
|
70
|
+
expect(afterUndo.body?.name, "update\u2192undo restores the prior name (I1)").toBe(beforeName);
|
|
71
|
+
expect(typeof afterUndo.body?.updatedAt, "template surfaces updatedAt").toBe("string");
|
|
72
|
+
expect(afterUndo.body?.updatedAt, "undo bumps updatedAt (I1)").not.toBe(before.body?.updatedAt);
|
|
73
|
+
await redoOk(request, token, updateOp.logId, "redo update template");
|
|
74
|
+
expect(
|
|
75
|
+
(await getTemplate(request, token, templateId)).body?.name,
|
|
76
|
+
"redo re-applies the renamed value (I6)"
|
|
77
|
+
).toBe(changedName);
|
|
78
|
+
} finally {
|
|
79
|
+
await deleteCheckoutEntityIfExists(request, token, "templates", templateId);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
test("delete \u2192 undo re-materializes (I2) \u2192 redo re-deletes (I6)", async ({ request }) => {
|
|
83
|
+
const token = await getAuthToken(request, "admin");
|
|
84
|
+
let templateId = null;
|
|
85
|
+
try {
|
|
86
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: "draft" }));
|
|
87
|
+
const beforeName = (await getTemplate(request, token, templateId)).body?.name;
|
|
88
|
+
await settle();
|
|
89
|
+
const deleteRes = await deleteTemplate(request, token, templateId);
|
|
90
|
+
expect(deleteRes.ok(), `delete status ${deleteRes.status()}`).toBeTruthy();
|
|
91
|
+
const deleteOp = expectOperation(deleteRes, "checkout.template.delete");
|
|
92
|
+
expect((await getTemplate(request, token, templateId)).status, "gone after delete").not.toBe(200);
|
|
93
|
+
await undoOk(request, token, deleteOp.undoToken, "undo delete template");
|
|
94
|
+
const afterUndo = await getTemplate(request, token, templateId);
|
|
95
|
+
expect(afterUndo.status, "delete\u2192undo re-materializes the row (I2)").toBe(200);
|
|
96
|
+
expect(afterUndo.body?.name, "re-materialized record keeps its scalars (I2)").toBe(beforeName);
|
|
97
|
+
await redoOk(request, token, deleteOp.logId, "redo delete template");
|
|
98
|
+
expect(
|
|
99
|
+
(await getTemplate(request, token, templateId)).status,
|
|
100
|
+
"gone again after redo delete (I6)"
|
|
101
|
+
).not.toBe(200);
|
|
102
|
+
} finally {
|
|
103
|
+
await deleteCheckoutEntityIfExists(request, token, "templates", templateId);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
test("create \u2192 undo \u2192 redo restores the SAME record (I6)", async ({ request }) => {
|
|
107
|
+
const token = await getAuthToken(request, "admin");
|
|
108
|
+
let templateId = null;
|
|
109
|
+
try {
|
|
110
|
+
const createRes = await apiRequest(request, "POST", TEMPLATES, {
|
|
111
|
+
token,
|
|
112
|
+
data: createFixedTemplateInput({ status: "draft" })
|
|
113
|
+
});
|
|
114
|
+
const createOp = expectOperation(createRes, "checkout.template.create");
|
|
115
|
+
templateId = createOp.resourceId;
|
|
116
|
+
await settle();
|
|
117
|
+
const undoLogId = await undoOk(request, token, createOp.undoToken, "undo create template");
|
|
118
|
+
expect((await getTemplate(request, token, templateId)).status, "gone after undo create").not.toBe(200);
|
|
119
|
+
await redoOk(request, token, undoLogId, "redo create template");
|
|
120
|
+
const afterRedo = await getTemplate(request, token, templateId);
|
|
121
|
+
expect(afterRedo.status, "same template restored after redo (I6)").toBe(200);
|
|
122
|
+
expect(afterRedo.body?.id, "redo preserves the original id (I6)").toBe(templateId);
|
|
123
|
+
} finally {
|
|
124
|
+
await deleteCheckoutEntityIfExists(request, token, "templates", templateId);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
test("custom fields revert on undo and re-apply on redo (I4)", async ({ request }) => {
|
|
128
|
+
const token = await getAuthToken(request, "admin");
|
|
129
|
+
let templateId = null;
|
|
130
|
+
try {
|
|
131
|
+
const beforeValue = `before ${Date.now()}`;
|
|
132
|
+
templateId = await createTemplateFixture(
|
|
133
|
+
request,
|
|
134
|
+
token,
|
|
135
|
+
createFixedTemplateInput({
|
|
136
|
+
status: "draft",
|
|
137
|
+
customFieldsetCode: "service_package",
|
|
138
|
+
customFields: { delivery_timeline: beforeValue }
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
expect(
|
|
142
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
143
|
+
"custom field persisted on create"
|
|
144
|
+
).toBe(beforeValue);
|
|
145
|
+
await settle();
|
|
146
|
+
const afterValue = `after ${Date.now()}`;
|
|
147
|
+
const updateRes = await updateTemplate(request, token, templateId, {
|
|
148
|
+
customFields: { delivery_timeline: afterValue }
|
|
149
|
+
});
|
|
150
|
+
const updateOp = expectOperation(updateRes, "checkout.template.update");
|
|
151
|
+
expect(
|
|
152
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
153
|
+
"update changed the custom field"
|
|
154
|
+
).toBe(afterValue);
|
|
155
|
+
await settle();
|
|
156
|
+
await undoOk(request, token, updateOp.undoToken, "undo update custom field");
|
|
157
|
+
expect(
|
|
158
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
159
|
+
"custom field reverts on undo (I4)"
|
|
160
|
+
).toBe(beforeValue);
|
|
161
|
+
await redoOk(request, token, updateOp.logId, "redo update custom field");
|
|
162
|
+
expect(
|
|
163
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
164
|
+
"custom field re-applies on redo (I4)"
|
|
165
|
+
).toBe(afterValue);
|
|
166
|
+
} finally {
|
|
167
|
+
await deleteCheckoutEntityIfExists(request, token, "templates", templateId);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
//# sourceMappingURL=TC-UNDO-001-template.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/checkout/__integration__/TC-UNDO-001-template.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test, type APIRequestContext } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n expectOperation,\n undoOk,\n redoOk,\n expectTokenConsumed,\n skipIfUndoTestsDisabled,\n} from '@open-mercato/core/helpers/integration/undoHarness'\nimport {\n createFixedTemplateInput,\n createTemplateFixture,\n deleteCheckoutEntityIfExists,\n deleteTemplate,\n updateTemplate,\n} from './helpers/fixtures'\n\n/**\n * TC-UNDO-001 (\u00A73 checkout.template) \u2014 Undo/Redo correctness for the checkout template\n * command bus, driven through the real `/api/checkout/templates` routes plus the\n * `/api/audit_logs/audit-logs/actions/undo|redo` endpoints.\n *\n * Invariants asserted (per the #2468 tracking issue):\n * I1 update\u2192undo restores scalars (and bumps updatedAt)\n * I2 delete\u2192undo re-materializes the soft-deleted row\n * I3 create\u2192undo soft-deletes (never hard-deletes)\n * I4 custom fields revert on undo and re-apply on redo\n * I5 a consumed undo token is rejected on a second undo\n * I6 redo reproduces the command's post-state\n *\n * Unlike the customers.people pilot (where create\u2192undo\u2192redo mints a new id, #2468),\n * checkout.template.create ships an id-preserving redo handler, so the SAME-id redo\n * leg is asserted as corrected behaviour rather than quarantined.\n */\n\nconst TEMPLATES = '/api/checkout/templates'\n\ntype TemplateRecord = {\n id?: string\n name?: string\n updatedAt?: string\n gatewayProviderKey?: string | null\n customFields?: Record<string, unknown>\n}\n\n// The undo endpoint resolves the *latest* undoable log for a resource ordered by\n// millisecond-precision created_at, so consecutive mutations issued within the same\n// millisecond can tie. A short settle keeps each round-trip deterministic.\nasync function settle(): Promise<void> {\n await new Promise((resolve) => setTimeout(resolve, 50))\n}\n\nasync function getTemplate(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<{ status: number; body: TemplateRecord | null }> {\n const res = await apiRequest(request, 'GET', `${TEMPLATES}/${encodeURIComponent(id)}`, { token })\n return { status: res.status(), body: await readJsonSafe<TemplateRecord>(res) }\n}\n\ntest.describe('TC-UNDO-001 checkout.template undo/redo', () => {\n test.beforeAll(() => {\n skipIfUndoTestsDisabled()\n })\n\n test('create \u2192 undo soft-deletes (I3) + token consumed (I5)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let templateId: string | null = null\n try {\n const createRes = await apiRequest(request, 'POST', TEMPLATES, {\n token,\n data: createFixedTemplateInput({ status: 'draft' }),\n })\n expect(createRes.status(), `create status ${createRes.status()}`).toBe(201)\n const createOp = expectOperation(createRes, 'checkout.template.create')\n templateId = createOp.resourceId\n expect(templateId, 'create returns a resource id').toBeTruthy()\n\n expect((await getTemplate(request, token, templateId as string)).status, 'template exists after create').toBe(200)\n\n await undoOk(request, token, createOp.undoToken, 'undo create template')\n expect(\n (await getTemplate(request, token, templateId as string)).status,\n 'template is gone after undoing create (I3 \u2014 soft-deleted, not readable)',\n ).not.toBe(200)\n\n await expectTokenConsumed(request, token, createOp.undoToken, 'checkout.template.create double-undo (I5)')\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)\n }\n })\n\n test('update \u2192 undo restores scalars (I1) \u2192 redo re-applies (I6)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let templateId: string | null = null\n try {\n templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))\n const before = await getTemplate(request, token, templateId)\n const beforeName = before.body?.name\n expect(beforeName, 'template name readable before update').toBeTruthy()\n\n await settle()\n const changedName = `Renamed by undo test ${Date.now()}`\n const updateRes = await updateTemplate(request, token, templateId, { name: changedName })\n expect(updateRes.status(), `update status ${updateRes.status()}`).toBe(200)\n const updateOp = expectOperation(updateRes, 'checkout.template.update')\n expect((await getTemplate(request, token, templateId)).body?.name, 'update changed the name').toBe(changedName)\n\n await settle()\n await undoOk(request, token, updateOp.undoToken, 'undo update template')\n const afterUndo = await getTemplate(request, token, templateId)\n expect(afterUndo.body?.name, 'update\u2192undo restores the prior name (I1)').toBe(beforeName)\n expect(typeof afterUndo.body?.updatedAt, 'template surfaces updatedAt').toBe('string')\n expect(afterUndo.body?.updatedAt, 'undo bumps updatedAt (I1)').not.toBe(before.body?.updatedAt)\n\n await redoOk(request, token, updateOp.logId, 'redo update template')\n expect(\n (await getTemplate(request, token, templateId)).body?.name,\n 'redo re-applies the renamed value (I6)',\n ).toBe(changedName)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)\n }\n })\n\n test('delete \u2192 undo re-materializes (I2) \u2192 redo re-deletes (I6)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let templateId: string | null = null\n try {\n templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))\n const beforeName = (await getTemplate(request, token, templateId)).body?.name\n\n await settle()\n const deleteRes = await deleteTemplate(request, token, templateId)\n expect(deleteRes.ok(), `delete status ${deleteRes.status()}`).toBeTruthy()\n const deleteOp = expectOperation(deleteRes, 'checkout.template.delete')\n expect((await getTemplate(request, token, templateId)).status, 'gone after delete').not.toBe(200)\n\n await undoOk(request, token, deleteOp.undoToken, 'undo delete template')\n const afterUndo = await getTemplate(request, token, templateId)\n expect(afterUndo.status, 'delete\u2192undo re-materializes the row (I2)').toBe(200)\n expect(afterUndo.body?.name, 're-materialized record keeps its scalars (I2)').toBe(beforeName)\n\n await redoOk(request, token, deleteOp.logId, 'redo delete template')\n expect(\n (await getTemplate(request, token, templateId)).status,\n 'gone again after redo delete (I6)',\n ).not.toBe(200)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)\n }\n })\n\n test('create \u2192 undo \u2192 redo restores the SAME record (I6)', async ({ request }) => {\n // checkout.template.create registers an id-preserving redo handler, so redo must restore\n // the original soft-deleted row \u2014 not mint a new id (the #2468 customers.people defect).\n const token = await getAuthToken(request, 'admin')\n let templateId: string | null = null\n try {\n const createRes = await apiRequest(request, 'POST', TEMPLATES, {\n token,\n data: createFixedTemplateInput({ status: 'draft' }),\n })\n const createOp = expectOperation(createRes, 'checkout.template.create')\n templateId = createOp.resourceId\n\n await settle()\n const undoLogId = await undoOk(request, token, createOp.undoToken, 'undo create template')\n expect((await getTemplate(request, token, templateId as string)).status, 'gone after undo create').not.toBe(200)\n\n await redoOk(request, token, undoLogId, 'redo create template')\n const afterRedo = await getTemplate(request, token, templateId as string)\n expect(afterRedo.status, 'same template restored after redo (I6)').toBe(200)\n expect(afterRedo.body?.id, 'redo preserves the original id (I6)').toBe(templateId)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)\n }\n })\n\n test('custom fields revert on undo and re-apply on redo (I4)', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let templateId: string | null = null\n try {\n const beforeValue = `before ${Date.now()}`\n templateId = await createTemplateFixture(\n request,\n token,\n createFixedTemplateInput({\n status: 'draft',\n customFieldsetCode: 'service_package',\n customFields: { delivery_timeline: beforeValue },\n }),\n )\n expect(\n (await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,\n 'custom field persisted on create',\n ).toBe(beforeValue)\n\n await settle()\n const afterValue = `after ${Date.now()}`\n const updateRes = await updateTemplate(request, token, templateId, {\n customFields: { delivery_timeline: afterValue },\n })\n const updateOp = expectOperation(updateRes, 'checkout.template.update')\n expect(\n (await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,\n 'update changed the custom field',\n ).toBe(afterValue)\n\n await settle()\n await undoOk(request, token, updateOp.undoToken, 'undo update custom field')\n expect(\n (await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,\n 'custom field reverts on undo (I4)',\n ).toBe(beforeValue)\n\n await redoOk(request, token, updateOp.logId, 'redo update custom field')\n expect(\n (await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,\n 'custom field re-applies on redo (I4)',\n ).toBe(afterValue)\n } finally {\n await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAoC;AACrD,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAoBP,MAAM,YAAY;AAalB,eAAe,SAAwB;AACrC,QAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AACxD;AAEA,eAAe,YACb,SACA,OACA,IAC0D;AAC1D,QAAM,MAAM,MAAM,WAAW,SAAS,OAAO,GAAG,SAAS,IAAI,mBAAmB,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC;AAChG,SAAO,EAAE,QAAQ,IAAI,OAAO,GAAG,MAAM,MAAM,aAA6B,GAAG,EAAE;AAC/E;AAEA,KAAK,SAAS,2CAA2C,MAAM;AAC7D,OAAK,UAAU,MAAM;AACnB,4BAAwB;AAAA,EAC1B,CAAC;AAED,OAAK,8DAAyD,OAAO,EAAE,QAAQ,MAAM;AACnF,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAChC,QAAI;AACF,YAAM,YAAY,MAAM,WAAW,SAAS,QAAQ,WAAW;AAAA,QAC7D;AAAA,QACA,MAAM,yBAAyB,EAAE,QAAQ,QAAQ,CAAC;AAAA,MACpD,CAAC;AACD,aAAO,UAAU,OAAO,GAAG,iBAAiB,UAAU,OAAO,CAAC,EAAE,EAAE,KAAK,GAAG;AAC1E,YAAM,WAAW,gBAAgB,WAAW,0BAA0B;AACtE,mBAAa,SAAS;AACtB,aAAO,YAAY,8BAA8B,EAAE,WAAW;AAE9D,cAAQ,MAAM,YAAY,SAAS,OAAO,UAAoB,GAAG,QAAQ,8BAA8B,EAAE,KAAK,GAAG;AAEjH,YAAM,OAAO,SAAS,OAAO,SAAS,WAAW,sBAAsB;AACvE;AAAA,SACG,MAAM,YAAY,SAAS,OAAO,UAAoB,GAAG;AAAA,QAC1D;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AAEd,YAAM,oBAAoB,SAAS,OAAO,SAAS,WAAW,2CAA2C;AAAA,IAC3G,UAAE;AACA,YAAM,6BAA6B,SAAS,OAAO,aAAa,UAAU;AAAA,IAC5E;AAAA,EACF,CAAC;AAED,OAAK,wEAA8D,OAAO,EAAE,QAAQ,MAAM;AACxF,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAChC,QAAI;AACF,mBAAa,MAAM,sBAAsB,SAAS,OAAO,yBAAyB,EAAE,QAAQ,QAAQ,CAAC,CAAC;AACtG,YAAM,SAAS,MAAM,YAAY,SAAS,OAAO,UAAU;AAC3D,YAAM,aAAa,OAAO,MAAM;AAChC,aAAO,YAAY,sCAAsC,EAAE,WAAW;AAEtE,YAAM,OAAO;AACb,YAAM,cAAc,wBAAwB,KAAK,IAAI,CAAC;AACtD,YAAM,YAAY,MAAM,eAAe,SAAS,OAAO,YAAY,EAAE,MAAM,YAAY,CAAC;AACxF,aAAO,UAAU,OAAO,GAAG,iBAAiB,UAAU,OAAO,CAAC,EAAE,EAAE,KAAK,GAAG;AAC1E,YAAM,WAAW,gBAAgB,WAAW,0BAA0B;AACtE,cAAQ,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,MAAM,MAAM,yBAAyB,EAAE,KAAK,WAAW;AAE9G,YAAM,OAAO;AACb,YAAM,OAAO,SAAS,OAAO,SAAS,WAAW,sBAAsB;AACvE,YAAM,YAAY,MAAM,YAAY,SAAS,OAAO,UAAU;AAC9D,aAAO,UAAU,MAAM,MAAM,+CAA0C,EAAE,KAAK,UAAU;AACxF,aAAO,OAAO,UAAU,MAAM,WAAW,6BAA6B,EAAE,KAAK,QAAQ;AACrF,aAAO,UAAU,MAAM,WAAW,2BAA2B,EAAE,IAAI,KAAK,OAAO,MAAM,SAAS;AAE9F,YAAM,OAAO,SAAS,OAAO,SAAS,OAAO,sBAAsB;AACnE;AAAA,SACG,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,MAAM;AAAA,QACtD;AAAA,MACF,EAAE,KAAK,WAAW;AAAA,IACpB,UAAE;AACA,YAAM,6BAA6B,SAAS,OAAO,aAAa,UAAU;AAAA,IAC5E;AAAA,EACF,CAAC;AAED,OAAK,uEAA6D,OAAO,EAAE,QAAQ,MAAM;AACvF,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAChC,QAAI;AACF,mBAAa,MAAM,sBAAsB,SAAS,OAAO,yBAAyB,EAAE,QAAQ,QAAQ,CAAC,CAAC;AACtG,YAAM,cAAc,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,MAAM;AAEzE,YAAM,OAAO;AACb,YAAM,YAAY,MAAM,eAAe,SAAS,OAAO,UAAU;AACjE,aAAO,UAAU,GAAG,GAAG,iBAAiB,UAAU,OAAO,CAAC,EAAE,EAAE,WAAW;AACzE,YAAM,WAAW,gBAAgB,WAAW,0BAA0B;AACtE,cAAQ,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,QAAQ,mBAAmB,EAAE,IAAI,KAAK,GAAG;AAEhG,YAAM,OAAO,SAAS,OAAO,SAAS,WAAW,sBAAsB;AACvE,YAAM,YAAY,MAAM,YAAY,SAAS,OAAO,UAAU;AAC9D,aAAO,UAAU,QAAQ,+CAA0C,EAAE,KAAK,GAAG;AAC7E,aAAO,UAAU,MAAM,MAAM,+CAA+C,EAAE,KAAK,UAAU;AAE7F,YAAM,OAAO,SAAS,OAAO,SAAS,OAAO,sBAAsB;AACnE;AAAA,SACG,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG;AAAA,QAChD;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AAAA,IAChB,UAAE;AACA,YAAM,6BAA6B,SAAS,OAAO,aAAa,UAAU;AAAA,IAC5E;AAAA,EACF,CAAC;AAED,OAAK,gEAAsD,OAAO,EAAE,QAAQ,MAAM;AAGhF,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAChC,QAAI;AACF,YAAM,YAAY,MAAM,WAAW,SAAS,QAAQ,WAAW;AAAA,QAC7D;AAAA,QACA,MAAM,yBAAyB,EAAE,QAAQ,QAAQ,CAAC;AAAA,MACpD,CAAC;AACD,YAAM,WAAW,gBAAgB,WAAW,0BAA0B;AACtE,mBAAa,SAAS;AAEtB,YAAM,OAAO;AACb,YAAM,YAAY,MAAM,OAAO,SAAS,OAAO,SAAS,WAAW,sBAAsB;AACzF,cAAQ,MAAM,YAAY,SAAS,OAAO,UAAoB,GAAG,QAAQ,wBAAwB,EAAE,IAAI,KAAK,GAAG;AAE/G,YAAM,OAAO,SAAS,OAAO,WAAW,sBAAsB;AAC9D,YAAM,YAAY,MAAM,YAAY,SAAS,OAAO,UAAoB;AACxE,aAAO,UAAU,QAAQ,wCAAwC,EAAE,KAAK,GAAG;AAC3E,aAAO,UAAU,MAAM,IAAI,qCAAqC,EAAE,KAAK,UAAU;AAAA,IACnF,UAAE;AACA,YAAM,6BAA6B,SAAS,OAAO,aAAa,UAAU;AAAA,IAC5E;AAAA,EACF,CAAC;AAED,OAAK,0DAA0D,OAAO,EAAE,QAAQ,MAAM;AACpF,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAChC,QAAI;AACF,YAAM,cAAc,UAAU,KAAK,IAAI,CAAC;AACxC,mBAAa,MAAM;AAAA,QACjB;AAAA,QACA;AAAA,QACA,yBAAyB;AAAA,UACvB,QAAQ;AAAA,UACR,oBAAoB;AAAA,UACpB,cAAc,EAAE,mBAAmB,YAAY;AAAA,QACjD,CAAC;AAAA,MACH;AACA;AAAA,SACG,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,MAAM,cAAc;AAAA,QACpE;AAAA,MACF,EAAE,KAAK,WAAW;AAElB,YAAM,OAAO;AACb,YAAM,aAAa,SAAS,KAAK,IAAI,CAAC;AACtC,YAAM,YAAY,MAAM,eAAe,SAAS,OAAO,YAAY;AAAA,QACjE,cAAc,EAAE,mBAAmB,WAAW;AAAA,MAChD,CAAC;AACD,YAAM,WAAW,gBAAgB,WAAW,0BAA0B;AACtE;AAAA,SACG,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,MAAM,cAAc;AAAA,QACpE;AAAA,MACF,EAAE,KAAK,UAAU;AAEjB,YAAM,OAAO;AACb,YAAM,OAAO,SAAS,OAAO,SAAS,WAAW,0BAA0B;AAC3E;AAAA,SACG,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,MAAM,cAAc;AAAA,QACpE;AAAA,MACF,EAAE,KAAK,WAAW;AAElB,YAAM,OAAO,SAAS,OAAO,SAAS,OAAO,0BAA0B;AACvE;AAAA,SACG,MAAM,YAAY,SAAS,OAAO,UAAU,GAAG,MAAM,cAAc;AAAA,QACpE;AAAA,MACF,EAAE,KAAK,UAAU;AAAA,IACnB,UAAE;AACA,YAAM,6BAA6B,SAAS,OAAO,aAAa,UAAU;AAAA,IAC5E;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/checkout",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5200.1.871eca3402",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -61,18 +61,18 @@
|
|
|
61
61
|
}
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@open-mercato/core": "0.6.5-develop.
|
|
65
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
64
|
+
"@open-mercato/core": "0.6.5-develop.5200.1.871eca3402",
|
|
65
|
+
"@open-mercato/ui": "0.6.5-develop.5200.1.871eca3402",
|
|
66
66
|
"bcryptjs": "^3.0.3"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
70
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
70
|
+
"@open-mercato/shared": "0.6.5-develop.5200.1.871eca3402",
|
|
71
71
|
"react": "^19.0.0",
|
|
72
72
|
"react-dom": "^19.0.0"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
75
|
+
"@open-mercato/shared": "0.6.5-develop.5200.1.871eca3402",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
77
77
|
"@types/react": "^19.2.17",
|
|
78
78
|
"@types/react-dom": "^19.2.3",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { extractOperation, skipIfUndoTestsDisabled } from '@open-mercato/core/helpers/integration/undoHarness'
|
|
4
|
+
import {
|
|
5
|
+
createCustomerData,
|
|
6
|
+
createFixedTemplateInput,
|
|
7
|
+
createLinkFixture,
|
|
8
|
+
createTemplateFixture,
|
|
9
|
+
deleteCheckoutEntityIfExists,
|
|
10
|
+
submitPayLink,
|
|
11
|
+
} from './helpers/fixtures'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TC-UNDO-001 (§4 checkout) — non-undoable commands expose no undo affordance.
|
|
15
|
+
*
|
|
16
|
+
* Public checkout submissions create a CheckoutTransaction via the `checkout.transaction.*`
|
|
17
|
+
* commands, which are intentionally NOT undoable (financial events). The mutating response
|
|
18
|
+
* must therefore carry no `x-om-operation` undo envelope: `extractOperation(res) === null`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
test.describe('TC-UNDO-001 checkout §4 — non-undoable commands expose no undo token', () => {
|
|
22
|
+
test.beforeAll(() => {
|
|
23
|
+
skipIfUndoTestsDisabled()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('public checkout submit creates a transaction with no undo token', async ({ request }) => {
|
|
27
|
+
const token = await getAuthToken(request, 'admin')
|
|
28
|
+
let templateId: string | null = null
|
|
29
|
+
let linkId: string | null = null
|
|
30
|
+
try {
|
|
31
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
32
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'active', templateId }))
|
|
33
|
+
linkId = link.id
|
|
34
|
+
|
|
35
|
+
const submitRes = await submitPayLink(request, link.slug, {
|
|
36
|
+
customerData: createCustomerData(),
|
|
37
|
+
acceptedLegalConsents: {},
|
|
38
|
+
amount: 49.99,
|
|
39
|
+
})
|
|
40
|
+
expect(submitRes.status(), `submit status ${submitRes.status()}`).toBe(201)
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
extractOperation(submitRes),
|
|
44
|
+
'a financial checkout submit must expose no undo token (§4)',
|
|
45
|
+
).toBeNull()
|
|
46
|
+
} finally {
|
|
47
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
48
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { expect, test, type APIRequestContext } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
import {
|
|
5
|
+
expectOperation,
|
|
6
|
+
undoOk,
|
|
7
|
+
redoOk,
|
|
8
|
+
expectTokenConsumed,
|
|
9
|
+
skipIfUndoTestsDisabled,
|
|
10
|
+
} from '@open-mercato/core/helpers/integration/undoHarness'
|
|
11
|
+
import {
|
|
12
|
+
createFixedTemplateInput,
|
|
13
|
+
createLinkFixture,
|
|
14
|
+
deleteCheckoutEntityIfExists,
|
|
15
|
+
deleteLink,
|
|
16
|
+
updateLink,
|
|
17
|
+
} from './helpers/fixtures'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* TC-UNDO-001 (§3 checkout.pay-link) — Undo/Redo correctness for the checkout pay-link
|
|
21
|
+
* command bus, driven through the real `/api/checkout/links` routes plus the
|
|
22
|
+
* `/api/audit_logs/audit-logs/actions/undo|redo` endpoints.
|
|
23
|
+
*
|
|
24
|
+
* Invariants asserted: I1 (update→undo restores), I2 (delete→undo re-materializes),
|
|
25
|
+
* I3 (create→undo soft-deletes), I4 (custom fields restore), I5 (token consumed),
|
|
26
|
+
* I6 (redo reproduces post-state). checkout.link.create ships an id-preserving redo
|
|
27
|
+
* handler, so the create→undo→redo SAME-id leg is asserted as corrected behaviour.
|
|
28
|
+
*
|
|
29
|
+
* Also covers the #2540 fix: a draft pay-link may persist a null gatewayProviderKey,
|
|
30
|
+
* and editing it to null then undoing must restore the prior gateway.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const LINKS = '/api/checkout/links'
|
|
34
|
+
|
|
35
|
+
type LinkRecord = {
|
|
36
|
+
id?: string
|
|
37
|
+
name?: string
|
|
38
|
+
slug?: string
|
|
39
|
+
updatedAt?: string
|
|
40
|
+
gatewayProviderKey?: string | null
|
|
41
|
+
customFields?: Record<string, unknown>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The undo endpoint resolves the *latest* undoable log for a resource ordered by
|
|
45
|
+
// millisecond-precision created_at, so consecutive mutations issued within the same
|
|
46
|
+
// millisecond can tie. A short settle keeps each round-trip deterministic.
|
|
47
|
+
async function settle(): Promise<void> {
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getLink(
|
|
52
|
+
request: APIRequestContext,
|
|
53
|
+
token: string,
|
|
54
|
+
id: string,
|
|
55
|
+
): Promise<{ status: number; body: LinkRecord | null }> {
|
|
56
|
+
const res = await apiRequest(request, 'GET', `${LINKS}/${encodeURIComponent(id)}`, { token })
|
|
57
|
+
return { status: res.status(), body: await readJsonSafe<LinkRecord>(res) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test.describe('TC-UNDO-001 checkout.pay-link undo/redo', () => {
|
|
61
|
+
test.beforeAll(() => {
|
|
62
|
+
skipIfUndoTestsDisabled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('create → undo soft-deletes (I3) + token consumed (I5)', async ({ request }) => {
|
|
66
|
+
const token = await getAuthToken(request, 'admin')
|
|
67
|
+
let linkId: string | null = null
|
|
68
|
+
try {
|
|
69
|
+
const createRes = await apiRequest(request, 'POST', LINKS, {
|
|
70
|
+
token,
|
|
71
|
+
data: createFixedTemplateInput({ status: 'draft' }),
|
|
72
|
+
})
|
|
73
|
+
expect(createRes.status(), `create status ${createRes.status()}`).toBe(201)
|
|
74
|
+
const createOp = expectOperation(createRes, 'checkout.link.create')
|
|
75
|
+
linkId = createOp.resourceId
|
|
76
|
+
expect(linkId, 'create returns a resource id').toBeTruthy()
|
|
77
|
+
|
|
78
|
+
expect((await getLink(request, token, linkId as string)).status, 'pay-link exists after create').toBe(200)
|
|
79
|
+
|
|
80
|
+
await undoOk(request, token, createOp.undoToken, 'undo create pay-link')
|
|
81
|
+
expect(
|
|
82
|
+
(await getLink(request, token, linkId as string)).status,
|
|
83
|
+
'pay-link is gone after undoing create (I3 — soft-deleted, not readable)',
|
|
84
|
+
).not.toBe(200)
|
|
85
|
+
|
|
86
|
+
await expectTokenConsumed(request, token, createOp.undoToken, 'checkout.link.create double-undo (I5)')
|
|
87
|
+
} finally {
|
|
88
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('update → undo restores scalars (I1) → redo re-applies (I6)', async ({ request }) => {
|
|
93
|
+
const token = await getAuthToken(request, 'admin')
|
|
94
|
+
let linkId: string | null = null
|
|
95
|
+
try {
|
|
96
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
97
|
+
linkId = link.id
|
|
98
|
+
const before = await getLink(request, token, linkId)
|
|
99
|
+
const beforeName = before.body?.name
|
|
100
|
+
expect(beforeName, 'pay-link name readable before update').toBeTruthy()
|
|
101
|
+
|
|
102
|
+
await settle()
|
|
103
|
+
const changedName = `Renamed by undo test ${Date.now()}`
|
|
104
|
+
const updateRes = await updateLink(request, token, linkId, { name: changedName })
|
|
105
|
+
expect(updateRes.status(), `update status ${updateRes.status()}`).toBe(200)
|
|
106
|
+
const updateOp = expectOperation(updateRes, 'checkout.link.update')
|
|
107
|
+
expect((await getLink(request, token, linkId)).body?.name, 'update changed the name').toBe(changedName)
|
|
108
|
+
|
|
109
|
+
await settle()
|
|
110
|
+
await undoOk(request, token, updateOp.undoToken, 'undo update pay-link')
|
|
111
|
+
const afterUndo = await getLink(request, token, linkId)
|
|
112
|
+
expect(afterUndo.body?.name, 'update→undo restores the prior name (I1)').toBe(beforeName)
|
|
113
|
+
expect(typeof afterUndo.body?.updatedAt, 'pay-link surfaces updatedAt').toBe('string')
|
|
114
|
+
expect(afterUndo.body?.updatedAt, 'undo bumps updatedAt (I1)').not.toBe(before.body?.updatedAt)
|
|
115
|
+
|
|
116
|
+
await redoOk(request, token, updateOp.logId, 'redo update pay-link')
|
|
117
|
+
expect((await getLink(request, token, linkId)).body?.name, 'redo re-applies the renamed value (I6)').toBe(changedName)
|
|
118
|
+
} finally {
|
|
119
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('delete → undo re-materializes (I2) → redo re-deletes (I6)', async ({ request }) => {
|
|
124
|
+
const token = await getAuthToken(request, 'admin')
|
|
125
|
+
let linkId: string | null = null
|
|
126
|
+
try {
|
|
127
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
128
|
+
linkId = link.id
|
|
129
|
+
const beforeName = (await getLink(request, token, linkId)).body?.name
|
|
130
|
+
|
|
131
|
+
await settle()
|
|
132
|
+
const deleteRes = await deleteLink(request, token, linkId)
|
|
133
|
+
expect(deleteRes.ok(), `delete status ${deleteRes.status()}`).toBeTruthy()
|
|
134
|
+
const deleteOp = expectOperation(deleteRes, 'checkout.link.delete')
|
|
135
|
+
expect((await getLink(request, token, linkId)).status, 'gone after delete').not.toBe(200)
|
|
136
|
+
|
|
137
|
+
await undoOk(request, token, deleteOp.undoToken, 'undo delete pay-link')
|
|
138
|
+
const afterUndo = await getLink(request, token, linkId)
|
|
139
|
+
expect(afterUndo.status, 'delete→undo re-materializes the row (I2)').toBe(200)
|
|
140
|
+
expect(afterUndo.body?.name, 're-materialized record keeps its scalars (I2)').toBe(beforeName)
|
|
141
|
+
|
|
142
|
+
await redoOk(request, token, deleteOp.logId, 'redo delete pay-link')
|
|
143
|
+
expect((await getLink(request, token, linkId)).status, 'gone again after redo delete (I6)').not.toBe(200)
|
|
144
|
+
} finally {
|
|
145
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('create → undo → redo restores the SAME record (I6)', async ({ request }) => {
|
|
150
|
+
// checkout.link.create registers an id-preserving redo handler, so redo must restore
|
|
151
|
+
// the original soft-deleted row — not mint a new id (the #2468 customers.people defect).
|
|
152
|
+
const token = await getAuthToken(request, 'admin')
|
|
153
|
+
let linkId: string | null = null
|
|
154
|
+
try {
|
|
155
|
+
const createRes = await apiRequest(request, 'POST', LINKS, {
|
|
156
|
+
token,
|
|
157
|
+
data: createFixedTemplateInput({ status: 'draft' }),
|
|
158
|
+
})
|
|
159
|
+
const createOp = expectOperation(createRes, 'checkout.link.create')
|
|
160
|
+
linkId = createOp.resourceId
|
|
161
|
+
|
|
162
|
+
await settle()
|
|
163
|
+
const undoLogId = await undoOk(request, token, createOp.undoToken, 'undo create pay-link')
|
|
164
|
+
expect((await getLink(request, token, linkId as string)).status, 'gone after undo create').not.toBe(200)
|
|
165
|
+
|
|
166
|
+
await redoOk(request, token, undoLogId, 'redo create pay-link')
|
|
167
|
+
const afterRedo = await getLink(request, token, linkId as string)
|
|
168
|
+
expect(afterRedo.status, 'same pay-link restored after redo (I6)').toBe(200)
|
|
169
|
+
expect(afterRedo.body?.id, 'redo preserves the original id (I6)').toBe(linkId)
|
|
170
|
+
} finally {
|
|
171
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('custom fields revert on undo and re-apply on redo (I4)', async ({ request }) => {
|
|
176
|
+
const token = await getAuthToken(request, 'admin')
|
|
177
|
+
let linkId: string | null = null
|
|
178
|
+
try {
|
|
179
|
+
const beforeValue = `before ${Date.now()}`
|
|
180
|
+
const link = await createLinkFixture(
|
|
181
|
+
request,
|
|
182
|
+
token,
|
|
183
|
+
createFixedTemplateInput({
|
|
184
|
+
status: 'draft',
|
|
185
|
+
customFieldsetCode: 'service_package',
|
|
186
|
+
customFields: { delivery_timeline: beforeValue },
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
linkId = link.id
|
|
190
|
+
expect(
|
|
191
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
192
|
+
'custom field persisted on create',
|
|
193
|
+
).toBe(beforeValue)
|
|
194
|
+
|
|
195
|
+
await settle()
|
|
196
|
+
const afterValue = `after ${Date.now()}`
|
|
197
|
+
const updateRes = await updateLink(request, token, linkId, { customFields: { delivery_timeline: afterValue } })
|
|
198
|
+
const updateOp = expectOperation(updateRes, 'checkout.link.update')
|
|
199
|
+
expect(
|
|
200
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
201
|
+
'update changed the custom field',
|
|
202
|
+
).toBe(afterValue)
|
|
203
|
+
|
|
204
|
+
await settle()
|
|
205
|
+
await undoOk(request, token, updateOp.undoToken, 'undo update custom field')
|
|
206
|
+
expect(
|
|
207
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
208
|
+
'custom field reverts on undo (I4)',
|
|
209
|
+
).toBe(beforeValue)
|
|
210
|
+
|
|
211
|
+
await redoOk(request, token, updateOp.logId, 'redo update custom field')
|
|
212
|
+
expect(
|
|
213
|
+
(await getLink(request, token, linkId)).body?.customFields?.delivery_timeline,
|
|
214
|
+
'custom field re-applies on redo (I4)',
|
|
215
|
+
).toBe(afterValue)
|
|
216
|
+
} finally {
|
|
217
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('null gatewayProviderKey edit → undo restores the prior gateway (#2540, I1)', async ({ request }) => {
|
|
222
|
+
// #2540: a draft pay-link may persist a null gatewayProviderKey. Editing the gateway to
|
|
223
|
+
// null must round-trip, and undoing the edit must restore the prior gateway value.
|
|
224
|
+
const token = await getAuthToken(request, 'admin')
|
|
225
|
+
let linkId: string | null = null
|
|
226
|
+
try {
|
|
227
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
228
|
+
linkId = link.id
|
|
229
|
+
const before = await getLink(request, token, linkId)
|
|
230
|
+
expect(before.body?.gatewayProviderKey, 'pay-link starts with the mock gateway').toBe('mock')
|
|
231
|
+
|
|
232
|
+
await settle()
|
|
233
|
+
const updateRes = await updateLink(request, token, linkId, {
|
|
234
|
+
name: 'Pay link (no gateway)',
|
|
235
|
+
pricingMode: 'fixed',
|
|
236
|
+
fixedPriceAmount: 49.99,
|
|
237
|
+
fixedPriceCurrencyCode: 'USD',
|
|
238
|
+
status: 'draft',
|
|
239
|
+
gatewayProviderKey: null,
|
|
240
|
+
})
|
|
241
|
+
expect(
|
|
242
|
+
updateRes.ok(),
|
|
243
|
+
`clearing the gateway on a draft pay-link should succeed: ${updateRes.status()}`,
|
|
244
|
+
).toBeTruthy()
|
|
245
|
+
const updateOp = expectOperation(updateRes, 'checkout.link.update (null gateway)')
|
|
246
|
+
|
|
247
|
+
const afterUpdate = await getLink(request, token, linkId)
|
|
248
|
+
expect(afterUpdate.body?.gatewayProviderKey ?? null, 'gateway cleared to null after edit').toBeNull()
|
|
249
|
+
expect(afterUpdate.body?.name, 'name changed by edit').toBe('Pay link (no gateway)')
|
|
250
|
+
|
|
251
|
+
await settle()
|
|
252
|
+
await undoOk(request, token, updateOp.undoToken, 'undo null-gateway edit')
|
|
253
|
+
const afterUndo = await getLink(request, token, linkId)
|
|
254
|
+
expect(afterUndo.body?.gatewayProviderKey, 'undo restores the prior gateway (#2540, I1)').toBe('mock')
|
|
255
|
+
expect(afterUndo.body?.name, 'undo restores the prior name (I1)').toBe(before.body?.name)
|
|
256
|
+
|
|
257
|
+
await redoOk(request, token, updateOp.logId, 'redo null-gateway edit')
|
|
258
|
+
expect(
|
|
259
|
+
(await getLink(request, token, linkId)).body?.gatewayProviderKey ?? null,
|
|
260
|
+
'redo re-clears the gateway to null (I6)',
|
|
261
|
+
).toBeNull()
|
|
262
|
+
} finally {
|
|
263
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
})
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { expect, test, type APIRequestContext } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
import {
|
|
5
|
+
expectOperation,
|
|
6
|
+
undoOk,
|
|
7
|
+
redoOk,
|
|
8
|
+
expectTokenConsumed,
|
|
9
|
+
skipIfUndoTestsDisabled,
|
|
10
|
+
} from '@open-mercato/core/helpers/integration/undoHarness'
|
|
11
|
+
import {
|
|
12
|
+
createFixedTemplateInput,
|
|
13
|
+
createTemplateFixture,
|
|
14
|
+
deleteCheckoutEntityIfExists,
|
|
15
|
+
deleteTemplate,
|
|
16
|
+
updateTemplate,
|
|
17
|
+
} from './helpers/fixtures'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* TC-UNDO-001 (§3 checkout.template) — Undo/Redo correctness for the checkout template
|
|
21
|
+
* command bus, driven through the real `/api/checkout/templates` routes plus the
|
|
22
|
+
* `/api/audit_logs/audit-logs/actions/undo|redo` endpoints.
|
|
23
|
+
*
|
|
24
|
+
* Invariants asserted (per the #2468 tracking issue):
|
|
25
|
+
* I1 update→undo restores scalars (and bumps updatedAt)
|
|
26
|
+
* I2 delete→undo re-materializes the soft-deleted row
|
|
27
|
+
* I3 create→undo soft-deletes (never hard-deletes)
|
|
28
|
+
* I4 custom fields revert on undo and re-apply on redo
|
|
29
|
+
* I5 a consumed undo token is rejected on a second undo
|
|
30
|
+
* I6 redo reproduces the command's post-state
|
|
31
|
+
*
|
|
32
|
+
* Unlike the customers.people pilot (where create→undo→redo mints a new id, #2468),
|
|
33
|
+
* checkout.template.create ships an id-preserving redo handler, so the SAME-id redo
|
|
34
|
+
* leg is asserted as corrected behaviour rather than quarantined.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const TEMPLATES = '/api/checkout/templates'
|
|
38
|
+
|
|
39
|
+
type TemplateRecord = {
|
|
40
|
+
id?: string
|
|
41
|
+
name?: string
|
|
42
|
+
updatedAt?: string
|
|
43
|
+
gatewayProviderKey?: string | null
|
|
44
|
+
customFields?: Record<string, unknown>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The undo endpoint resolves the *latest* undoable log for a resource ordered by
|
|
48
|
+
// millisecond-precision created_at, so consecutive mutations issued within the same
|
|
49
|
+
// millisecond can tie. A short settle keeps each round-trip deterministic.
|
|
50
|
+
async function settle(): Promise<void> {
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getTemplate(
|
|
55
|
+
request: APIRequestContext,
|
|
56
|
+
token: string,
|
|
57
|
+
id: string,
|
|
58
|
+
): Promise<{ status: number; body: TemplateRecord | null }> {
|
|
59
|
+
const res = await apiRequest(request, 'GET', `${TEMPLATES}/${encodeURIComponent(id)}`, { token })
|
|
60
|
+
return { status: res.status(), body: await readJsonSafe<TemplateRecord>(res) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test.describe('TC-UNDO-001 checkout.template undo/redo', () => {
|
|
64
|
+
test.beforeAll(() => {
|
|
65
|
+
skipIfUndoTestsDisabled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('create → undo soft-deletes (I3) + token consumed (I5)', async ({ request }) => {
|
|
69
|
+
const token = await getAuthToken(request, 'admin')
|
|
70
|
+
let templateId: string | null = null
|
|
71
|
+
try {
|
|
72
|
+
const createRes = await apiRequest(request, 'POST', TEMPLATES, {
|
|
73
|
+
token,
|
|
74
|
+
data: createFixedTemplateInput({ status: 'draft' }),
|
|
75
|
+
})
|
|
76
|
+
expect(createRes.status(), `create status ${createRes.status()}`).toBe(201)
|
|
77
|
+
const createOp = expectOperation(createRes, 'checkout.template.create')
|
|
78
|
+
templateId = createOp.resourceId
|
|
79
|
+
expect(templateId, 'create returns a resource id').toBeTruthy()
|
|
80
|
+
|
|
81
|
+
expect((await getTemplate(request, token, templateId as string)).status, 'template exists after create').toBe(200)
|
|
82
|
+
|
|
83
|
+
await undoOk(request, token, createOp.undoToken, 'undo create template')
|
|
84
|
+
expect(
|
|
85
|
+
(await getTemplate(request, token, templateId as string)).status,
|
|
86
|
+
'template is gone after undoing create (I3 — soft-deleted, not readable)',
|
|
87
|
+
).not.toBe(200)
|
|
88
|
+
|
|
89
|
+
await expectTokenConsumed(request, token, createOp.undoToken, 'checkout.template.create double-undo (I5)')
|
|
90
|
+
} finally {
|
|
91
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('update → undo restores scalars (I1) → redo re-applies (I6)', async ({ request }) => {
|
|
96
|
+
const token = await getAuthToken(request, 'admin')
|
|
97
|
+
let templateId: string | null = null
|
|
98
|
+
try {
|
|
99
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
100
|
+
const before = await getTemplate(request, token, templateId)
|
|
101
|
+
const beforeName = before.body?.name
|
|
102
|
+
expect(beforeName, 'template name readable before update').toBeTruthy()
|
|
103
|
+
|
|
104
|
+
await settle()
|
|
105
|
+
const changedName = `Renamed by undo test ${Date.now()}`
|
|
106
|
+
const updateRes = await updateTemplate(request, token, templateId, { name: changedName })
|
|
107
|
+
expect(updateRes.status(), `update status ${updateRes.status()}`).toBe(200)
|
|
108
|
+
const updateOp = expectOperation(updateRes, 'checkout.template.update')
|
|
109
|
+
expect((await getTemplate(request, token, templateId)).body?.name, 'update changed the name').toBe(changedName)
|
|
110
|
+
|
|
111
|
+
await settle()
|
|
112
|
+
await undoOk(request, token, updateOp.undoToken, 'undo update template')
|
|
113
|
+
const afterUndo = await getTemplate(request, token, templateId)
|
|
114
|
+
expect(afterUndo.body?.name, 'update→undo restores the prior name (I1)').toBe(beforeName)
|
|
115
|
+
expect(typeof afterUndo.body?.updatedAt, 'template surfaces updatedAt').toBe('string')
|
|
116
|
+
expect(afterUndo.body?.updatedAt, 'undo bumps updatedAt (I1)').not.toBe(before.body?.updatedAt)
|
|
117
|
+
|
|
118
|
+
await redoOk(request, token, updateOp.logId, 'redo update template')
|
|
119
|
+
expect(
|
|
120
|
+
(await getTemplate(request, token, templateId)).body?.name,
|
|
121
|
+
'redo re-applies the renamed value (I6)',
|
|
122
|
+
).toBe(changedName)
|
|
123
|
+
} finally {
|
|
124
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('delete → undo re-materializes (I2) → redo re-deletes (I6)', async ({ request }) => {
|
|
129
|
+
const token = await getAuthToken(request, 'admin')
|
|
130
|
+
let templateId: string | null = null
|
|
131
|
+
try {
|
|
132
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
133
|
+
const beforeName = (await getTemplate(request, token, templateId)).body?.name
|
|
134
|
+
|
|
135
|
+
await settle()
|
|
136
|
+
const deleteRes = await deleteTemplate(request, token, templateId)
|
|
137
|
+
expect(deleteRes.ok(), `delete status ${deleteRes.status()}`).toBeTruthy()
|
|
138
|
+
const deleteOp = expectOperation(deleteRes, 'checkout.template.delete')
|
|
139
|
+
expect((await getTemplate(request, token, templateId)).status, 'gone after delete').not.toBe(200)
|
|
140
|
+
|
|
141
|
+
await undoOk(request, token, deleteOp.undoToken, 'undo delete template')
|
|
142
|
+
const afterUndo = await getTemplate(request, token, templateId)
|
|
143
|
+
expect(afterUndo.status, 'delete→undo re-materializes the row (I2)').toBe(200)
|
|
144
|
+
expect(afterUndo.body?.name, 're-materialized record keeps its scalars (I2)').toBe(beforeName)
|
|
145
|
+
|
|
146
|
+
await redoOk(request, token, deleteOp.logId, 'redo delete template')
|
|
147
|
+
expect(
|
|
148
|
+
(await getTemplate(request, token, templateId)).status,
|
|
149
|
+
'gone again after redo delete (I6)',
|
|
150
|
+
).not.toBe(200)
|
|
151
|
+
} finally {
|
|
152
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('create → undo → redo restores the SAME record (I6)', async ({ request }) => {
|
|
157
|
+
// checkout.template.create registers an id-preserving redo handler, so redo must restore
|
|
158
|
+
// the original soft-deleted row — not mint a new id (the #2468 customers.people defect).
|
|
159
|
+
const token = await getAuthToken(request, 'admin')
|
|
160
|
+
let templateId: string | null = null
|
|
161
|
+
try {
|
|
162
|
+
const createRes = await apiRequest(request, 'POST', TEMPLATES, {
|
|
163
|
+
token,
|
|
164
|
+
data: createFixedTemplateInput({ status: 'draft' }),
|
|
165
|
+
})
|
|
166
|
+
const createOp = expectOperation(createRes, 'checkout.template.create')
|
|
167
|
+
templateId = createOp.resourceId
|
|
168
|
+
|
|
169
|
+
await settle()
|
|
170
|
+
const undoLogId = await undoOk(request, token, createOp.undoToken, 'undo create template')
|
|
171
|
+
expect((await getTemplate(request, token, templateId as string)).status, 'gone after undo create').not.toBe(200)
|
|
172
|
+
|
|
173
|
+
await redoOk(request, token, undoLogId, 'redo create template')
|
|
174
|
+
const afterRedo = await getTemplate(request, token, templateId as string)
|
|
175
|
+
expect(afterRedo.status, 'same template restored after redo (I6)').toBe(200)
|
|
176
|
+
expect(afterRedo.body?.id, 'redo preserves the original id (I6)').toBe(templateId)
|
|
177
|
+
} finally {
|
|
178
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('custom fields revert on undo and re-apply on redo (I4)', async ({ request }) => {
|
|
183
|
+
const token = await getAuthToken(request, 'admin')
|
|
184
|
+
let templateId: string | null = null
|
|
185
|
+
try {
|
|
186
|
+
const beforeValue = `before ${Date.now()}`
|
|
187
|
+
templateId = await createTemplateFixture(
|
|
188
|
+
request,
|
|
189
|
+
token,
|
|
190
|
+
createFixedTemplateInput({
|
|
191
|
+
status: 'draft',
|
|
192
|
+
customFieldsetCode: 'service_package',
|
|
193
|
+
customFields: { delivery_timeline: beforeValue },
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
expect(
|
|
197
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
198
|
+
'custom field persisted on create',
|
|
199
|
+
).toBe(beforeValue)
|
|
200
|
+
|
|
201
|
+
await settle()
|
|
202
|
+
const afterValue = `after ${Date.now()}`
|
|
203
|
+
const updateRes = await updateTemplate(request, token, templateId, {
|
|
204
|
+
customFields: { delivery_timeline: afterValue },
|
|
205
|
+
})
|
|
206
|
+
const updateOp = expectOperation(updateRes, 'checkout.template.update')
|
|
207
|
+
expect(
|
|
208
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
209
|
+
'update changed the custom field',
|
|
210
|
+
).toBe(afterValue)
|
|
211
|
+
|
|
212
|
+
await settle()
|
|
213
|
+
await undoOk(request, token, updateOp.undoToken, 'undo update custom field')
|
|
214
|
+
expect(
|
|
215
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
216
|
+
'custom field reverts on undo (I4)',
|
|
217
|
+
).toBe(beforeValue)
|
|
218
|
+
|
|
219
|
+
await redoOk(request, token, updateOp.logId, 'redo update custom field')
|
|
220
|
+
expect(
|
|
221
|
+
(await getTemplate(request, token, templateId)).body?.customFields?.delivery_timeline,
|
|
222
|
+
'custom field re-applies on redo (I4)',
|
|
223
|
+
).toBe(afterValue)
|
|
224
|
+
} finally {
|
|
225
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
})
|