@open-mercato/checkout 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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-CHK-CRUDFORM-001.spec.js +125 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js +139 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js +2 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js.map +2 -2
- package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js +115 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js +66 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js +52 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js +44 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js.map +7 -0
- package/dist/modules/checkout/backend/checkout/pay-links/page.js +9 -0
- package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
- package/dist/modules/checkout/backend/checkout/templates/page.js +9 -0
- package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
- package/dist/modules/checkout/commands/links.js +67 -0
- package/dist/modules/checkout/commands/links.js.map +2 -2
- package/dist/modules/checkout/commands/templates.js +69 -2
- package/dist/modules/checkout/commands/templates.js.map +2 -2
- package/dist/modules/checkout/components/GatewaySettingsFields.js +32 -15
- package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
- package/dist/modules/checkout/components/LinkTemplateForm.js +27 -7
- package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
- package/dist/modules/checkout/data/validators.js +18 -2
- package/dist/modules/checkout/data/validators.js.map +2 -2
- package/dist/modules/checkout/lib/utils.js +26 -5
- package/dist/modules/checkout/lib/utils.js.map +2 -2
- package/jest.config.cjs +2 -0
- package/package.json +7 -8
- package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.ts +158 -0
- package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.ts +171 -0
- package/src/modules/checkout/__integration__/TC-CHKT-008.spec.ts +4 -0
- package/src/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.ts +131 -0
- package/src/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.ts +98 -0
- package/src/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.ts +60 -0
- package/src/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.ts +58 -0
- package/src/modules/checkout/backend/checkout/pay-links/page.tsx +8 -0
- package/src/modules/checkout/backend/checkout/templates/page.tsx +8 -0
- package/src/modules/checkout/commands/__tests__/optimistic-lock.test.ts +261 -0
- package/src/modules/checkout/commands/__tests__/redo-coverage.test.ts +21 -0
- package/src/modules/checkout/commands/links.ts +67 -0
- package/src/modules/checkout/commands/templates.ts +74 -2
- package/src/modules/checkout/components/GatewaySettingsFields.tsx +40 -18
- package/src/modules/checkout/components/LinkTemplateForm.tsx +27 -7
- package/src/modules/checkout/data/__tests__/validators.test.ts +66 -1
- package/src/modules/checkout/data/validators.ts +18 -2
- package/src/modules/checkout/lib/__tests__/utils.test.ts +112 -0
- package/src/modules/checkout/lib/utils.ts +41 -5
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import {
|
|
4
|
+
assertScalarFieldsPersisted,
|
|
5
|
+
skipIfCrudFormExtensionTestsDisabled,
|
|
6
|
+
} from '@open-mercato/core/helpers/integration/crudFormPersistence'
|
|
7
|
+
import {
|
|
8
|
+
createFixedTemplateInput,
|
|
9
|
+
createLinkFixture,
|
|
10
|
+
createTemplateFixture,
|
|
11
|
+
deleteCheckoutEntityIfExists,
|
|
12
|
+
readLink,
|
|
13
|
+
updateLink,
|
|
14
|
+
type CheckoutLinkInput,
|
|
15
|
+
} from './helpers/fixtures'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* TC-CHK-CRUDFORM-002: pay-link CrudForm persists scalars, the templateId FK, slug
|
|
19
|
+
* + custom fields (#2466 / #2566).
|
|
20
|
+
*
|
|
21
|
+
* The checkout pay-link surface is hand-written (command bus + bespoke serializer) and so does
|
|
22
|
+
* NOT fit `runCrudFormRoundTrip` — see the note in TC-CHK-CRUDFORM-001. This spec drives the
|
|
23
|
+
* canonical create → read-back → assert → update → read-back → assert → delete cycle inline,
|
|
24
|
+
* reusing the sweep harness gate + scalar assertion helper.
|
|
25
|
+
*
|
|
26
|
+
* It covers the link-specific fields on top of the shared content fields: the `templateId`
|
|
27
|
+
* foreign key and the generated `slug`, plus fixed-price money scalars, enums, booleans, an
|
|
28
|
+
* integer, and default-seeded custom fields. Every explicitly-submitted field overrides the
|
|
29
|
+
* source template (`pickExplicitParsedOverrides`), so the round-trip asserts the link's own
|
|
30
|
+
* values, not inherited ones.
|
|
31
|
+
*
|
|
32
|
+
* Verified contract:
|
|
33
|
+
* - Read-back uses the detail GET (the list route does not filter by `?ids=`/`?id=`).
|
|
34
|
+
* - Custom fields submit as a `customFields` object and return under `record.customFields`.
|
|
35
|
+
* - PUT is a partial update — omitted custom fields are retained; the slug is preserved when the
|
|
36
|
+
* name/title change (it recomputes from the existing slug).
|
|
37
|
+
* - Self-contained: creates a throwaway template (no custom fields, so the link's own custom
|
|
38
|
+
* fields are not affected by template copy) and deletes both fixtures in `finally`.
|
|
39
|
+
*
|
|
40
|
+
* Gated by `OM_INTEGRATION_CRUDFORM_EXTENSION_TESTS_DISABLED` (default off → runs).
|
|
41
|
+
*/
|
|
42
|
+
test.describe('TC-CHK-CRUDFORM-002: pay-link CrudForm persists scalars, templateId + slug + custom fields', () => {
|
|
43
|
+
test.beforeAll(() => {
|
|
44
|
+
skipIfCrudFormExtensionTestsDisabled()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('round-trips scalars, templateId, slug, and custom fields on create and update', async ({ request }) => {
|
|
48
|
+
const token = await getAuthToken(request)
|
|
49
|
+
const stamp = Date.now()
|
|
50
|
+
let templateId: string | null = null
|
|
51
|
+
let linkId: string | null = null
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
templateId = await createTemplateFixture(
|
|
55
|
+
request,
|
|
56
|
+
token,
|
|
57
|
+
createFixedTemplateInput({ name: `QA CRUDFORM Link Template ${stamp}` }),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const createInput: CheckoutLinkInput = createFixedTemplateInput({
|
|
61
|
+
name: `QA CRUDFORM Link ${stamp}`,
|
|
62
|
+
title: `QA CRUDFORM Link Title ${stamp}`,
|
|
63
|
+
subtitle: 'Original link subtitle',
|
|
64
|
+
description: 'Original link description',
|
|
65
|
+
fixedPriceAmount: 49.99,
|
|
66
|
+
fixedPriceCurrencyCode: 'USD',
|
|
67
|
+
fixedPriceIncludesTax: true,
|
|
68
|
+
fixedPriceOriginalAmount: 69.99,
|
|
69
|
+
collectCustomerDetails: true,
|
|
70
|
+
displayCustomFieldsOnPage: true,
|
|
71
|
+
customFieldsetCode: 'service_package',
|
|
72
|
+
maxCompletions: 10,
|
|
73
|
+
status: 'draft',
|
|
74
|
+
templateId,
|
|
75
|
+
slug: `qa-crudform-link-${stamp}`,
|
|
76
|
+
customFields: {
|
|
77
|
+
service_deliverables: 'One-on-one consultation call',
|
|
78
|
+
delivery_timeline: 'Same week',
|
|
79
|
+
support_contact: 'help@example.test',
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
const created = await createLinkFixture(request, token, createInput)
|
|
83
|
+
linkId = created.id
|
|
84
|
+
|
|
85
|
+
const afterCreate = await readLink(request, token, linkId)
|
|
86
|
+
assertScalarFieldsPersisted(
|
|
87
|
+
afterCreate,
|
|
88
|
+
{
|
|
89
|
+
name: `QA CRUDFORM Link ${stamp}`,
|
|
90
|
+
title: `QA CRUDFORM Link Title ${stamp}`,
|
|
91
|
+
subtitle: 'Original link subtitle',
|
|
92
|
+
description: 'Original link description',
|
|
93
|
+
pricingMode: 'fixed',
|
|
94
|
+
fixedPriceAmount: 49.99,
|
|
95
|
+
fixedPriceCurrencyCode: 'USD',
|
|
96
|
+
fixedPriceIncludesTax: true,
|
|
97
|
+
fixedPriceOriginalAmount: 69.99,
|
|
98
|
+
collectCustomerDetails: true,
|
|
99
|
+
displayCustomFieldsOnPage: true,
|
|
100
|
+
customFieldsetCode: 'service_package',
|
|
101
|
+
maxCompletions: 10,
|
|
102
|
+
status: 'draft',
|
|
103
|
+
templateId,
|
|
104
|
+
slug: created.slug,
|
|
105
|
+
},
|
|
106
|
+
'after-create',
|
|
107
|
+
)
|
|
108
|
+
expect(afterCreate.customFields, 'after-create custom fields should persist').toMatchObject({
|
|
109
|
+
service_deliverables: 'One-on-one consultation call',
|
|
110
|
+
delivery_timeline: 'Same week',
|
|
111
|
+
support_contact: 'help@example.test',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const updatePayload: Partial<CheckoutLinkInput> = {
|
|
115
|
+
name: `QA CRUDFORM Link ${stamp} EDITED`,
|
|
116
|
+
title: `QA CRUDFORM Link Title ${stamp} EDITED`,
|
|
117
|
+
subtitle: 'Updated link subtitle',
|
|
118
|
+
description: 'Updated link description',
|
|
119
|
+
pricingMode: 'fixed',
|
|
120
|
+
fixedPriceAmount: 89.5,
|
|
121
|
+
fixedPriceCurrencyCode: 'USD',
|
|
122
|
+
fixedPriceIncludesTax: false,
|
|
123
|
+
fixedPriceOriginalAmount: 129.99,
|
|
124
|
+
gatewayProviderKey: 'mock',
|
|
125
|
+
collectCustomerDetails: false,
|
|
126
|
+
displayCustomFieldsOnPage: false,
|
|
127
|
+
customFieldsetCode: 'service_package',
|
|
128
|
+
maxCompletions: 20,
|
|
129
|
+
status: 'active',
|
|
130
|
+
customFields: {
|
|
131
|
+
delivery_timeline: 'Next business day',
|
|
132
|
+
session_format: 'In person',
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
const updateResponse = await updateLink(request, token, linkId, updatePayload)
|
|
136
|
+
expect(updateResponse.ok(), `update link failed: ${updateResponse.status()}`).toBeTruthy()
|
|
137
|
+
|
|
138
|
+
const afterUpdate = await readLink(request, token, linkId)
|
|
139
|
+
assertScalarFieldsPersisted(
|
|
140
|
+
afterUpdate,
|
|
141
|
+
{
|
|
142
|
+
name: `QA CRUDFORM Link ${stamp} EDITED`,
|
|
143
|
+
title: `QA CRUDFORM Link Title ${stamp} EDITED`,
|
|
144
|
+
subtitle: 'Updated link subtitle',
|
|
145
|
+
description: 'Updated link description',
|
|
146
|
+
pricingMode: 'fixed',
|
|
147
|
+
fixedPriceAmount: 89.5,
|
|
148
|
+
fixedPriceCurrencyCode: 'USD',
|
|
149
|
+
fixedPriceIncludesTax: false,
|
|
150
|
+
fixedPriceOriginalAmount: 129.99,
|
|
151
|
+
collectCustomerDetails: false,
|
|
152
|
+
displayCustomFieldsOnPage: false,
|
|
153
|
+
maxCompletions: 20,
|
|
154
|
+
status: 'active',
|
|
155
|
+
templateId,
|
|
156
|
+
slug: created.slug,
|
|
157
|
+
},
|
|
158
|
+
'after-update',
|
|
159
|
+
)
|
|
160
|
+
expect(afterUpdate.customFields, 'after-update custom fields should persist + retain omitted keys').toMatchObject({
|
|
161
|
+
delivery_timeline: 'Next business day',
|
|
162
|
+
session_format: 'In person',
|
|
163
|
+
service_deliverables: 'One-on-one consultation call',
|
|
164
|
+
support_contact: 'help@example.test',
|
|
165
|
+
})
|
|
166
|
+
} finally {
|
|
167
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
168
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -14,7 +14,11 @@ import {
|
|
|
14
14
|
} from './helpers/fixtures'
|
|
15
15
|
|
|
16
16
|
test.describe('TC-CHKT-008: Attempt update on locked link, verify 422', () => {
|
|
17
|
+
test.setTimeout(120_000)
|
|
18
|
+
|
|
17
19
|
test('rejects link edits after the first transaction locks the record', async ({ request }) => {
|
|
20
|
+
test.slow()
|
|
21
|
+
|
|
18
22
|
let token: string | null = null
|
|
19
23
|
let linkId: string | null = null
|
|
20
24
|
let transactionId: string | null = null
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/modules/core/__integration__/helpers/generalFixtures'
|
|
4
|
+
import {
|
|
5
|
+
createFixedTemplateInput,
|
|
6
|
+
createLinkFixture,
|
|
7
|
+
createTemplateFixture,
|
|
8
|
+
deleteCheckoutEntityIfExists,
|
|
9
|
+
readLink,
|
|
10
|
+
readTemplate,
|
|
11
|
+
updateLink,
|
|
12
|
+
updateTemplate,
|
|
13
|
+
} from './helpers/fixtures'
|
|
14
|
+
|
|
15
|
+
test.describe('TC-CHKT-039: Draft template/pay-link gateway provider edit validation', () => {
|
|
16
|
+
test('template: clearing the required gateway provider is rejected and preserves the previous value', async ({ request }) => {
|
|
17
|
+
let token: string | null = null
|
|
18
|
+
let templateId: string | null = null
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
token = await getAuthToken(request)
|
|
22
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
23
|
+
|
|
24
|
+
const clearResponse = await updateTemplate(request, token, templateId, {
|
|
25
|
+
name: 'Consulting Fee (no gateway)',
|
|
26
|
+
pricingMode: 'fixed',
|
|
27
|
+
fixedPriceAmount: 49.99,
|
|
28
|
+
fixedPriceCurrencyCode: 'USD',
|
|
29
|
+
status: 'draft',
|
|
30
|
+
gatewayProviderKey: null,
|
|
31
|
+
})
|
|
32
|
+
expect(clearResponse.status()).toBe(400)
|
|
33
|
+
const clearBody = await readJsonSafe<{ fieldErrors?: { gatewayProviderKey?: string }; error?: string }>(clearResponse)
|
|
34
|
+
expect(clearBody?.fieldErrors?.gatewayProviderKey ?? clearBody?.error ?? '').toContain('checkout.validation.gatewayProviderKey.required')
|
|
35
|
+
|
|
36
|
+
const cleared = await readTemplate(request, token, templateId)
|
|
37
|
+
expect(cleared.gatewayProviderKey).toBe('mock')
|
|
38
|
+
expect(cleared.name).not.toBe('Consulting Fee (no gateway)')
|
|
39
|
+
|
|
40
|
+
const renameResponse = await updateTemplate(request, token, templateId, {
|
|
41
|
+
name: 'Consulting Fee renamed',
|
|
42
|
+
})
|
|
43
|
+
expect(
|
|
44
|
+
renameResponse.ok(),
|
|
45
|
+
`Editing a field while retaining the existing gateway should succeed: ${renameResponse.status()} ${JSON.stringify(await readJsonSafe(renameResponse))}`,
|
|
46
|
+
).toBeTruthy()
|
|
47
|
+
|
|
48
|
+
const renamed = await readTemplate(request, token, templateId)
|
|
49
|
+
expect(renamed.gatewayProviderKey).toBe('mock')
|
|
50
|
+
expect(renamed.name).toBe('Consulting Fee renamed')
|
|
51
|
+
|
|
52
|
+
const blankResponse = await updateTemplate(request, token, templateId, {
|
|
53
|
+
gatewayProviderKey: ' ',
|
|
54
|
+
})
|
|
55
|
+
expect(blankResponse.status()).toBe(400)
|
|
56
|
+
const blankBody = await readJsonSafe<{ fieldErrors?: { gatewayProviderKey?: string }; error?: string }>(blankResponse)
|
|
57
|
+
expect(blankBody?.fieldErrors?.gatewayProviderKey ?? blankBody?.error ?? '').toContain('checkout.validation.gatewayProviderKey.required')
|
|
58
|
+
|
|
59
|
+
const blanked = await readTemplate(request, token, templateId)
|
|
60
|
+
expect(blanked.gatewayProviderKey).toBe('mock')
|
|
61
|
+
} finally {
|
|
62
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('pay-link: clearing the gateway provider on a draft saves and round-trips as null', async ({ request }) => {
|
|
67
|
+
let token: string | null = null
|
|
68
|
+
let linkId: string | null = null
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
token = await getAuthToken(request)
|
|
72
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
73
|
+
linkId = link.id
|
|
74
|
+
|
|
75
|
+
const clearResponse = await updateLink(request, token, link.id, {
|
|
76
|
+
name: 'Pay link (no gateway)',
|
|
77
|
+
pricingMode: 'fixed',
|
|
78
|
+
fixedPriceAmount: 49.99,
|
|
79
|
+
fixedPriceCurrencyCode: 'USD',
|
|
80
|
+
status: 'draft',
|
|
81
|
+
gatewayProviderKey: null,
|
|
82
|
+
})
|
|
83
|
+
expect(
|
|
84
|
+
clearResponse.ok(),
|
|
85
|
+
`Clearing the gateway on a draft pay-link should succeed: ${clearResponse.status()} ${JSON.stringify(await readJsonSafe(clearResponse))}`,
|
|
86
|
+
).toBeTruthy()
|
|
87
|
+
|
|
88
|
+
const cleared = await readLink(request, token, link.id)
|
|
89
|
+
expect(cleared.gatewayProviderKey ?? null).toBeNull()
|
|
90
|
+
expect(cleared.name).toBe('Pay link (no gateway)')
|
|
91
|
+
|
|
92
|
+
const renameResponse = await updateLink(request, token, link.id, {
|
|
93
|
+
name: 'Pay link renamed',
|
|
94
|
+
gatewayProviderKey: null,
|
|
95
|
+
})
|
|
96
|
+
expect(
|
|
97
|
+
renameResponse.ok(),
|
|
98
|
+
`Editing a field on a gateway-less draft pay-link should succeed: ${renameResponse.status()} ${JSON.stringify(await readJsonSafe(renameResponse))}`,
|
|
99
|
+
).toBeTruthy()
|
|
100
|
+
|
|
101
|
+
const renamed = await readLink(request, token, link.id)
|
|
102
|
+
expect(renamed.gatewayProviderKey ?? null).toBeNull()
|
|
103
|
+
expect(renamed.name).toBe('Pay link renamed')
|
|
104
|
+
} finally {
|
|
105
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('pay-link: publishing still requires a gateway provider', async ({ request }) => {
|
|
110
|
+
let token: string | null = null
|
|
111
|
+
let linkId: string | null = null
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
token = await getAuthToken(request)
|
|
115
|
+
const link = await createLinkFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
116
|
+
linkId = link.id
|
|
117
|
+
|
|
118
|
+
const publishResponse = await updateLink(request, token, link.id, {
|
|
119
|
+
status: 'active',
|
|
120
|
+
gatewayProviderKey: null,
|
|
121
|
+
})
|
|
122
|
+
expect([400, 422]).toContain(publishResponse.status())
|
|
123
|
+
const body = await readJsonSafe<{ error?: string; fieldErrors?: { gatewayProviderKey?: string } }>(publishResponse)
|
|
124
|
+
const fieldError = typeof body?.fieldErrors?.gatewayProviderKey === 'string' ? body.fieldErrors.gatewayProviderKey : ''
|
|
125
|
+
const errorMessage = typeof body?.error === 'string' ? body.error : ''
|
|
126
|
+
expect(`${fieldError} ${errorMessage}`.toLowerCase()).toContain('gateway')
|
|
127
|
+
} finally {
|
|
128
|
+
await deleteCheckoutEntityIfExists(request, token, 'links', linkId)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { createFixedTemplateInput, createLinkFixture, readLink } from './helpers/fixtures'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TC-CHKT-039: OSS optimistic locking now guards the pay-link/template DELETE.
|
|
7
|
+
*
|
|
8
|
+
* QA round-6 (PR #2055): after a save conflict surfaced the unified conflict
|
|
9
|
+
* bar, clicking Delete still removed the stale record. The pay-link/template
|
|
10
|
+
* delete path was unguarded — the client sent no version header and the
|
|
11
|
+
* `checkout.link.delete` / `checkout.template.delete` commands never called
|
|
12
|
+
* `enforceCommandOptimisticLock` (only the *.update commands did).
|
|
13
|
+
*
|
|
14
|
+
* This spec proves the server contract the UI relies on:
|
|
15
|
+
* - GET detail exposes `updatedAt`.
|
|
16
|
+
* - DELETE without the header succeeds (strictly additive).
|
|
17
|
+
* - DELETE with a stale header returns 409 with the structured conflict body.
|
|
18
|
+
* - DELETE with a fresh header deletes the record.
|
|
19
|
+
*
|
|
20
|
+
* Requires `OM_OPTIMISTIC_LOCK=all` (CI default).
|
|
21
|
+
*/
|
|
22
|
+
const OPTIMISTIC_LOCK_HEADER = 'x-om-ext-optimistic-lock-expected-updated-at'
|
|
23
|
+
|
|
24
|
+
function readUpdatedAt(record: Record<string, unknown>): string {
|
|
25
|
+
const raw = record.updatedAt ?? record.updated_at
|
|
26
|
+
expect(typeof raw, 'link detail should expose updatedAt as a string').toBe('string')
|
|
27
|
+
return new Date(Date.parse(raw as string)).toISOString()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test.describe('TC-CHKT-039: pay-link stale DELETE optimistic-lock guard', () => {
|
|
31
|
+
test('stale DELETE returns 409; fresh DELETE succeeds; header-less stays backward-compatible', async ({ request }) => {
|
|
32
|
+
const token = await getAuthToken(request)
|
|
33
|
+
|
|
34
|
+
// Two links: one to prove the stale→409→fresh path, one to prove the
|
|
35
|
+
// strictly-additive header-less delete still works.
|
|
36
|
+
const guarded = await createLinkFixture(request, token, createFixedTemplateInput())
|
|
37
|
+
const additive = await createLinkFixture(request, token, createFixedTemplateInput())
|
|
38
|
+
let guardedDeleted = false
|
|
39
|
+
let additiveDeleted = false
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const detail = await readLink(request, token, guarded.id)
|
|
43
|
+
const t0 = readUpdatedAt(detail as Record<string, unknown>)
|
|
44
|
+
|
|
45
|
+
// A save advances the version, making t0 stale.
|
|
46
|
+
const bump = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
47
|
+
method: 'PUT',
|
|
48
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
49
|
+
data: { subtitle: 'bumped for stale-delete test' },
|
|
50
|
+
})
|
|
51
|
+
expect(bump.status(), 'PUT bump should succeed').toBeLessThan(300)
|
|
52
|
+
|
|
53
|
+
// Stale DELETE → 409 with the structured conflict body; record survives.
|
|
54
|
+
const staleDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
55
|
+
method: 'DELETE',
|
|
56
|
+
headers: { Authorization: `Bearer ${token}`, [OPTIMISTIC_LOCK_HEADER]: t0 },
|
|
57
|
+
})
|
|
58
|
+
expect(staleDelete.status(), 'DELETE with a stale header should return 409').toBe(409)
|
|
59
|
+
expect((await staleDelete.json()) as Record<string, unknown>).toMatchObject({
|
|
60
|
+
code: 'optimistic_lock_conflict',
|
|
61
|
+
expectedUpdatedAt: t0,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const stillThere = await readLink(request, token, guarded.id)
|
|
65
|
+
const t1 = readUpdatedAt(stillThere as Record<string, unknown>)
|
|
66
|
+
expect(t1, 'stale delete must NOT have removed the record').not.toBe(t0)
|
|
67
|
+
|
|
68
|
+
// Fresh DELETE → succeeds.
|
|
69
|
+
const freshDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
70
|
+
method: 'DELETE',
|
|
71
|
+
headers: { Authorization: `Bearer ${token}`, [OPTIMISTIC_LOCK_HEADER]: t1 },
|
|
72
|
+
})
|
|
73
|
+
expect(freshDelete.status(), 'DELETE with a fresh header should succeed').toBeLessThan(300)
|
|
74
|
+
guardedDeleted = true
|
|
75
|
+
|
|
76
|
+
// Header-less DELETE still works (strictly additive).
|
|
77
|
+
const nohdrDelete = await request.fetch(`/api/checkout/links/${encodeURIComponent(additive.id)}`, {
|
|
78
|
+
method: 'DELETE',
|
|
79
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
80
|
+
})
|
|
81
|
+
expect(nohdrDelete.status(), 'DELETE without a header should succeed').toBeLessThan(300)
|
|
82
|
+
additiveDeleted = true
|
|
83
|
+
} finally {
|
|
84
|
+
if (!guardedDeleted) {
|
|
85
|
+
await request.fetch(`/api/checkout/links/${encodeURIComponent(guarded.id)}`, {
|
|
86
|
+
method: 'DELETE',
|
|
87
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
88
|
+
}).catch(() => undefined)
|
|
89
|
+
}
|
|
90
|
+
if (!additiveDeleted) {
|
|
91
|
+
await request.fetch(`/api/checkout/links/${encodeURIComponent(additive.id)}`, {
|
|
92
|
+
method: 'DELETE',
|
|
93
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
94
|
+
}).catch(() => undefined)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { expect, test, type Locator, type Page } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { login } from '@open-mercato/core/helpers/integration/auth'
|
|
4
|
+
import {
|
|
5
|
+
createFixedTemplateInput,
|
|
6
|
+
createTemplateFixture,
|
|
7
|
+
deleteCheckoutEntityIfExists,
|
|
8
|
+
readTemplate,
|
|
9
|
+
} from './helpers/fixtures'
|
|
10
|
+
|
|
11
|
+
async function waitForCaptureMethodSelect(page: Page): Promise<Locator> {
|
|
12
|
+
const captureMethodField = page.getByText('Capture method').locator('xpath=ancestor::div[contains(@class, "space-y-2")]').first()
|
|
13
|
+
const captureMethodSelect = captureMethodField.getByRole('combobox').first()
|
|
14
|
+
|
|
15
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
16
|
+
if (await captureMethodSelect.isVisible({ timeout: 20_000 }).catch(() => false)) {
|
|
17
|
+
return captureMethodSelect
|
|
18
|
+
}
|
|
19
|
+
if (attempt < 2) {
|
|
20
|
+
await page.reload({ waitUntil: 'domcontentloaded' })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await expect(page.locator('main').getByText(/Edit Template|Capture method/).first()).toBeVisible({ timeout: 5_000 })
|
|
25
|
+
return captureMethodSelect
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test.describe('TC-CHKT-040: Gateway setting selects round-trip through template edit UI', () => {
|
|
29
|
+
test('prefills and saves the checkout capture method', async ({ page, request }) => {
|
|
30
|
+
test.setTimeout(120_000)
|
|
31
|
+
let token: string | null = null
|
|
32
|
+
let templateId: string | null = null
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
token = await getAuthToken(request)
|
|
36
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({
|
|
37
|
+
gatewayProviderKey: 'mock_processing',
|
|
38
|
+
gatewaySettings: { captureMethod: 'manual' },
|
|
39
|
+
status: 'draft',
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
await login(page, 'admin')
|
|
43
|
+
await page.goto(`/backend/checkout/templates/${encodeURIComponent(templateId)}`, { waitUntil: 'domcontentloaded' })
|
|
44
|
+
|
|
45
|
+
const captureMethodSelect = await waitForCaptureMethodSelect(page)
|
|
46
|
+
await expect(captureMethodSelect).toBeVisible()
|
|
47
|
+
await expect(captureMethodSelect).toContainText('Manual capture')
|
|
48
|
+
|
|
49
|
+
await captureMethodSelect.click()
|
|
50
|
+
await page.getByRole('option', { name: 'Automatic capture' }).click()
|
|
51
|
+
await page.locator('form').getByRole('button', { name: 'Save' }).click()
|
|
52
|
+
await expect(page).toHaveURL(/\/backend\/checkout\/templates(?:\?.*)?$/)
|
|
53
|
+
|
|
54
|
+
const saved = await readTemplate(request, token, templateId)
|
|
55
|
+
expect((saved.gatewaySettings as Record<string, unknown> | undefined)?.captureMethod).toBe('automatic')
|
|
56
|
+
} finally {
|
|
57
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/modules/core/__integration__/helpers/generalFixtures'
|
|
4
|
+
import {
|
|
5
|
+
createFixedTemplateInput,
|
|
6
|
+
createTemplateFixture,
|
|
7
|
+
deleteCheckoutEntityIfExists,
|
|
8
|
+
deleteTemplate,
|
|
9
|
+
updateTemplate,
|
|
10
|
+
} from './helpers/fixtures'
|
|
11
|
+
|
|
12
|
+
const OPTIMISTIC_LOCK_HEADER = 'x-om-ext-optimistic-lock-expected-updated-at'
|
|
13
|
+
const STALE_EXPECTED_AT = '2020-01-01T00:00:00.000Z'
|
|
14
|
+
|
|
15
|
+
function resolveUrl(path: string): string {
|
|
16
|
+
const base = process.env.BASE_URL?.trim() || null
|
|
17
|
+
return base ? `${base}${path}` : path
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Regression for #2529 (alinadivante comment 4638514821, "TC A" optimistic-lock gap):
|
|
21
|
+
// editing a checkout template that was deleted in another tab must surface a clean
|
|
22
|
+
// optimistic-lock conflict (409) when the client sent the expected-version header —
|
|
23
|
+
// not a bare "Template not found" 404. Plain API clients that send no header keep the
|
|
24
|
+
// existing 404 (fail-open).
|
|
25
|
+
test.describe('TC-CHKT-041: Checkout template stale-edit after delete', () => {
|
|
26
|
+
test('PUT with stale optimistic-lock header on a deleted template returns 409 conflict', async ({ request }) => {
|
|
27
|
+
const token = await getAuthToken(request, 'admin')
|
|
28
|
+
let templateId: string | null = null
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
templateId = await createTemplateFixture(request, token, createFixedTemplateInput({ status: 'draft' }))
|
|
32
|
+
|
|
33
|
+
// Simulate "deleted in another tab".
|
|
34
|
+
const deleteResponse = await deleteTemplate(request, token, templateId)
|
|
35
|
+
expect(deleteResponse.ok(), 'template delete fixture should succeed').toBeTruthy()
|
|
36
|
+
|
|
37
|
+
// Replay a stale edit carrying the optimistic-lock header → expect 409.
|
|
38
|
+
const conflictResponse = await request.fetch(resolveUrl(`/api/checkout/templates/${encodeURIComponent(templateId)}`), {
|
|
39
|
+
method: 'PUT',
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: `Bearer ${token}`,
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
[OPTIMISTIC_LOCK_HEADER]: STALE_EXPECTED_AT,
|
|
44
|
+
},
|
|
45
|
+
data: { name: 'QA stale edit' },
|
|
46
|
+
})
|
|
47
|
+
expect(conflictResponse.status(), 'stale edit after delete should be a 409 conflict').toBe(409)
|
|
48
|
+
const conflictBody = await readJsonSafe<{ code?: string }>(conflictResponse)
|
|
49
|
+
expect(conflictBody?.code, 'conflict body should carry the optimistic-lock code').toBe('optimistic_lock_conflict')
|
|
50
|
+
|
|
51
|
+
// Without the header, the same edit keeps the plain 404 (fail-open).
|
|
52
|
+
const notFoundResponse = await updateTemplate(request, token, templateId, { name: 'QA stale edit no header' })
|
|
53
|
+
expect(notFoundResponse.status(), 'no-header edit after delete should stay 404').toBe(404)
|
|
54
|
+
} finally {
|
|
55
|
+
await deleteCheckoutEntityIfExists(request, token, 'templates', templateId)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -14,6 +14,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
|
|
|
14
14
|
import { Badge } from '@open-mercato/ui/primitives/badge'
|
|
15
15
|
import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
16
16
|
import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
17
|
+
import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
|
|
17
18
|
|
|
18
19
|
type LinkRow = {
|
|
19
20
|
id: string
|
|
@@ -205,6 +206,13 @@ export default function CheckoutPayLinksPage() {
|
|
|
205
206
|
</Link>
|
|
206
207
|
</Button>
|
|
207
208
|
)}
|
|
209
|
+
emptyState={(
|
|
210
|
+
<ListEmptyState
|
|
211
|
+
entityName={t('checkout.admin.payLinks.title')}
|
|
212
|
+
createHref="/backend/checkout/pay-links/create"
|
|
213
|
+
createLabel={t('checkout.admin.payLinks.actions.create')}
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
208
216
|
rowActions={(row) => (
|
|
209
217
|
<RowActions items={[
|
|
210
218
|
{ id: 'edit', label: t('checkout.common.actions.edit'), href: `/backend/checkout/pay-links/${encodeURIComponent(row.id)}` },
|
|
@@ -10,6 +10,7 @@ import { DataTable } from '@open-mercato/ui/backend/DataTable'
|
|
|
10
10
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
11
11
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
12
12
|
import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
13
|
+
import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
|
|
13
14
|
|
|
14
15
|
type TemplateRow = {
|
|
15
16
|
id: string
|
|
@@ -129,6 +130,13 @@ export default function CheckoutTemplatesPage() {
|
|
|
129
130
|
</Link>
|
|
130
131
|
</Button>
|
|
131
132
|
)}
|
|
133
|
+
emptyState={(
|
|
134
|
+
<ListEmptyState
|
|
135
|
+
entityName={t('checkout.admin.templates.title')}
|
|
136
|
+
createHref="/backend/checkout/templates/create"
|
|
137
|
+
createLabel={t('checkout.admin.templates.actions.create')}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
132
140
|
rowActions={(row) => (
|
|
133
141
|
<RowActions items={[
|
|
134
142
|
{ id: 'edit', label: t('checkout.common.actions.edit'), href: `/backend/checkout/templates/${encodeURIComponent(row.id)}` },
|