@open-mercato/checkout 0.6.4-develop.4371.1.8f3030407e → 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.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js +125 -0
  3. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js.map +7 -0
  4. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js +139 -0
  5. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js.map +7 -0
  6. package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js +2 -0
  7. package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js.map +2 -2
  8. package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js +115 -0
  9. package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js.map +7 -0
  10. package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js +66 -0
  11. package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js.map +7 -0
  12. package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js +52 -0
  13. package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js.map +7 -0
  14. package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js +44 -0
  15. package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js.map +7 -0
  16. package/dist/modules/checkout/backend/checkout/pay-links/page.js +9 -0
  17. package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
  18. package/dist/modules/checkout/backend/checkout/templates/page.js +9 -0
  19. package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
  20. package/dist/modules/checkout/commands/links.js +67 -0
  21. package/dist/modules/checkout/commands/links.js.map +2 -2
  22. package/dist/modules/checkout/commands/templates.js +69 -2
  23. package/dist/modules/checkout/commands/templates.js.map +2 -2
  24. package/dist/modules/checkout/components/GatewaySettingsFields.js +32 -15
  25. package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
  26. package/dist/modules/checkout/components/LinkTemplateForm.js +27 -7
  27. package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
  28. package/dist/modules/checkout/data/validators.js +18 -2
  29. package/dist/modules/checkout/data/validators.js.map +2 -2
  30. package/dist/modules/checkout/lib/utils.js +26 -5
  31. package/dist/modules/checkout/lib/utils.js.map +2 -2
  32. package/jest.config.cjs +2 -0
  33. package/package.json +7 -8
  34. package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.ts +158 -0
  35. package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.ts +171 -0
  36. package/src/modules/checkout/__integration__/TC-CHKT-008.spec.ts +4 -0
  37. package/src/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.ts +131 -0
  38. package/src/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.ts +98 -0
  39. package/src/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.ts +60 -0
  40. package/src/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.ts +58 -0
  41. package/src/modules/checkout/backend/checkout/pay-links/page.tsx +8 -0
  42. package/src/modules/checkout/backend/checkout/templates/page.tsx +8 -0
  43. package/src/modules/checkout/commands/__tests__/optimistic-lock.test.ts +261 -0
  44. package/src/modules/checkout/commands/__tests__/redo-coverage.test.ts +21 -0
  45. package/src/modules/checkout/commands/links.ts +67 -0
  46. package/src/modules/checkout/commands/templates.ts +74 -2
  47. package/src/modules/checkout/components/GatewaySettingsFields.tsx +40 -18
  48. package/src/modules/checkout/components/LinkTemplateForm.tsx +27 -7
  49. package/src/modules/checkout/data/__tests__/validators.test.ts +66 -1
  50. package/src/modules/checkout/data/validators.ts +18 -2
  51. package/src/modules/checkout/lib/__tests__/utils.test.ts +112 -0
  52. 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
+ })
@@ -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)}` },