@open-mercato/webhooks 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4544.1.71c003c861
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/webhooks/__integration__/TC-LOCK-OSS-043.spec.js +238 -0
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js.map +7 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +24 -0
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js +9 -2
- package/dist/modules/webhooks/backend/webhooks/[id]/page.js.map +3 -3
- package/dist/modules/webhooks/backend/webhooks/page.js +4 -1
- package/dist/modules/webhooks/backend/webhooks/page.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +352 -0
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +101 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +26 -0
- package/src/modules/webhooks/backend/webhooks/[id]/page.tsx +9 -2
- package/src/modules/webhooks/backend/webhooks/page.tsx +4 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { test, expect, type APIRequestContext } from '@playwright/test'
|
|
2
|
+
import { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
3
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'
|
|
4
|
+
import {
|
|
5
|
+
putWithLock,
|
|
6
|
+
expectConflictBody,
|
|
7
|
+
expectConflictBanner,
|
|
8
|
+
resolveApiUrl,
|
|
9
|
+
} from '@open-mercato/core/modules/core/__integration__/helpers/optimisticLockUi'
|
|
10
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TC-LOCK-OSS-043 — webhooks + inbox settings + data-sync schedule
|
|
14
|
+
* optimistic-lock conflict contract (WHK-01, INB-01, SYNC-01).
|
|
15
|
+
*
|
|
16
|
+
* All three surfaces guard writes with `enforceCommandOptimisticLock` inside
|
|
17
|
+
* pure command/API routes — none expose a dedicated `data-crud-field-id`
|
|
18
|
+
* CrudForm edit page for the locked record, so the conflict contract is proven
|
|
19
|
+
* at the API level: capture the record's `updated_at`, advance it out-of-band
|
|
20
|
+
* via a header-less write (the strictly-additive path always succeeds and bumps
|
|
21
|
+
* `updated_at`), then replay the now-stale write with the original
|
|
22
|
+
* expected-version header → 409 `optimistic_lock_conflict`.
|
|
23
|
+
*
|
|
24
|
+
* - WHK-01: `PUT /api/webhooks/<id>` and `DELETE /api/webhooks/<id>`
|
|
25
|
+
* (`packages/webhooks/src/modules/webhooks/api/webhooks/[id]/route.ts`,
|
|
26
|
+
* `resourceKind: 'webhooks.endpoint'`).
|
|
27
|
+
* - INB-01: `PATCH /api/inbox_ops/settings`
|
|
28
|
+
* (`packages/core/src/modules/inbox_ops/api/settings/route.ts`,
|
|
29
|
+
* `resourceKind: 'inbox_ops.settings'`). The settings row is a per-tenant
|
|
30
|
+
* singleton, so the test bumps + restores `workingLanguage` instead of
|
|
31
|
+
* creating/deleting a fixture.
|
|
32
|
+
* - SYNC-01: `POST /api/data_sync/schedules`
|
|
33
|
+
* (`packages/core/src/modules/data_sync/api/schedules/route.ts` →
|
|
34
|
+
* `SyncScheduleService.saveSchedule`, `resourceKind: 'data_sync.schedule'`).
|
|
35
|
+
* The collection POST reads the expected-version header; the service enforces
|
|
36
|
+
* the lock when a row with the same (integrationId, entityType, direction)
|
|
37
|
+
* key already exists.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const WEBHOOKS_API = '/api/webhooks'
|
|
41
|
+
const INBOX_SETTINGS_API = '/api/inbox_ops/settings'
|
|
42
|
+
const SCHEDULES_API = '/api/data_sync/schedules'
|
|
43
|
+
|
|
44
|
+
type InboxSettings = { id: string; workingLanguage: string; updatedAt: string }
|
|
45
|
+
|
|
46
|
+
async function createWebhook(
|
|
47
|
+
request: APIRequestContext,
|
|
48
|
+
token: string,
|
|
49
|
+
stamp: number,
|
|
50
|
+
): Promise<{ id: string; updatedAt: string }> {
|
|
51
|
+
const created = await apiRequest(request, 'POST', WEBHOOKS_API, {
|
|
52
|
+
token,
|
|
53
|
+
data: {
|
|
54
|
+
name: `QA Lock 043 ${stamp}`,
|
|
55
|
+
url: `https://example.com/qa-lock-043-${stamp}`,
|
|
56
|
+
subscribedEvents: ['qa.lock.test'],
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
expect(created.status(), 'POST webhook should be 201').toBe(201)
|
|
60
|
+
const body = (await created.json()) as { id?: string }
|
|
61
|
+
expect(typeof body.id, 'webhook creation should return an id').toBe('string')
|
|
62
|
+
const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${body.id}`, { token })
|
|
63
|
+
expect(detail.status(), 'GET webhook detail should be 200').toBe(200)
|
|
64
|
+
const detailBody = (await detail.json()) as { updatedAt?: string }
|
|
65
|
+
expect(typeof detailBody.updatedAt, 'webhook should expose updatedAt').toBe('string')
|
|
66
|
+
return { id: body.id as string, updatedAt: detailBody.updatedAt as string }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readWebhookUpdatedAt(
|
|
70
|
+
request: APIRequestContext,
|
|
71
|
+
token: string,
|
|
72
|
+
id: string,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${id}`, { token })
|
|
75
|
+
expect(detail.status(), 'GET webhook detail should be 200').toBe(200)
|
|
76
|
+
const body = (await detail.json()) as { updatedAt?: string }
|
|
77
|
+
return body.updatedAt as string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function deleteWebhook(
|
|
81
|
+
request: APIRequestContext,
|
|
82
|
+
token: string,
|
|
83
|
+
id: string,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const current = await readWebhookUpdatedAt(request, token, id).catch(() => undefined)
|
|
86
|
+
await request
|
|
87
|
+
.fetch(resolveApiUrl(`${WEBHOOKS_API}/${id}`), {
|
|
88
|
+
method: 'DELETE',
|
|
89
|
+
headers: {
|
|
90
|
+
Authorization: `Bearer ${token}`,
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
...(current ? { [OPTIMISTIC_LOCK_HEADER_NAME]: current } : {}),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
.catch(() => undefined)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function patchInboxSettings(
|
|
99
|
+
request: APIRequestContext,
|
|
100
|
+
token: string,
|
|
101
|
+
body: Record<string, unknown>,
|
|
102
|
+
lockValue?: string,
|
|
103
|
+
) {
|
|
104
|
+
return request.fetch(resolveApiUrl(INBOX_SETTINGS_API), {
|
|
105
|
+
method: 'PATCH',
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${token}`,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),
|
|
110
|
+
},
|
|
111
|
+
data: body,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readInboxSettings(
|
|
116
|
+
request: APIRequestContext,
|
|
117
|
+
token: string,
|
|
118
|
+
): Promise<InboxSettings> {
|
|
119
|
+
const response = await apiRequest(request, 'GET', INBOX_SETTINGS_API, { token })
|
|
120
|
+
expect(response.status(), 'GET inbox settings should be 200').toBe(200)
|
|
121
|
+
const body = (await response.json()) as { settings?: InboxSettings | null }
|
|
122
|
+
expect(body.settings, 'tenant inbox settings singleton should exist').toBeTruthy()
|
|
123
|
+
return body.settings as InboxSettings
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function postSchedule(
|
|
127
|
+
request: APIRequestContext,
|
|
128
|
+
token: string,
|
|
129
|
+
scheduleValue: string,
|
|
130
|
+
integrationId: string,
|
|
131
|
+
lockValue?: string,
|
|
132
|
+
) {
|
|
133
|
+
return request.fetch(resolveApiUrl(SCHEDULES_API), {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${token}`,
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),
|
|
139
|
+
},
|
|
140
|
+
data: {
|
|
141
|
+
integrationId,
|
|
142
|
+
entityType: 'products',
|
|
143
|
+
direction: 'import',
|
|
144
|
+
scheduleType: 'cron',
|
|
145
|
+
scheduleValue,
|
|
146
|
+
timezone: 'UTC',
|
|
147
|
+
fullSync: false,
|
|
148
|
+
isEnabled: true,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule optimistic lock', () => {
|
|
154
|
+
test('WHK-01 stale webhook PUT is refused with a 409 conflict', async ({ page }) => {
|
|
155
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
156
|
+
const stamp = Date.now()
|
|
157
|
+
let webhookId: string | null = null
|
|
158
|
+
try {
|
|
159
|
+
const webhook = await createWebhook(page.request, token, stamp)
|
|
160
|
+
webhookId = webhook.id
|
|
161
|
+
const staleUpdatedAt = webhook.updatedAt
|
|
162
|
+
|
|
163
|
+
// Advance updated_at out-of-band via a header-less PUT.
|
|
164
|
+
const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
|
|
165
|
+
token,
|
|
166
|
+
data: { name: `QA Lock 043 bumped ${stamp}` },
|
|
167
|
+
})
|
|
168
|
+
expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
|
|
169
|
+
|
|
170
|
+
// Replay the now-stale write → 409.
|
|
171
|
+
const conflict = await putWithLock(
|
|
172
|
+
page.request,
|
|
173
|
+
token,
|
|
174
|
+
`${WEBHOOKS_API}/${webhookId}`,
|
|
175
|
+
{ name: `QA Lock 043 stale ${stamp}` },
|
|
176
|
+
staleUpdatedAt,
|
|
177
|
+
)
|
|
178
|
+
await expectConflictBody(conflict)
|
|
179
|
+
} finally {
|
|
180
|
+
if (webhookId) await deleteWebhook(page.request, token, webhookId)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('WHK-01 stale webhook DELETE is refused with a 409 conflict', async ({ page }) => {
|
|
185
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
186
|
+
const stamp = Date.now()
|
|
187
|
+
let webhookId: string | null = null
|
|
188
|
+
try {
|
|
189
|
+
const webhook = await createWebhook(page.request, token, stamp)
|
|
190
|
+
webhookId = webhook.id
|
|
191
|
+
const staleUpdatedAt = webhook.updatedAt
|
|
192
|
+
|
|
193
|
+
const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
|
|
194
|
+
token,
|
|
195
|
+
data: { name: `QA Lock 043 bumped ${stamp}` },
|
|
196
|
+
})
|
|
197
|
+
expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
|
|
198
|
+
|
|
199
|
+
const conflict = await page.request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${webhookId}`), {
|
|
200
|
+
method: 'DELETE',
|
|
201
|
+
headers: {
|
|
202
|
+
Authorization: `Bearer ${token}`,
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
[OPTIMISTIC_LOCK_HEADER_NAME]: staleUpdatedAt,
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
await expectConflictBody(conflict)
|
|
208
|
+
} finally {
|
|
209
|
+
if (webhookId) await deleteWebhook(page.request, token, webhookId)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// WHK-01 (browser UI): now ACTIVE. The product fix is committed/verified — the webhooks
|
|
214
|
+
// server DELETE returns the structured 409 (proven by the active "WHK-01 stale webhook
|
|
215
|
+
// DELETE is refused with a 409" API test) and the list page sends the lock header
|
|
216
|
+
// (`buildOptimisticLockHeader(row.updatedAt)`) + routes the resulting 409 through
|
|
217
|
+
// surfaceRecordConflict. The RowActions menu opens on CLICK of the "Open actions" kebab and
|
|
218
|
+
// renders the items in a portal on document.body (role="menuitem"); the confirm dialog is a
|
|
219
|
+
// role="alertdialog" with a "Confirm" button. We drive that choreography robustly here:
|
|
220
|
+
// wait for the filtered list GET to settle, open the kebab, click Delete, confirm, then
|
|
221
|
+
// assert the unified conflict bar (never the success toast).
|
|
222
|
+
test('WHK-01 stale webhook DELETE in the list surfaces the conflict bar', async ({ page }) => {
|
|
223
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
224
|
+
const stamp = Date.now()
|
|
225
|
+
const webhookName = `QA Lock 043 ${stamp}`
|
|
226
|
+
let webhookId: string | null = null
|
|
227
|
+
try {
|
|
228
|
+
const webhook = await createWebhook(page.request, token, stamp)
|
|
229
|
+
webhookId = webhook.id
|
|
230
|
+
|
|
231
|
+
await login(page, 'admin')
|
|
232
|
+
await page.goto('/backend/webhooks')
|
|
233
|
+
// Wait for the list GET to settle before interacting. The list reflects the
|
|
234
|
+
// freshly created fixture immediately (newest-first), so a unique name makes
|
|
235
|
+
// the row unambiguous.
|
|
236
|
+
await page
|
|
237
|
+
.waitForResponse(
|
|
238
|
+
(response) =>
|
|
239
|
+
response.url().includes('/api/webhooks') &&
|
|
240
|
+
response.request().method() === 'GET' &&
|
|
241
|
+
response.ok(),
|
|
242
|
+
{ timeout: 20_000 },
|
|
243
|
+
)
|
|
244
|
+
.catch(() => undefined)
|
|
245
|
+
|
|
246
|
+
const row = page.getByRole('row', { name: new RegExp(`${webhookName}\\b`) }).first()
|
|
247
|
+
await expect(row, 'created webhook should appear in the list').toBeVisible({ timeout: 20_000 })
|
|
248
|
+
|
|
249
|
+
// Advance updated_at out-of-band → the in-page row token is now stale.
|
|
250
|
+
const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {
|
|
251
|
+
token,
|
|
252
|
+
data: { name: `QA Lock 043 bumped ${stamp}` },
|
|
253
|
+
})
|
|
254
|
+
expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)
|
|
255
|
+
|
|
256
|
+
// Open the row's RowActions kebab (opens on click) and trigger Delete. The
|
|
257
|
+
// menu renders in a portal on document.body, so query it at the page level.
|
|
258
|
+
const kebab = row.getByRole('button', { name: /open actions/i })
|
|
259
|
+
await expect(kebab, 'row should expose an Open actions trigger').toBeVisible()
|
|
260
|
+
await kebab.click()
|
|
261
|
+
const deleteItem = page.getByRole('menuitem', { name: /^delete$/i })
|
|
262
|
+
await expect(deleteItem, 'delete menu item should be visible').toBeVisible({ timeout: 10_000 })
|
|
263
|
+
await deleteItem.click()
|
|
264
|
+
|
|
265
|
+
// Confirm the destructive alertdialog (the row still holds the stale
|
|
266
|
+
// updated_at captured at render time, so the DELETE 409s).
|
|
267
|
+
const dialog = page.getByRole('alertdialog')
|
|
268
|
+
await expect(dialog, 'confirm dialog should open').toBeVisible({ timeout: 10_000 })
|
|
269
|
+
await dialog.getByRole('button', { name: /^(confirm|delete)$/i }).click()
|
|
270
|
+
|
|
271
|
+
// The client must route the 409 to the unified conflict bar, never the
|
|
272
|
+
// success toast.
|
|
273
|
+
await expectConflictBanner(page)
|
|
274
|
+
} finally {
|
|
275
|
+
if (webhookId) await deleteWebhook(page.request, token, webhookId)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('INB-01 stale inbox-settings PATCH is refused with a 409 conflict', async ({ page }) => {
|
|
280
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
281
|
+
const before = await readInboxSettings(page.request, token)
|
|
282
|
+
const originalLanguage = before.workingLanguage
|
|
283
|
+
const staleUpdatedAt = before.updatedAt
|
|
284
|
+
const bumpLanguage = originalLanguage === 'de' ? 'en' : 'de'
|
|
285
|
+
const staleLanguage = originalLanguage === 'es' ? 'pl' : 'es'
|
|
286
|
+
try {
|
|
287
|
+
// Advance updated_at out-of-band via a header-less PATCH.
|
|
288
|
+
const bump = await patchInboxSettings(page.request, token, { workingLanguage: bumpLanguage })
|
|
289
|
+
expect(bump.status(), 'out-of-band inbox PATCH should succeed').toBeLessThan(300)
|
|
290
|
+
|
|
291
|
+
// Replay the now-stale write with the original expected-version → 409.
|
|
292
|
+
const conflict = await patchInboxSettings(
|
|
293
|
+
page.request,
|
|
294
|
+
token,
|
|
295
|
+
{ workingLanguage: staleLanguage },
|
|
296
|
+
staleUpdatedAt,
|
|
297
|
+
)
|
|
298
|
+
await expectConflictBody(conflict)
|
|
299
|
+
} finally {
|
|
300
|
+
// Restore the original working language with a fresh lock token.
|
|
301
|
+
const current = await readInboxSettings(page.request, token).catch(() => null)
|
|
302
|
+
if (current) {
|
|
303
|
+
await patchInboxSettings(
|
|
304
|
+
page.request,
|
|
305
|
+
token,
|
|
306
|
+
{ workingLanguage: originalLanguage },
|
|
307
|
+
current.updatedAt,
|
|
308
|
+
).catch(() => undefined)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('SYNC-01 stale data-sync schedule save is refused with a 409 conflict', async ({ page }) => {
|
|
314
|
+
const token = await getAuthToken(page.request, 'admin')
|
|
315
|
+
const stamp = Date.now()
|
|
316
|
+
const integrationId = `qa-lock-043-${stamp}`
|
|
317
|
+
let scheduleId: string | null = null
|
|
318
|
+
try {
|
|
319
|
+
const created = await postSchedule(page.request, token, '0 * * * *', integrationId)
|
|
320
|
+
expect(created.status(), 'POST schedule should be 201').toBe(201)
|
|
321
|
+
const createdBody = (await created.json()) as { id?: string; updatedAt?: string }
|
|
322
|
+
scheduleId = createdBody.id ?? null
|
|
323
|
+
const staleUpdatedAt = createdBody.updatedAt
|
|
324
|
+
expect(typeof scheduleId, 'schedule creation should return an id').toBe('string')
|
|
325
|
+
expect(typeof staleUpdatedAt, 'schedule should expose updatedAt').toBe('string')
|
|
326
|
+
|
|
327
|
+
// Advance updated_at out-of-band via the header-less [id] PUT route.
|
|
328
|
+
const bump = await apiRequest(page.request, 'PUT', `${SCHEDULES_API}/${scheduleId}`, {
|
|
329
|
+
token,
|
|
330
|
+
data: { scheduleValue: '5 * * * *' },
|
|
331
|
+
})
|
|
332
|
+
expect(bump.status(), 'out-of-band schedule PUT should succeed').toBeLessThan(300)
|
|
333
|
+
|
|
334
|
+
// Replay the save for the same (integrationId, entityType, direction) key
|
|
335
|
+
// with the now-stale expected-version → 409.
|
|
336
|
+
const conflict = await postSchedule(
|
|
337
|
+
page.request,
|
|
338
|
+
token,
|
|
339
|
+
'9 * * * *',
|
|
340
|
+
integrationId,
|
|
341
|
+
staleUpdatedAt as string,
|
|
342
|
+
)
|
|
343
|
+
await expectConflictBody(conflict)
|
|
344
|
+
} finally {
|
|
345
|
+
if (scheduleId) {
|
|
346
|
+
await apiRequest(page.request, 'DELETE', `${SCHEDULES_API}/${scheduleId}`, { token }).catch(
|
|
347
|
+
() => undefined,
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
4
|
+
|
|
5
|
+
const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174070'
|
|
6
|
+
const CURRENT_VERSION = '2026-06-01T10:00:00.000Z'
|
|
7
|
+
const STALE_VERSION = '2026-06-01T09:00:00.000Z'
|
|
8
|
+
|
|
9
|
+
const webhookRecord = {
|
|
10
|
+
id: WEBHOOK_ID,
|
|
11
|
+
name: 'Hook',
|
|
12
|
+
description: null,
|
|
13
|
+
url: 'https://example.com/hook',
|
|
14
|
+
subscribedEvents: ['a.b.c'],
|
|
15
|
+
httpMethod: 'POST',
|
|
16
|
+
isActive: true,
|
|
17
|
+
organizationId: 'org-1',
|
|
18
|
+
tenantId: 'tenant-1',
|
|
19
|
+
updatedAt: new Date(CURRENT_VERSION),
|
|
20
|
+
deletedAt: null as Date | null,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mockEm = {
|
|
24
|
+
fork: jest.fn(() => mockEm),
|
|
25
|
+
flush: jest.fn(async () => undefined),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mockEmitWebhooksEvent = jest.fn(async () => undefined)
|
|
29
|
+
|
|
30
|
+
jest.mock('../../../../events', () => ({
|
|
31
|
+
emitWebhooksEvent: (...args: unknown[]) => mockEmitWebhooksEvent(...args),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
jest.mock('../../../helpers', () => ({
|
|
35
|
+
json: (payload: unknown, init: ResponseInit = { status: 200 }) =>
|
|
36
|
+
new Response(JSON.stringify(payload), {
|
|
37
|
+
...init,
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
}),
|
|
40
|
+
resolveWebhookRequestScope: jest.fn(async () => ({ em: mockEm, tenantId: 'tenant-1', organizationId: 'org-1' })),
|
|
41
|
+
findScopedWebhook: jest.fn(async () => (webhookRecord.deletedAt ? null : webhookRecord)),
|
|
42
|
+
serializeWebhookDetail: (item: { id: string; updatedAt: Date }) => ({
|
|
43
|
+
id: item.id,
|
|
44
|
+
updatedAt: item.updatedAt.toISOString(),
|
|
45
|
+
}),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
49
|
+
resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
import { PUT, DELETE } from '../route'
|
|
53
|
+
|
|
54
|
+
function request(method: string, headerVersion: string | null, body?: unknown) {
|
|
55
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
56
|
+
if (headerVersion) headers[OPTIMISTIC_LOCK_HEADER_NAME] = headerVersion
|
|
57
|
+
return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
|
|
58
|
+
method,
|
|
59
|
+
headers,
|
|
60
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
|
|
65
|
+
|
|
66
|
+
describe('webhook endpoint PUT/DELETE optimistic locking', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
jest.clearAllMocks()
|
|
69
|
+
webhookRecord.deletedAt = null
|
|
70
|
+
delete process.env.OM_OPTIMISTIC_LOCK
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('PUT returns 409 with the structured conflict body when the expected version is stale', async () => {
|
|
74
|
+
const res = await PUT(request('PUT', STALE_VERSION, { name: 'X' }), context)
|
|
75
|
+
expect(res.status).toBe(409)
|
|
76
|
+
const body = await res.json()
|
|
77
|
+
expect(body.code).toBe('optimistic_lock_conflict')
|
|
78
|
+
expect(body.currentUpdatedAt).toBe(CURRENT_VERSION)
|
|
79
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('PUT succeeds when the expected version matches', async () => {
|
|
83
|
+
const res = await PUT(request('PUT', CURRENT_VERSION, { name: 'X' }), context)
|
|
84
|
+
expect(res.status).toBe(200)
|
|
85
|
+
expect(mockEm.flush).toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('PUT is a no-op (no 409) when the client sends no expected-version header', async () => {
|
|
89
|
+
const res = await PUT(request('PUT', null, { name: 'X' }), context)
|
|
90
|
+
expect(res.status).toBe(200)
|
|
91
|
+
expect(mockEm.flush).toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('DELETE returns 409 when the expected version is stale', async () => {
|
|
95
|
+
const res = await DELETE(request('DELETE', STALE_VERSION), context)
|
|
96
|
+
expect(res.status).toBe(409)
|
|
97
|
+
const body = await res.json()
|
|
98
|
+
expect(body.code).toBe('optimistic_lock_conflict')
|
|
99
|
+
expect(mockEm.flush).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -4,6 +4,8 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
|
4
4
|
import { emitWebhooksEvent } from '../../../events'
|
|
5
5
|
import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'
|
|
6
6
|
import { webhookUpdateSchema } from '../../../data/validators'
|
|
7
|
+
import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
8
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
7
9
|
|
|
8
10
|
export const metadata = {
|
|
9
11
|
GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
|
|
@@ -69,6 +71,18 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
|
|
|
69
71
|
return json({ error: 'Webhook not found' }, { status: 404 })
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
try {
|
|
75
|
+
enforceCommandOptimisticLock({
|
|
76
|
+
resourceKind: 'webhooks.endpoint',
|
|
77
|
+
resourceId: webhook.id,
|
|
78
|
+
current: webhook.updatedAt ?? null,
|
|
79
|
+
request,
|
|
80
|
+
})
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
83
|
+
throw err
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))
|
|
73
87
|
if (!parsed.success) {
|
|
74
88
|
return json({ error: 'Invalid request payload' }, { status: 400 })
|
|
@@ -114,6 +128,18 @@ export async function DELETE(request: Request, context: RouteContext): Promise<R
|
|
|
114
128
|
return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })
|
|
115
129
|
}
|
|
116
130
|
|
|
131
|
+
try {
|
|
132
|
+
enforceCommandOptimisticLock({
|
|
133
|
+
resourceKind: 'webhooks.endpoint',
|
|
134
|
+
resourceId: webhook.id,
|
|
135
|
+
current: webhook.updatedAt ?? null,
|
|
136
|
+
request,
|
|
137
|
+
})
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
140
|
+
throw err
|
|
141
|
+
}
|
|
142
|
+
|
|
117
143
|
webhook.deletedAt = new Date()
|
|
118
144
|
await em.flush()
|
|
119
145
|
|
|
@@ -16,6 +16,8 @@ import { FormHeader } from '@open-mercato/ui/backend/forms'
|
|
|
16
16
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
17
17
|
import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
|
|
18
18
|
import { deleteCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
19
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
20
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
19
21
|
import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
|
|
20
22
|
import {
|
|
21
23
|
buildWebhookFormContentHeader,
|
|
@@ -362,10 +364,14 @@ export default function WebhookDetailPage() {
|
|
|
362
364
|
const handleDelete = React.useCallback(async () => {
|
|
363
365
|
if (!webhook) return
|
|
364
366
|
try {
|
|
365
|
-
await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
|
|
367
|
+
await deleteCrud(`webhooks/${encodeURIComponent(webhook.id)}`, {
|
|
368
|
+
fallbackResult: null,
|
|
369
|
+
headers: buildOptimisticLockHeader(webhook.updatedAt),
|
|
370
|
+
})
|
|
366
371
|
flash(t('webhooks.list.deleteSuccess'), 'success')
|
|
367
372
|
router.push('/backend/webhooks')
|
|
368
|
-
} catch {
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (surfaceRecordConflict(error, t)) return
|
|
369
375
|
flash(t('webhooks.list.deleteError'), 'error')
|
|
370
376
|
}
|
|
371
377
|
}, [router, t, webhook])
|
|
@@ -497,6 +503,7 @@ export default function WebhookDetailPage() {
|
|
|
497
503
|
fields={fields}
|
|
498
504
|
groups={groups}
|
|
499
505
|
initialValues={createWebhookInitialValues(webhook)}
|
|
506
|
+
optimisticLockUpdatedAt={webhook.updatedAt}
|
|
500
507
|
submitLabel={t('common.save')}
|
|
501
508
|
cancelHref={`/backend/webhooks/${webhook.id}`}
|
|
502
509
|
contentHeader={contentHeader}
|
|
@@ -8,6 +8,8 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|
|
8
8
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
9
9
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
10
10
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
11
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
12
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
11
13
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
12
14
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
13
15
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -107,10 +109,11 @@ export default function WebhooksListPage() {
|
|
|
107
109
|
try {
|
|
108
110
|
const call = await apiCall<{ error?: string }>(
|
|
109
111
|
`/api/webhooks/${encodeURIComponent(row.id)}`,
|
|
110
|
-
{ method: 'DELETE' },
|
|
112
|
+
{ method: 'DELETE', headers: buildOptimisticLockHeader(row.updatedAt) },
|
|
111
113
|
{ fallback: null },
|
|
112
114
|
)
|
|
113
115
|
if (!call.ok) {
|
|
116
|
+
if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
|
|
114
117
|
const errorPayload = call.result as { error?: string } | undefined
|
|
115
118
|
const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('webhooks.list.deleteError')
|
|
116
119
|
flash(message, 'error')
|