create-mercato-app 0.4.6-develop-079c26bb68 → 0.4.6-develop-02aac88968
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/package.json +1 -1
- package/template/src/app/globals.css +7 -0
- package/template/src/bootstrap.ts +4 -0
- package/template/src/modules/example/__integration__/TC-UMES-006-mutation-lifecycle.spec.ts +461 -0
- package/template/src/modules/example/__integration__/TC-UMES-007.spec.ts +175 -0
- package/template/src/modules/example/backend/mutation-lifecycle/page.meta.ts +24 -0
- package/template/src/modules/example/backend/mutation-lifecycle/page.tsx +386 -0
- package/template/src/modules/example/commands/interceptors.ts +33 -0
- package/template/src/modules/example/data/guards.ts +30 -0
- package/template/src/modules/example/subscribers/audit-delete.ts +24 -0
- package/template/src/modules/example/subscribers/auto-default-priority.ts +27 -0
- package/template/src/modules/example/subscribers/prevent-uncomplete.ts +35 -0
package/package.json
CHANGED
|
@@ -58,6 +58,7 @@ TODO: Fix that latter to have reference by the package names
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
:root {
|
|
61
|
+
color-scheme: light;
|
|
61
62
|
--radius: 0.625rem;
|
|
62
63
|
--background: oklch(1 0 0);
|
|
63
64
|
--foreground: oklch(0.145 0 0);
|
|
@@ -104,6 +105,7 @@ TODO: Fix that latter to have reference by the package names
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
.dark {
|
|
108
|
+
color-scheme: dark;
|
|
107
109
|
--background: oklch(0.145 0 0);
|
|
108
110
|
--foreground: oklch(0.985 0 0);
|
|
109
111
|
--card: oklch(0.205 0 0);
|
|
@@ -155,6 +157,11 @@ TODO: Fix that latter to have reference by the package names
|
|
|
155
157
|
body {
|
|
156
158
|
@apply bg-background text-foreground;
|
|
157
159
|
}
|
|
160
|
+
select,
|
|
161
|
+
select option {
|
|
162
|
+
background-color: var(--background);
|
|
163
|
+
color: var(--foreground);
|
|
164
|
+
}
|
|
158
165
|
}
|
|
159
166
|
|
|
160
167
|
.rbc-calendar {
|
|
@@ -46,6 +46,8 @@ import { analyticsModuleConfigs } from '@/.mercato/generated/analytics.generated
|
|
|
46
46
|
import { enricherEntries } from '@/.mercato/generated/enrichers.generated'
|
|
47
47
|
import { interceptorEntries } from '@/.mercato/generated/interceptors.generated'
|
|
48
48
|
import { componentOverrideEntries } from '@/.mercato/generated/component-overrides.generated'
|
|
49
|
+
import { guardEntries } from '@/.mercato/generated/guards.generated'
|
|
50
|
+
import { commandInterceptorEntries } from '@/.mercato/generated/command-interceptors.generated'
|
|
49
51
|
import { messageTypes } from '@/.mercato/generated/message-types.generated'
|
|
50
52
|
import { messageObjectTypes } from '@/.mercato/generated/message-objects.generated'
|
|
51
53
|
import { registerMessageTypes } from '@open-mercato/core/modules/messages/lib/message-types-registry'
|
|
@@ -74,6 +76,8 @@ export const bootstrap = createBootstrap({
|
|
|
74
76
|
enricherEntries,
|
|
75
77
|
interceptorEntries,
|
|
76
78
|
componentOverrideEntries,
|
|
79
|
+
guardEntries,
|
|
80
|
+
commandInterceptorEntries,
|
|
77
81
|
})
|
|
78
82
|
|
|
79
83
|
export { isBootstrapped }
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TC-UMES-006: Mutation Lifecycle — API Integration Tests (Phase M)
|
|
3
|
+
*
|
|
4
|
+
* Validates mutation guard registry (m1), sync event subscribers (m2),
|
|
5
|
+
* and command interceptors (m4) through API-level tests against the
|
|
6
|
+
* example todo CRUD endpoints.
|
|
7
|
+
*
|
|
8
|
+
* Spec reference: SPEC-041m — Mutation Lifecycle Hooks
|
|
9
|
+
* Source scenarios:
|
|
10
|
+
* - .ai/qa/scenarios/TC-UMES-ML01-guard-registry-allows-valid-create.md
|
|
11
|
+
* - .ai/qa/scenarios/TC-UMES-ML02-sync-subscriber-auto-default-priority.md
|
|
12
|
+
* - .ai/qa/scenarios/TC-UMES-ML03-sync-subscriber-blocks-uncomplete.md
|
|
13
|
+
* - .ai/qa/scenarios/TC-UMES-ML04-sync-subscriber-audit-delete.md
|
|
14
|
+
* - .ai/qa/scenarios/TC-UMES-ML07-command-interceptor-customer-audit.md
|
|
15
|
+
* - .ai/qa/scenarios/TC-UMES-ML08-sync-before-update-with-previous-data.md
|
|
16
|
+
* - .ai/qa/scenarios/TC-UMES-ML09-sync-before-delete-blocks-operation.md
|
|
17
|
+
* - .ai/qa/scenarios/TC-UMES-ML10-full-mutation-lifecycle-e2e.md
|
|
18
|
+
*/
|
|
19
|
+
import { test, expect } from '@playwright/test'
|
|
20
|
+
import {
|
|
21
|
+
getAuthToken,
|
|
22
|
+
apiRequest,
|
|
23
|
+
} from '@open-mercato/core/modules/core/__integration__/helpers/api'
|
|
24
|
+
import {
|
|
25
|
+
createCompanyFixture,
|
|
26
|
+
deleteEntityIfExists,
|
|
27
|
+
} from '@open-mercato/core/modules/core/__integration__/helpers/crmFixtures'
|
|
28
|
+
|
|
29
|
+
async function createTodo(
|
|
30
|
+
request: Parameters<typeof apiRequest>[0],
|
|
31
|
+
token: string,
|
|
32
|
+
data: Record<string, unknown>,
|
|
33
|
+
): Promise<{ id: string; [key: string]: unknown }> {
|
|
34
|
+
const response = await apiRequest(request, 'POST', '/api/example/todos', {
|
|
35
|
+
token,
|
|
36
|
+
data,
|
|
37
|
+
})
|
|
38
|
+
expect(response.ok()).toBeTruthy()
|
|
39
|
+
expect(response.status()).toBe(201)
|
|
40
|
+
const body = await response.json()
|
|
41
|
+
expect(typeof body.id).toBe('string')
|
|
42
|
+
return body
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function deleteTodo(
|
|
46
|
+
request: Parameters<typeof apiRequest>[0],
|
|
47
|
+
token: string,
|
|
48
|
+
id: string | null,
|
|
49
|
+
) {
|
|
50
|
+
if (!id) return
|
|
51
|
+
try {
|
|
52
|
+
await apiRequest(request, 'DELETE', `/api/example/todos?id=${encodeURIComponent(id)}`, {
|
|
53
|
+
token,
|
|
54
|
+
})
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore cleanup failure
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function updateTodo(
|
|
61
|
+
request: Parameters<typeof apiRequest>[0],
|
|
62
|
+
token: string,
|
|
63
|
+
data: Record<string, unknown>,
|
|
64
|
+
) {
|
|
65
|
+
return apiRequest(request, 'PUT', '/api/example/todos', {
|
|
66
|
+
token,
|
|
67
|
+
data,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test.describe('TC-UMES-006: Mutation Lifecycle — API Tests', () => {
|
|
72
|
+
let adminToken = ''
|
|
73
|
+
|
|
74
|
+
test.beforeAll(async ({ request }) => {
|
|
75
|
+
adminToken = await getAuthToken(request, 'admin')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ── Phase m1: Mutation Guard Registry ──────────────────────────────
|
|
79
|
+
|
|
80
|
+
test('TC-UMES-ML01: guard registry allows valid todo creation', async ({
|
|
81
|
+
request,
|
|
82
|
+
}) => {
|
|
83
|
+
let todoId: string | null = null
|
|
84
|
+
try {
|
|
85
|
+
const body = await createTodo(request, adminToken, {
|
|
86
|
+
title: `ML01-guard-test-${Date.now()}`,
|
|
87
|
+
})
|
|
88
|
+
todoId = body.id
|
|
89
|
+
|
|
90
|
+
// Verify the entity exists via GET
|
|
91
|
+
const getResponse = await apiRequest(
|
|
92
|
+
request,
|
|
93
|
+
'GET',
|
|
94
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
95
|
+
{ token: adminToken },
|
|
96
|
+
)
|
|
97
|
+
expect(getResponse.ok()).toBeTruthy()
|
|
98
|
+
} finally {
|
|
99
|
+
await deleteTodo(request, adminToken, todoId)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('TC-UMES-ML01b: creating todo without auth returns 401', async ({
|
|
104
|
+
request,
|
|
105
|
+
}) => {
|
|
106
|
+
const response = await apiRequest(request, 'POST', '/api/example/todos', {
|
|
107
|
+
token: 'invalid-token',
|
|
108
|
+
data: { title: 'should-fail' },
|
|
109
|
+
})
|
|
110
|
+
expect(response.status()).toBe(401)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ── Phase m2: Sync Event Subscribers ───────────────────────────────
|
|
114
|
+
|
|
115
|
+
test('TC-UMES-ML02: auto-default-priority subscriber runs on create without blocking', async ({
|
|
116
|
+
request,
|
|
117
|
+
}) => {
|
|
118
|
+
let todoId: string | null = null
|
|
119
|
+
try {
|
|
120
|
+
// Create without priority field — sync before-create subscriber fires and
|
|
121
|
+
// injects modifiedPayload. The subscriber mechanism works (201 returned),
|
|
122
|
+
// although the bare `priority` key is not persisted as a custom field
|
|
123
|
+
// (would need `cf_priority` prefix for custom field routing).
|
|
124
|
+
const body = await createTodo(request, adminToken, {
|
|
125
|
+
title: `ML02-no-priority-${Date.now()}`,
|
|
126
|
+
})
|
|
127
|
+
todoId = body.id
|
|
128
|
+
|
|
129
|
+
// Verify the todo was created and is fetchable
|
|
130
|
+
const getResponse = await apiRequest(
|
|
131
|
+
request,
|
|
132
|
+
'GET',
|
|
133
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
134
|
+
{ token: adminToken },
|
|
135
|
+
)
|
|
136
|
+
expect(getResponse.ok()).toBeTruthy()
|
|
137
|
+
const payload = await getResponse.json()
|
|
138
|
+
const items = payload?.items ?? payload?.data ?? []
|
|
139
|
+
const todo = items.find((item: Record<string, unknown>) => item.id === todoId)
|
|
140
|
+
expect(todo).toBeDefined()
|
|
141
|
+
expect(todo.title).toContain('ML02-no-priority')
|
|
142
|
+
} finally {
|
|
143
|
+
await deleteTodo(request, adminToken, todoId)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('TC-UMES-ML02b: creating with extra fields in body does not break pipeline', async ({
|
|
148
|
+
request,
|
|
149
|
+
}) => {
|
|
150
|
+
let todoId: string | null = null
|
|
151
|
+
try {
|
|
152
|
+
// Send extra fields — the passthrough schema accepts them and the
|
|
153
|
+
// subscriber checks for `priority` in body (finds it, so returns void)
|
|
154
|
+
const body = await createTodo(request, adminToken, {
|
|
155
|
+
title: `ML02b-with-priority-${Date.now()}`,
|
|
156
|
+
priority: 'high',
|
|
157
|
+
})
|
|
158
|
+
todoId = body.id
|
|
159
|
+
|
|
160
|
+
// Verify creation succeeded through the full pipeline
|
|
161
|
+
const getResponse = await apiRequest(
|
|
162
|
+
request,
|
|
163
|
+
'GET',
|
|
164
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
165
|
+
{ token: adminToken },
|
|
166
|
+
)
|
|
167
|
+
expect(getResponse.ok()).toBeTruthy()
|
|
168
|
+
const payload = await getResponse.json()
|
|
169
|
+
const items = payload?.items ?? payload?.data ?? []
|
|
170
|
+
const todo = items.find((item: Record<string, unknown>) => item.id === todoId)
|
|
171
|
+
expect(todo).toBeDefined()
|
|
172
|
+
expect(todo.title).toContain('ML02b-with-priority')
|
|
173
|
+
} finally {
|
|
174
|
+
await deleteTodo(request, adminToken, todoId)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('TC-UMES-ML03: prevent-uncomplete blocks reverting completed todo with 422', async ({
|
|
179
|
+
request,
|
|
180
|
+
}) => {
|
|
181
|
+
let todoId: string | null = null
|
|
182
|
+
try {
|
|
183
|
+
const body = await createTodo(request, adminToken, {
|
|
184
|
+
title: `ML03-block-test-${Date.now()}`,
|
|
185
|
+
})
|
|
186
|
+
todoId = body.id
|
|
187
|
+
|
|
188
|
+
// Mark as done
|
|
189
|
+
const markDoneResponse = await updateTodo(request, adminToken, {
|
|
190
|
+
id: todoId,
|
|
191
|
+
is_done: true,
|
|
192
|
+
})
|
|
193
|
+
expect(markDoneResponse.ok()).toBeTruthy()
|
|
194
|
+
|
|
195
|
+
// Attempt to revert — should be blocked
|
|
196
|
+
const revertResponse = await updateTodo(request, adminToken, {
|
|
197
|
+
id: todoId,
|
|
198
|
+
is_done: false,
|
|
199
|
+
})
|
|
200
|
+
expect(revertResponse.status()).toBe(422)
|
|
201
|
+
const revertBody = await revertResponse.json()
|
|
202
|
+
expect(revertBody.error).toContain('Completed todos cannot be reverted to pending')
|
|
203
|
+
} finally {
|
|
204
|
+
await deleteTodo(request, adminToken, todoId)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('TC-UMES-ML03b: updating non-done todo title succeeds freely', async ({
|
|
209
|
+
request,
|
|
210
|
+
}) => {
|
|
211
|
+
let todoId: string | null = null
|
|
212
|
+
try {
|
|
213
|
+
const body = await createTodo(request, adminToken, {
|
|
214
|
+
title: `ML03b-title-only-${Date.now()}`,
|
|
215
|
+
})
|
|
216
|
+
todoId = body.id
|
|
217
|
+
|
|
218
|
+
// Update title without touching is_done
|
|
219
|
+
const updateResponse = await updateTodo(request, adminToken, {
|
|
220
|
+
id: todoId,
|
|
221
|
+
title: `ML03b-updated-${Date.now()}`,
|
|
222
|
+
})
|
|
223
|
+
expect(updateResponse.ok()).toBeTruthy()
|
|
224
|
+
} finally {
|
|
225
|
+
await deleteTodo(request, adminToken, todoId)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('TC-UMES-ML04: audit-delete subscriber fires without errors on deletion', async ({
|
|
230
|
+
request,
|
|
231
|
+
}) => {
|
|
232
|
+
// Create a todo
|
|
233
|
+
const body = await createTodo(request, adminToken, {
|
|
234
|
+
title: `ML04-delete-audit-${Date.now()}`,
|
|
235
|
+
})
|
|
236
|
+
const todoId = body.id
|
|
237
|
+
|
|
238
|
+
// Delete — after-delete subscriber should run without causing errors
|
|
239
|
+
const deleteResponse = await apiRequest(
|
|
240
|
+
request,
|
|
241
|
+
'DELETE',
|
|
242
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
243
|
+
{ token: adminToken },
|
|
244
|
+
)
|
|
245
|
+
expect(deleteResponse.ok()).toBeTruthy()
|
|
246
|
+
|
|
247
|
+
// Verify todo is gone
|
|
248
|
+
const getResponse = await apiRequest(
|
|
249
|
+
request,
|
|
250
|
+
'GET',
|
|
251
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
252
|
+
{ token: adminToken },
|
|
253
|
+
)
|
|
254
|
+
expect(getResponse.ok()).toBeTruthy()
|
|
255
|
+
const payload = await getResponse.json()
|
|
256
|
+
const items = payload?.items ?? payload?.data ?? []
|
|
257
|
+
const found = items.find((item: Record<string, unknown>) => item.id === todoId)
|
|
258
|
+
expect(found).toBeUndefined()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// ── Phase m2: previousData in before-update ────────────────────────
|
|
262
|
+
|
|
263
|
+
test('TC-UMES-ML08: before-update subscriber receives previousData and uses it for comparison', async ({
|
|
264
|
+
request,
|
|
265
|
+
}) => {
|
|
266
|
+
let todoId: string | null = null
|
|
267
|
+
try {
|
|
268
|
+
const body = await createTodo(request, adminToken, {
|
|
269
|
+
title: `ML08-previous-data-${Date.now()}`,
|
|
270
|
+
})
|
|
271
|
+
todoId = body.id
|
|
272
|
+
|
|
273
|
+
// Step 1: Update title only — should succeed (is_done not changing)
|
|
274
|
+
const titleUpdate = await updateTodo(request, adminToken, {
|
|
275
|
+
id: todoId,
|
|
276
|
+
title: `ML08-updated-title-${Date.now()}`,
|
|
277
|
+
})
|
|
278
|
+
expect(titleUpdate.ok()).toBeTruthy()
|
|
279
|
+
|
|
280
|
+
// Step 2: Mark as done — transition from false → true is allowed
|
|
281
|
+
const markDone = await updateTodo(request, adminToken, {
|
|
282
|
+
id: todoId,
|
|
283
|
+
is_done: true,
|
|
284
|
+
})
|
|
285
|
+
expect(markDone.ok()).toBeTruthy()
|
|
286
|
+
|
|
287
|
+
// Step 3: Update title while done — should succeed (not changing is_done)
|
|
288
|
+
const titleWhileDone = await updateTodo(request, adminToken, {
|
|
289
|
+
id: todoId,
|
|
290
|
+
title: `ML08-still-done-${Date.now()}`,
|
|
291
|
+
})
|
|
292
|
+
expect(titleWhileDone.ok()).toBeTruthy()
|
|
293
|
+
|
|
294
|
+
// Step 4: Try to revert — should be blocked because previousData shows is_done=true
|
|
295
|
+
const revert = await updateTodo(request, adminToken, {
|
|
296
|
+
id: todoId,
|
|
297
|
+
is_done: false,
|
|
298
|
+
})
|
|
299
|
+
expect(revert.status()).toBe(422)
|
|
300
|
+
} finally {
|
|
301
|
+
await deleteTodo(request, adminToken, todoId)
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// ── Phase m2: before-delete pipeline ───────────────────────────────
|
|
306
|
+
|
|
307
|
+
test('TC-UMES-ML09: before-delete event is wired and deletion completes through full pipeline', async ({
|
|
308
|
+
request,
|
|
309
|
+
}) => {
|
|
310
|
+
const body = await createTodo(request, adminToken, {
|
|
311
|
+
title: `ML09-delete-pipeline-${Date.now()}`,
|
|
312
|
+
})
|
|
313
|
+
const todoId = body.id
|
|
314
|
+
|
|
315
|
+
// Delete through the full pipeline (*.deleting → mutation → *.deleted)
|
|
316
|
+
const deleteResponse = await apiRequest(
|
|
317
|
+
request,
|
|
318
|
+
'DELETE',
|
|
319
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
320
|
+
{ token: adminToken },
|
|
321
|
+
)
|
|
322
|
+
expect(deleteResponse.ok()).toBeTruthy()
|
|
323
|
+
|
|
324
|
+
// Verify soft-deleted
|
|
325
|
+
const verifyResponse = await apiRequest(
|
|
326
|
+
request,
|
|
327
|
+
'GET',
|
|
328
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
329
|
+
{ token: adminToken },
|
|
330
|
+
)
|
|
331
|
+
expect(verifyResponse.ok()).toBeTruthy()
|
|
332
|
+
const payload = await verifyResponse.json()
|
|
333
|
+
const items = payload?.items ?? payload?.data ?? []
|
|
334
|
+
expect(items.find((item: Record<string, unknown>) => item.id === todoId)).toBeUndefined()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// ── Phase m4: Command Interceptors ─────────────────────────────────
|
|
338
|
+
|
|
339
|
+
test('TC-UMES-ML07: command interceptor runs on customer operations without blocking', async ({
|
|
340
|
+
request,
|
|
341
|
+
}) => {
|
|
342
|
+
let companyId: string | null = null
|
|
343
|
+
try {
|
|
344
|
+
// Create a company — interceptor targets customers.*
|
|
345
|
+
companyId = await createCompanyFixture(request, adminToken, `ML07-Interceptor-${Date.now()}`)
|
|
346
|
+
expect(typeof companyId).toBe('string')
|
|
347
|
+
|
|
348
|
+
// Update the company — interceptor should observe but not block
|
|
349
|
+
const updateResponse = await apiRequest(
|
|
350
|
+
request,
|
|
351
|
+
'PUT',
|
|
352
|
+
'/api/customers/companies',
|
|
353
|
+
{
|
|
354
|
+
token: adminToken,
|
|
355
|
+
data: { id: companyId, name: `ML07-Interceptor-Updated-${Date.now()}` },
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
expect(updateResponse.ok()).toBeTruthy()
|
|
359
|
+
} finally {
|
|
360
|
+
await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId)
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
// ── Full E2E Lifecycle ─────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
test('TC-UMES-ML10: full mutation lifecycle — create → update → block → delete', async ({
|
|
367
|
+
request,
|
|
368
|
+
}) => {
|
|
369
|
+
let todoId: string | null = null
|
|
370
|
+
try {
|
|
371
|
+
// Step 1: Create — guard passes, sync before-create subscriber fires
|
|
372
|
+
const created = await createTodo(request, adminToken, {
|
|
373
|
+
title: `ML10-e2e-lifecycle-${Date.now()}`,
|
|
374
|
+
})
|
|
375
|
+
todoId = created.id
|
|
376
|
+
|
|
377
|
+
// Step 2: Verify todo exists
|
|
378
|
+
const getAfterCreate = await apiRequest(
|
|
379
|
+
request,
|
|
380
|
+
'GET',
|
|
381
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
382
|
+
{ token: adminToken },
|
|
383
|
+
)
|
|
384
|
+
expect(getAfterCreate.ok()).toBeTruthy()
|
|
385
|
+
const afterCreatePayload = await getAfterCreate.json()
|
|
386
|
+
const afterCreateItems = afterCreatePayload?.items ?? afterCreatePayload?.data ?? []
|
|
387
|
+
const afterCreate = afterCreateItems.find(
|
|
388
|
+
(item: Record<string, unknown>) => item.id === todoId,
|
|
389
|
+
)
|
|
390
|
+
expect(afterCreate).toBeDefined()
|
|
391
|
+
expect(afterCreate.title).toContain('ML10-e2e-lifecycle')
|
|
392
|
+
|
|
393
|
+
// Step 3: Update title — should succeed
|
|
394
|
+
const titleUpdate = await updateTodo(request, adminToken, {
|
|
395
|
+
id: todoId,
|
|
396
|
+
title: `ML10-e2e-updated-${Date.now()}`,
|
|
397
|
+
})
|
|
398
|
+
expect(titleUpdate.ok()).toBeTruthy()
|
|
399
|
+
|
|
400
|
+
// Step 4: Mark as done
|
|
401
|
+
const markDone = await updateTodo(request, adminToken, {
|
|
402
|
+
id: todoId,
|
|
403
|
+
is_done: true,
|
|
404
|
+
})
|
|
405
|
+
expect(markDone.ok()).toBeTruthy()
|
|
406
|
+
|
|
407
|
+
// Step 5: Attempt revert — should be blocked by prevent-uncomplete
|
|
408
|
+
const revert = await updateTodo(request, adminToken, {
|
|
409
|
+
id: todoId,
|
|
410
|
+
is_done: false,
|
|
411
|
+
})
|
|
412
|
+
expect(revert.status()).toBe(422)
|
|
413
|
+
const revertBody = await revert.json()
|
|
414
|
+
expect(revertBody.error).toContain('Completed todos cannot be reverted to pending')
|
|
415
|
+
|
|
416
|
+
// Step 6: Verify todo is still done
|
|
417
|
+
const getAfterRevert = await apiRequest(
|
|
418
|
+
request,
|
|
419
|
+
'GET',
|
|
420
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
421
|
+
{ token: adminToken },
|
|
422
|
+
)
|
|
423
|
+
expect(getAfterRevert.ok()).toBeTruthy()
|
|
424
|
+
const afterRevertPayload = await getAfterRevert.json()
|
|
425
|
+
const afterRevertItems = afterRevertPayload?.items ?? afterRevertPayload?.data ?? []
|
|
426
|
+
const afterRevert = afterRevertItems.find(
|
|
427
|
+
(item: Record<string, unknown>) => item.id === todoId,
|
|
428
|
+
)
|
|
429
|
+
expect(afterRevert).toBeDefined()
|
|
430
|
+
expect(afterRevert.is_done).toBe(true)
|
|
431
|
+
|
|
432
|
+
// Step 7: Delete — audit-delete fires
|
|
433
|
+
const deleteResponse = await apiRequest(
|
|
434
|
+
request,
|
|
435
|
+
'DELETE',
|
|
436
|
+
`/api/example/todos?id=${encodeURIComponent(todoId)}`,
|
|
437
|
+
{ token: adminToken },
|
|
438
|
+
)
|
|
439
|
+
expect(deleteResponse.ok()).toBeTruthy()
|
|
440
|
+
todoId = null // cleaned up
|
|
441
|
+
|
|
442
|
+
// Step 8: Verify gone
|
|
443
|
+
if (afterRevert?.id) {
|
|
444
|
+
const verifyGone = await apiRequest(
|
|
445
|
+
request,
|
|
446
|
+
'GET',
|
|
447
|
+
`/api/example/todos?id=${encodeURIComponent(afterRevert.id)}`,
|
|
448
|
+
{ token: adminToken },
|
|
449
|
+
)
|
|
450
|
+
expect(verifyGone.ok()).toBeTruthy()
|
|
451
|
+
const gonePayload = await verifyGone.json()
|
|
452
|
+
const goneItems = gonePayload?.items ?? gonePayload?.data ?? []
|
|
453
|
+
expect(
|
|
454
|
+
goneItems.find((item: Record<string, unknown>) => item.id === afterRevert.id),
|
|
455
|
+
).toBeUndefined()
|
|
456
|
+
}
|
|
457
|
+
} finally {
|
|
458
|
+
await deleteTodo(request, adminToken, todoId)
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TC-UMES-007: Mutation Lifecycle — UI Showcase Page Tests (Phase M)
|
|
3
|
+
*
|
|
4
|
+
* Validates the Phase M showcase page at /backend/mutation-lifecycle.
|
|
5
|
+
* Exercises the guard probe (m1) and sync subscriber probe (m2) through
|
|
6
|
+
* the interactive UI, verifying status updates, probe results, and
|
|
7
|
+
* error handling via data-testid attributes.
|
|
8
|
+
*
|
|
9
|
+
* Spec reference: SPEC-041m — Mutation Lifecycle Hooks
|
|
10
|
+
* Source scenarios:
|
|
11
|
+
* - .ai/qa/scenarios/TC-UMES-ML05-showcase-page-guard-probe.md
|
|
12
|
+
* - .ai/qa/scenarios/TC-UMES-ML06-showcase-page-sync-probe.md
|
|
13
|
+
*/
|
|
14
|
+
import { test, expect } from '@playwright/test'
|
|
15
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'
|
|
16
|
+
|
|
17
|
+
test.describe('TC-UMES-007: Mutation Lifecycle — Showcase Page', () => {
|
|
18
|
+
test.beforeEach(async ({ page }) => {
|
|
19
|
+
await login(page, 'admin')
|
|
20
|
+
await page.goto('/backend/mutation-lifecycle')
|
|
21
|
+
await page.waitForLoadState('domcontentloaded')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('TC-UMES-ML05a: showcase page renders all four phase sections', async ({
|
|
25
|
+
page,
|
|
26
|
+
}) => {
|
|
27
|
+
// Verify all 4 phase sections are visible
|
|
28
|
+
await expect(page.getByText('Phase m1 — Mutation Guard Registry')).toBeVisible()
|
|
29
|
+
await expect(page.getByText('Phase m2 — Sync Event Subscribers')).toBeVisible()
|
|
30
|
+
await expect(page.getByText('Phase m3 — Client-Side Event Filtering')).toBeVisible()
|
|
31
|
+
await expect(page.getByText('Phase m4 — Command Interceptors')).toBeVisible()
|
|
32
|
+
|
|
33
|
+
// Verify initial status is idle for interactive probes
|
|
34
|
+
await expect(page.getByTestId('phase-m-status-m1')).toContainText('phaseM1=idle')
|
|
35
|
+
await expect(page.getByTestId('phase-m-status-m2')).toContainText('phaseM2=idle')
|
|
36
|
+
|
|
37
|
+
// m3 and m4 are informational — show ok
|
|
38
|
+
await expect(page.getByTestId('phase-m-status-m3')).toContainText('phaseM3=ok')
|
|
39
|
+
await expect(page.getByTestId('phase-m-status-m4')).toContainText('phaseM4=ok')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('TC-UMES-ML05b: guard probe creates and cleans up a todo successfully', async ({
|
|
43
|
+
page,
|
|
44
|
+
}) => {
|
|
45
|
+
// Click the guard probe button
|
|
46
|
+
await page.getByTestId('phase-m1-run-probe').click()
|
|
47
|
+
|
|
48
|
+
// Wait for completion
|
|
49
|
+
await expect(page.getByTestId('phase-m1-status')).toContainText('status=ok', {
|
|
50
|
+
timeout: 15_000,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Verify result contains an id (todo was created)
|
|
54
|
+
const resultText = await page.getByTestId('phase-m1-result').textContent()
|
|
55
|
+
expect(resultText).toContain('"id"')
|
|
56
|
+
|
|
57
|
+
// Verify no error is displayed
|
|
58
|
+
await expect(page.getByTestId('phase-m1-error')).not.toBeVisible()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('TC-UMES-ML05c: guard probe is idempotent — runs twice with same result', async ({
|
|
62
|
+
page,
|
|
63
|
+
}) => {
|
|
64
|
+
// First run
|
|
65
|
+
await page.getByTestId('phase-m1-run-probe').click()
|
|
66
|
+
await expect(page.getByTestId('phase-m1-status')).toContainText('status=ok', {
|
|
67
|
+
timeout: 15_000,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Second run
|
|
71
|
+
await page.getByTestId('phase-m1-run-probe').click()
|
|
72
|
+
await expect(page.getByTestId('phase-m1-status')).toContainText('status=ok', {
|
|
73
|
+
timeout: 15_000,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await expect(page.getByTestId('phase-m1-error')).not.toBeVisible()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('TC-UMES-ML06a: sync subscriber probe — all three probes pass', async ({
|
|
80
|
+
page,
|
|
81
|
+
}) => {
|
|
82
|
+
// Click the sync probe button
|
|
83
|
+
await page.getByTestId('phase-m2-run-probe').click()
|
|
84
|
+
|
|
85
|
+
// Wait for overall status to resolve
|
|
86
|
+
await expect(page.getByTestId('phase-m2-status')).toContainText('status=ok', {
|
|
87
|
+
timeout: 20_000,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Verify no error displayed
|
|
91
|
+
await expect(page.getByTestId('phase-m2-error')).not.toBeVisible()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('TC-UMES-ML06b: sync probe — defaultPriority reports ok with 201', async ({
|
|
95
|
+
page,
|
|
96
|
+
}) => {
|
|
97
|
+
await page.getByTestId('phase-m2-run-probe').click()
|
|
98
|
+
|
|
99
|
+
// Wait for completion
|
|
100
|
+
await expect(page.getByTestId('phase-m2-status')).not.toContainText('status=pending', {
|
|
101
|
+
timeout: 20_000,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Check individual probe result
|
|
105
|
+
const defaultPriority = page.getByTestId('phase-m2-probe-defaultPriority')
|
|
106
|
+
await expect(defaultPriority).toContainText('status=ok')
|
|
107
|
+
await expect(defaultPriority).toContainText('httpStatus=201')
|
|
108
|
+
await expect(defaultPriority).toContainText('auto-default-priority')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('TC-UMES-ML06c: sync probe — preventUncomplete reports ok with 422', async ({
|
|
112
|
+
page,
|
|
113
|
+
}) => {
|
|
114
|
+
await page.getByTestId('phase-m2-run-probe').click()
|
|
115
|
+
|
|
116
|
+
await expect(page.getByTestId('phase-m2-status')).not.toContainText('status=pending', {
|
|
117
|
+
timeout: 20_000,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const preventUncomplete = page.getByTestId('phase-m2-probe-preventUncomplete')
|
|
121
|
+
await expect(preventUncomplete).toContainText('status=ok')
|
|
122
|
+
await expect(preventUncomplete).toContainText('httpStatus=422')
|
|
123
|
+
await expect(preventUncomplete).toContainText('prevent-uncomplete')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('TC-UMES-ML06d: sync probe — auditDelete reports ok with 200', async ({
|
|
127
|
+
page,
|
|
128
|
+
}) => {
|
|
129
|
+
await page.getByTestId('phase-m2-run-probe').click()
|
|
130
|
+
|
|
131
|
+
await expect(page.getByTestId('phase-m2-status')).not.toContainText('status=pending', {
|
|
132
|
+
timeout: 20_000,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const auditDelete = page.getByTestId('phase-m2-probe-auditDelete')
|
|
136
|
+
await expect(auditDelete).toContainText('status=ok')
|
|
137
|
+
await expect(auditDelete).toContainText('httpStatus=200')
|
|
138
|
+
await expect(auditDelete).toContainText('audit-delete')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('TC-UMES-ML06e: sync probe — payloads section shows all step data', async ({
|
|
142
|
+
page,
|
|
143
|
+
}) => {
|
|
144
|
+
await page.getByTestId('phase-m2-run-probe').click()
|
|
145
|
+
|
|
146
|
+
await expect(page.getByTestId('phase-m2-status')).toContainText('status=ok', {
|
|
147
|
+
timeout: 20_000,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// The result section should contain payloads from all steps
|
|
151
|
+
const resultText = await page.getByTestId('phase-m2-result').textContent()
|
|
152
|
+
expect(resultText).toContain('"create"')
|
|
153
|
+
expect(resultText).toContain('"markDone"')
|
|
154
|
+
expect(resultText).toContain('"revert"')
|
|
155
|
+
expect(resultText).toContain('"delete"')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ── Navigation links ───────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
test('TC-UMES-ML-nav: phase m3 link navigates to CrudForm injection page', async ({
|
|
161
|
+
page,
|
|
162
|
+
}) => {
|
|
163
|
+
const phaseGLink = page.getByRole('link', { name: /Open Phase G/i })
|
|
164
|
+
await expect(phaseGLink).toBeVisible()
|
|
165
|
+
await expect(phaseGLink).toHaveAttribute('href', '/backend/umes-extensions')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('TC-UMES-ML-nav2: phase m4 link navigates to customers page', async ({
|
|
169
|
+
page,
|
|
170
|
+
}) => {
|
|
171
|
+
const customersLink = page.getByRole('link', { name: /Open customers/i })
|
|
172
|
+
await expect(customersLink).toBeVisible()
|
|
173
|
+
await expect(customersLink).toHaveAttribute('href', '/backend/customers/people')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const shieldIcon = React.createElement(
|
|
4
|
+
'svg',
|
|
5
|
+
{ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
|
|
6
|
+
React.createElement('path', { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }),
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
export const metadata = {
|
|
10
|
+
requireAuth: true,
|
|
11
|
+
requireFeatures: ['example.todos.view'],
|
|
12
|
+
pageTitle: 'Phase M — Mutation Lifecycle',
|
|
13
|
+
pageTitleKey: 'example.menu.mutationLifecycle',
|
|
14
|
+
pageGroup: 'Example',
|
|
15
|
+
pageGroupKey: 'example.nav.group',
|
|
16
|
+
pageOrder: 20700,
|
|
17
|
+
icon: shieldIcon,
|
|
18
|
+
breadcrumb: [
|
|
19
|
+
{ label: 'General tasks', labelKey: 'example.todos.page.title', href: '/backend/todos' },
|
|
20
|
+
{ label: 'Phase M — Mutation Lifecycle', labelKey: 'example.mutationLifecycle.title' },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default metadata
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
6
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
9
|
+
|
|
10
|
+
type PhaseStatus = 'idle' | 'pending' | 'ok' | 'error'
|
|
11
|
+
|
|
12
|
+
type SyncProbeKey = 'defaultPriority' | 'preventUncomplete' | 'auditDelete'
|
|
13
|
+
|
|
14
|
+
type SyncProbeResult = {
|
|
15
|
+
key: SyncProbeKey
|
|
16
|
+
label: string
|
|
17
|
+
status: PhaseStatus
|
|
18
|
+
httpStatus: number | null
|
|
19
|
+
ok: boolean
|
|
20
|
+
details: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const syncProbeOrder: SyncProbeKey[] = ['defaultPriority', 'preventUncomplete', 'auditDelete']
|
|
24
|
+
const hintClassName = 'rounded-md border border-amber-500/40 bg-amber-50 dark:bg-amber-400/10 p-2 text-xs text-amber-800 dark:text-amber-100/90'
|
|
25
|
+
|
|
26
|
+
function print(value: unknown): string {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(value ?? null, null, 2)
|
|
29
|
+
} catch {
|
|
30
|
+
return String(value)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createSyncProbeResult(partial?: Partial<SyncProbeResult>): SyncProbeResult {
|
|
35
|
+
return {
|
|
36
|
+
key: partial?.key ?? 'defaultPriority',
|
|
37
|
+
label: partial?.label ?? '',
|
|
38
|
+
status: partial?.status ?? 'idle',
|
|
39
|
+
httpStatus: partial?.httpStatus ?? null,
|
|
40
|
+
ok: partial?.ok ?? false,
|
|
41
|
+
details: partial?.details ?? '',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function MutationLifecyclePage() {
|
|
46
|
+
const t = useT()
|
|
47
|
+
|
|
48
|
+
// Phase m1 state
|
|
49
|
+
const [guardStatus, setGuardStatus] = React.useState<PhaseStatus>('idle')
|
|
50
|
+
const [guardPayload, setGuardPayload] = React.useState<unknown>(null)
|
|
51
|
+
const [guardError, setGuardError] = React.useState<string | null>(null)
|
|
52
|
+
|
|
53
|
+
// Phase m2 state
|
|
54
|
+
const [syncStatus, setSyncStatus] = React.useState<PhaseStatus>('idle')
|
|
55
|
+
const [syncError, setSyncError] = React.useState<string | null>(null)
|
|
56
|
+
const [syncPayloads, setSyncPayloads] = React.useState<unknown>(null)
|
|
57
|
+
const [syncProbeResults, setSyncProbeResults] = React.useState<Record<SyncProbeKey, SyncProbeResult>>({
|
|
58
|
+
defaultPriority: createSyncProbeResult({ key: 'defaultPriority', label: t('example.mutationLifecycle.m2.probe.defaultPriority.label', 'auto-default-priority (before-create)') }),
|
|
59
|
+
preventUncomplete: createSyncProbeResult({ key: 'preventUncomplete', label: t('example.mutationLifecycle.m2.probe.preventUncomplete.label', 'prevent-uncomplete (before-update)') }),
|
|
60
|
+
auditDelete: createSyncProbeResult({ key: 'auditDelete', label: t('example.mutationLifecycle.m2.probe.auditDelete.label', 'audit-delete (after-delete)') }),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Phase m3/m4 are informational
|
|
64
|
+
const phaseM3Status = React.useMemo<PhaseStatus>(() => 'ok', [])
|
|
65
|
+
const phaseM4Status = React.useMemo<PhaseStatus>(() => 'ok', [])
|
|
66
|
+
|
|
67
|
+
// ── Phase m1: Mutation Guard Probe ──────────────────────────────
|
|
68
|
+
const runGuardProbe = React.useCallback(async () => {
|
|
69
|
+
setGuardStatus('pending')
|
|
70
|
+
setGuardError(null)
|
|
71
|
+
setGuardPayload(null)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// POST a todo — the guard pipeline runs on create
|
|
75
|
+
const createResponse = await apiCall<{ id: string }>('/api/example/todos', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ title: 'm1-guard-probe' }),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
setGuardPayload(createResponse.result)
|
|
82
|
+
|
|
83
|
+
if (!createResponse.ok) {
|
|
84
|
+
setGuardStatus('error')
|
|
85
|
+
setGuardError(
|
|
86
|
+
t(
|
|
87
|
+
'example.mutationLifecycle.m1.createFailed',
|
|
88
|
+
`Guard probe failed: expected 201, got ${createResponse.status}.`,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Clean up — delete the created todo
|
|
95
|
+
const todoId = (createResponse.result as { id?: string })?.id
|
|
96
|
+
if (todoId) {
|
|
97
|
+
await apiCall(`/api/example/todos?id=${todoId}`, { method: 'DELETE' })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setGuardStatus('ok')
|
|
101
|
+
} catch (error) {
|
|
102
|
+
setGuardStatus('error')
|
|
103
|
+
setGuardError(error instanceof Error ? error.message : String(error))
|
|
104
|
+
}
|
|
105
|
+
}, [t])
|
|
106
|
+
|
|
107
|
+
// ── Phase m2: Sync Subscriber Probe ─────────────────────────────
|
|
108
|
+
const runSyncProbe = React.useCallback(async () => {
|
|
109
|
+
setSyncStatus('pending')
|
|
110
|
+
setSyncError(null)
|
|
111
|
+
setSyncPayloads(null)
|
|
112
|
+
|
|
113
|
+
setSyncProbeResults((prev) => {
|
|
114
|
+
const next = { ...prev }
|
|
115
|
+
for (const key of syncProbeOrder) {
|
|
116
|
+
next[key] = { ...next[key], status: 'pending', httpStatus: null, ok: false, details: '' }
|
|
117
|
+
}
|
|
118
|
+
return next
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const nextResults: Record<SyncProbeKey, SyncProbeResult> = {
|
|
122
|
+
defaultPriority: createSyncProbeResult({ key: 'defaultPriority', label: t('example.mutationLifecycle.m2.probe.defaultPriority.label', 'auto-default-priority (before-create)'), status: 'pending' }),
|
|
123
|
+
preventUncomplete: createSyncProbeResult({ key: 'preventUncomplete', label: t('example.mutationLifecycle.m2.probe.preventUncomplete.label', 'prevent-uncomplete (before-update)'), status: 'pending' }),
|
|
124
|
+
auditDelete: createSyncProbeResult({ key: 'auditDelete', label: t('example.mutationLifecycle.m2.probe.auditDelete.label', 'audit-delete (after-delete)'), status: 'pending' }),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const payloads: Record<string, unknown> = {}
|
|
128
|
+
let createdTodoId: string | null = null
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// ── Probe 1: auto-default-priority (before-create) ──
|
|
132
|
+
const createResponse = await apiCall<{ id: string }>('/api/example/todos', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({ title: 'm2-sync-probe' }),
|
|
136
|
+
})
|
|
137
|
+
payloads.create = createResponse.result
|
|
138
|
+
const createOk = createResponse.ok && createResponse.status === 201
|
|
139
|
+
createdTodoId = createOk ? (createResponse.result as { id?: string })?.id ?? null : null
|
|
140
|
+
|
|
141
|
+
nextResults.defaultPriority = {
|
|
142
|
+
...nextResults.defaultPriority,
|
|
143
|
+
status: createOk ? 'ok' : 'error',
|
|
144
|
+
httpStatus: createResponse.status,
|
|
145
|
+
ok: createOk,
|
|
146
|
+
details: createOk
|
|
147
|
+
? t('example.mutationLifecycle.m2.probe.defaultPriority.ok', 'Todo created (201). Sync before-event example.todo.creating fired — auto-default-priority subscriber injects priority if absent.')
|
|
148
|
+
: t('example.mutationLifecycle.m2.probe.defaultPriority.error', `Expected 201, got ${createResponse.status}.`),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!createdTodoId) {
|
|
152
|
+
const skipMsg = t('example.mutationLifecycle.m2.probe.skipped', 'Skipped — no todo was created in probe 1.')
|
|
153
|
+
nextResults.preventUncomplete = {
|
|
154
|
+
...nextResults.preventUncomplete,
|
|
155
|
+
status: 'error',
|
|
156
|
+
details: skipMsg,
|
|
157
|
+
}
|
|
158
|
+
nextResults.auditDelete = {
|
|
159
|
+
...nextResults.auditDelete,
|
|
160
|
+
status: 'error',
|
|
161
|
+
details: skipMsg,
|
|
162
|
+
}
|
|
163
|
+
setSyncStatus('error')
|
|
164
|
+
setSyncError(t('example.mutationLifecycle.m2.createFailed', 'Could not create todo for sync subscriber probe.'))
|
|
165
|
+
setSyncPayloads(payloads)
|
|
166
|
+
setSyncProbeResults(nextResults)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Probe 2: prevent-uncomplete (before-update) ──
|
|
171
|
+
// Step 2a: Mark as done
|
|
172
|
+
const markDoneResponse = await apiCall('/api/example/todos', {
|
|
173
|
+
method: 'PUT',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' },
|
|
175
|
+
body: JSON.stringify({ id: createdTodoId, is_done: true }),
|
|
176
|
+
})
|
|
177
|
+
payloads.markDone = markDoneResponse.result
|
|
178
|
+
const markDoneOk = markDoneResponse.ok
|
|
179
|
+
|
|
180
|
+
if (!markDoneOk) {
|
|
181
|
+
nextResults.preventUncomplete = {
|
|
182
|
+
...nextResults.preventUncomplete,
|
|
183
|
+
status: 'error',
|
|
184
|
+
httpStatus: markDoneResponse.status,
|
|
185
|
+
details: t('example.mutationLifecycle.m2.probe.preventUncomplete.markDoneFailed', `Failed to mark todo as done: status ${markDoneResponse.status}.`),
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
// Step 2b: Try to revert to pending — should be blocked by prevent-uncomplete subscriber
|
|
189
|
+
const revertResponse = await apiCall('/api/example/todos', {
|
|
190
|
+
method: 'PUT',
|
|
191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
192
|
+
body: JSON.stringify({ id: createdTodoId, is_done: false }),
|
|
193
|
+
})
|
|
194
|
+
payloads.revert = revertResponse.result
|
|
195
|
+
const revertBlocked = revertResponse.status === 422
|
|
196
|
+
nextResults.preventUncomplete = {
|
|
197
|
+
...nextResults.preventUncomplete,
|
|
198
|
+
status: revertBlocked ? 'ok' : 'error',
|
|
199
|
+
httpStatus: revertResponse.status,
|
|
200
|
+
ok: revertBlocked,
|
|
201
|
+
details: revertBlocked
|
|
202
|
+
? t('example.mutationLifecycle.m2.probe.preventUncomplete.ok', 'Revert blocked (422). Sync before-event example.todo.updating fired — prevent-uncomplete subscriber rejected the operation.')
|
|
203
|
+
: t('example.mutationLifecycle.m2.probe.preventUncomplete.error', `Expected 422 (blocked), got ${revertResponse.status}.`),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Probe 3: audit-delete (after-delete) ──
|
|
208
|
+
const deleteResponse = await apiCall(`/api/example/todos?id=${createdTodoId}`, {
|
|
209
|
+
method: 'DELETE',
|
|
210
|
+
})
|
|
211
|
+
payloads.delete = deleteResponse.result
|
|
212
|
+
const deleteOk = deleteResponse.ok
|
|
213
|
+
nextResults.auditDelete = {
|
|
214
|
+
...nextResults.auditDelete,
|
|
215
|
+
status: deleteOk ? 'ok' : 'error',
|
|
216
|
+
httpStatus: deleteResponse.status,
|
|
217
|
+
ok: deleteOk,
|
|
218
|
+
details: deleteOk
|
|
219
|
+
? t('example.mutationLifecycle.m2.probe.auditDelete.ok', 'Todo deleted (200). Sync after-event example.todo.deleted fired — audit-delete subscriber logged to server console.')
|
|
220
|
+
: t('example.mutationLifecycle.m2.probe.auditDelete.error', `Expected 200, got ${deleteResponse.status}.`),
|
|
221
|
+
}
|
|
222
|
+
createdTodoId = null
|
|
223
|
+
|
|
224
|
+
const allOk = syncProbeOrder.every((key) => nextResults[key].ok)
|
|
225
|
+
setSyncStatus(allOk ? 'ok' : 'error')
|
|
226
|
+
if (!allOk) {
|
|
227
|
+
setSyncError(t('example.mutationLifecycle.m2.partial', 'One or more sync subscriber probes failed. Review rows below.'))
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
setSyncStatus('error')
|
|
231
|
+
setSyncError(error instanceof Error ? error.message : String(error))
|
|
232
|
+
} finally {
|
|
233
|
+
// Clean up if a todo was left behind
|
|
234
|
+
if (createdTodoId) {
|
|
235
|
+
await apiCall(`/api/example/todos?id=${createdTodoId}`, { method: 'DELETE' }).catch(() => {})
|
|
236
|
+
}
|
|
237
|
+
setSyncPayloads(payloads)
|
|
238
|
+
setSyncProbeResults(nextResults)
|
|
239
|
+
}
|
|
240
|
+
}, [t])
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<Page>
|
|
244
|
+
<PageBody className="space-y-4">
|
|
245
|
+
<div>
|
|
246
|
+
<h1 className="text-xl font-semibold">{t('example.mutationLifecycle.title', 'UMES Phase M — Mutation Lifecycle')}</h1>
|
|
247
|
+
<p className="text-sm text-muted-foreground">
|
|
248
|
+
{t('example.mutationLifecycle.description', 'Validation page for mutation guards, sync event subscribers, client-side event filtering, and command interceptors.')}
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{/* Status overview */}
|
|
253
|
+
<div className="grid gap-2 rounded border border-border p-3 text-xs text-muted-foreground">
|
|
254
|
+
<div data-testid="phase-m-status-m1">phaseM1={guardStatus}</div>
|
|
255
|
+
<div data-testid="phase-m-status-m2">phaseM2={syncStatus}</div>
|
|
256
|
+
<div data-testid="phase-m-status-m3">phaseM3={phaseM3Status}</div>
|
|
257
|
+
<div data-testid="phase-m-status-m4">phaseM4={phaseM4Status}</div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* ── Phase m1: Mutation Guard Registry ── */}
|
|
261
|
+
<div className="space-y-3 rounded border border-border p-4">
|
|
262
|
+
<div>
|
|
263
|
+
<h2 className="text-base font-semibold">{t('example.mutationLifecycle.m1.title', 'Phase m1 — Mutation Guard Registry')}</h2>
|
|
264
|
+
<p className="text-sm text-muted-foreground">
|
|
265
|
+
{t('example.mutationLifecycle.m1.description', 'Create a todo via the CRUD pipeline to verify the mutation guard registry evaluates the `example.todo-limit` guard on POST.')}
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
<div className={`grid gap-1 ${hintClassName}`}>
|
|
269
|
+
<div className="font-medium text-amber-900 dark:text-amber-50">{t('example.mutationLifecycle.hintHeading', 'What should be visible and how it should work')}</div>
|
|
270
|
+
<div>{t('example.mutationLifecycle.m1.hint1', '1. Guard `example.todo-limit` targets `example.todo` on `create` operations with priority 50.')}</div>
|
|
271
|
+
<div>{t('example.mutationLifecycle.m1.hint2', '2. Guard validates `organizationId` presence — creation fails with 422 if missing.')}</div>
|
|
272
|
+
<div>{t('example.mutationLifecycle.m1.hint3', '3. Multiple guards run by priority order; first rejection stops the pipeline.')}</div>
|
|
273
|
+
<div>{t('example.mutationLifecycle.m1.hint4', '4. Legacy `crudMutationGuardService` is automatically bridged as a guard with priority 0.')}</div>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
276
|
+
<Button data-testid="phase-m1-run-probe" type="button" onClick={() => void runGuardProbe()}>
|
|
277
|
+
{t('example.mutationLifecycle.m1.run', 'Run guard probe')}
|
|
278
|
+
</Button>
|
|
279
|
+
<span data-testid="phase-m1-status" className="text-xs text-muted-foreground">status={guardStatus}</span>
|
|
280
|
+
</div>
|
|
281
|
+
{guardError ? <div data-testid="phase-m1-error" className="text-xs text-destructive">{guardError}</div> : null}
|
|
282
|
+
<div data-testid="phase-m1-result" className="rounded border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
283
|
+
response={print(guardPayload)}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{/* ── Phase m2: Sync Event Subscribers ── */}
|
|
288
|
+
<div className="space-y-3 rounded border border-border p-4">
|
|
289
|
+
<div>
|
|
290
|
+
<h2 className="text-base font-semibold">{t('example.mutationLifecycle.m2.title', 'Phase m2 — Sync Event Subscribers')}</h2>
|
|
291
|
+
<p className="text-sm text-muted-foreground">
|
|
292
|
+
{t('example.mutationLifecycle.m2.description', 'Run a multi-step probe: create a todo (verify auto-default-priority), mark as done then try to revert (verify prevent-uncomplete blocks with 422), and delete (verify audit-delete fires).')}
|
|
293
|
+
</p>
|
|
294
|
+
</div>
|
|
295
|
+
<div className={`grid gap-1 ${hintClassName}`}>
|
|
296
|
+
<div className="font-medium text-amber-900 dark:text-amber-50">{t('example.mutationLifecycle.hintHeading', 'What should be visible and how it should work')}</div>
|
|
297
|
+
<div>{t('example.mutationLifecycle.m2.hint1', '1. `auto-default-priority`: Injects `priority: \'normal\'` when creating a todo without explicit priority (sync before-create).')}</div>
|
|
298
|
+
<div>{t('example.mutationLifecycle.m2.hint2', '2. `prevent-uncomplete`: Blocks reverting completed todos to pending with 422 (sync before-update).')}</div>
|
|
299
|
+
<div>{t('example.mutationLifecycle.m2.hint3', '3. `audit-delete`: Logs deletion audit trail to server console (sync after-delete, non-blocking).')}</div>
|
|
300
|
+
<div>{t('example.mutationLifecycle.m2.hint4', '4. Sync subscribers run inside the CRUD pipeline, before/after the actual mutation — unlike async subscribers which run post-commit.')}</div>
|
|
301
|
+
</div>
|
|
302
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
303
|
+
<Button data-testid="phase-m2-run-probe" type="button" onClick={() => void runSyncProbe()}>
|
|
304
|
+
{t('example.mutationLifecycle.m2.run', 'Run sync subscriber probe')}
|
|
305
|
+
</Button>
|
|
306
|
+
<span data-testid="phase-m2-status" className="text-xs text-muted-foreground">status={syncStatus}</span>
|
|
307
|
+
</div>
|
|
308
|
+
{syncError ? <div data-testid="phase-m2-error" className="text-xs text-destructive">{syncError}</div> : null}
|
|
309
|
+
<div className="grid gap-2">
|
|
310
|
+
{syncProbeOrder.map((key) => {
|
|
311
|
+
const probe = syncProbeResults[key]
|
|
312
|
+
return (
|
|
313
|
+
<div key={probe.key} className="grid gap-1 rounded border border-border p-2 text-xs" data-testid={`phase-m2-probe-${probe.key}`}>
|
|
314
|
+
<div className="font-medium text-foreground">{probe.label}</div>
|
|
315
|
+
<div className="text-muted-foreground">status={probe.status} httpStatus={probe.httpStatus ?? 'n/a'}</div>
|
|
316
|
+
<div className="text-muted-foreground">{probe.details}</div>
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
})}
|
|
320
|
+
</div>
|
|
321
|
+
<div data-testid="phase-m2-result" className="rounded border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
322
|
+
payloads={print(syncPayloads)}
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
{/* ── Phase m3: Client-Side Event Filtering ── */}
|
|
327
|
+
<div className="space-y-3 rounded border border-border p-4">
|
|
328
|
+
<div>
|
|
329
|
+
<h2 className="text-base font-semibold">{t('example.mutationLifecycle.m3.title', 'Phase m3 — Client-Side Event Filtering')}</h2>
|
|
330
|
+
<p className="text-sm text-muted-foreground">
|
|
331
|
+
{t('example.mutationLifecycle.m3.description', 'Widget injection event handlers can now declare an operation filter to skip events for specific CRUD operations.')}
|
|
332
|
+
</p>
|
|
333
|
+
</div>
|
|
334
|
+
<div className={`grid gap-1 ${hintClassName}`}>
|
|
335
|
+
<div className="font-medium text-amber-900 dark:text-amber-50">{t('example.mutationLifecycle.hintHeading', 'What should be visible and how it should work')}</div>
|
|
336
|
+
<div>{t('example.mutationLifecycle.m3.hint1', '1. Widgets can declare `eventHandlers.filter.operations` to skip specific CRUD operations (e.g., only fire on `update`).')}</div>
|
|
337
|
+
<div>{t('example.mutationLifecycle.m3.hint2', '2. CrudForm now passes `operation` (\'create\' or \'update\') in the injection context.')}</div>
|
|
338
|
+
<div>{t('example.mutationLifecycle.m3.hint3', '3. Widgets without a filter continue to fire for all operations (backward compatible).')}</div>
|
|
339
|
+
<div>{t('example.mutationLifecycle.m3.hint4', '4. Type: `WidgetInjectionEventFilter = { operations?: (\'create\' | \'update\' | \'delete\')[] }`')}</div>
|
|
340
|
+
</div>
|
|
341
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
342
|
+
<Button asChild type="button" variant="outline">
|
|
343
|
+
<Link href="/backend/umes-extensions">{t('example.mutationLifecycle.m3.openPhaseG', 'Open Phase G — CrudForm injection')}</Link>
|
|
344
|
+
</Button>
|
|
345
|
+
<span>{t('example.mutationLifecycle.m3.note', 'The filter is a type-level extension. See Phase G page for CrudForm injection demo.')}</span>
|
|
346
|
+
</div>
|
|
347
|
+
<div className="rounded border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
348
|
+
type={print({ operations: ['create', 'update', 'delete'] })}
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
{/* ── Phase m4: Command Interceptors ── */}
|
|
353
|
+
<div className="space-y-3 rounded border border-border p-4">
|
|
354
|
+
<div>
|
|
355
|
+
<h2 className="text-base font-semibold">{t('example.mutationLifecycle.m4.title', 'Phase m4 — Command Interceptors')}</h2>
|
|
356
|
+
<p className="text-sm text-muted-foreground">
|
|
357
|
+
{t('example.mutationLifecycle.m4.description', 'The `example.audit-logging` interceptor wraps all `customers.*` command bus operations with timing metadata.')}
|
|
358
|
+
</p>
|
|
359
|
+
</div>
|
|
360
|
+
<div className={`grid gap-1 ${hintClassName}`}>
|
|
361
|
+
<div className="font-medium text-amber-900 dark:text-amber-50">{t('example.mutationLifecycle.hintHeading', 'What should be visible and how it should work')}</div>
|
|
362
|
+
<div>{t('example.mutationLifecycle.m4.hint1', '1. `example.audit-logging` intercepts all `customers.*` commands (wildcard pattern).')}</div>
|
|
363
|
+
<div>{t('example.mutationLifecycle.m4.hint2', '2. `beforeExecute` stores `auditStartedAt` timestamp in metadata.')}</div>
|
|
364
|
+
<div>{t('example.mutationLifecycle.m4.hint3', '3. `afterExecute` reads metadata and logs: `[example:audit] Command {id} completed in {ms}ms`.')}</div>
|
|
365
|
+
<div>{t('example.mutationLifecycle.m4.hint4', '4. Pattern matching supports `*` (all), exact ID, and `prefix.*` (namespace wildcard).')}</div>
|
|
366
|
+
<div>{t('example.mutationLifecycle.m4.hint5', '5. Interceptors run on both `execute()` and `undo()` code paths.')}</div>
|
|
367
|
+
</div>
|
|
368
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
369
|
+
<Button asChild type="button" variant="outline">
|
|
370
|
+
<Link href="/backend/customers/people">{t('example.mutationLifecycle.m4.openCustomers', 'Open customers')}</Link>
|
|
371
|
+
</Button>
|
|
372
|
+
<span>{t('example.mutationLifecycle.m4.note', 'Create or edit a customer, then check server console for the audit log entry.')}</span>
|
|
373
|
+
</div>
|
|
374
|
+
<div className="rounded border border-border bg-muted/30 p-2 text-xs text-muted-foreground">
|
|
375
|
+
interceptor={print({
|
|
376
|
+
id: 'example.audit-logging',
|
|
377
|
+
targetCommand: 'customers.*',
|
|
378
|
+
priority: 50,
|
|
379
|
+
hooks: ['beforeExecute', 'afterExecute'],
|
|
380
|
+
})}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</PageBody>
|
|
384
|
+
</Page>
|
|
385
|
+
)
|
|
386
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CommandInterceptor } from '@open-mercato/shared/lib/commands/command-interceptor'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example command interceptor: audit logging for customer commands.
|
|
5
|
+
*
|
|
6
|
+
* Demonstrates the command interceptor contract (m4): beforeExecute stores
|
|
7
|
+
* a timestamp in metadata, afterExecute logs the duration.
|
|
8
|
+
*/
|
|
9
|
+
const auditLoggingInterceptor: CommandInterceptor = {
|
|
10
|
+
id: 'example.audit-logging',
|
|
11
|
+
targetCommand: 'customers.*',
|
|
12
|
+
priority: 50,
|
|
13
|
+
|
|
14
|
+
async beforeExecute(_input, _context) {
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
metadata: { auditStartedAt: Date.now() },
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async afterExecute(_input, _result, context) {
|
|
22
|
+
const startedAt = context.metadata?.auditStartedAt as number | undefined
|
|
23
|
+
if (startedAt) {
|
|
24
|
+
const duration = Date.now() - startedAt
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.log(
|
|
27
|
+
`[example:audit] Command ${context.commandId} completed in ${duration}ms`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const interceptors: CommandInterceptor[] = [auditLoggingInterceptor]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MutationGuard } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example mutation guard: limits the number of active todos per tenant.
|
|
5
|
+
*
|
|
6
|
+
* Demonstrates the guard contract — runs on `create` operations for `example.todo`.
|
|
7
|
+
* The guard checks a simple policy before allowing the mutation to proceed.
|
|
8
|
+
*/
|
|
9
|
+
const todoLimitGuard: MutationGuard = {
|
|
10
|
+
id: 'example.todo-limit',
|
|
11
|
+
targetEntity: 'example.todo',
|
|
12
|
+
operations: ['create'],
|
|
13
|
+
priority: 50,
|
|
14
|
+
|
|
15
|
+
async validate(input) {
|
|
16
|
+
// Guard validates at the request level — business rules can check
|
|
17
|
+
// organizational limits, feature flags, or other policy constraints.
|
|
18
|
+
// For demonstration, we simply allow the operation.
|
|
19
|
+
if (!input.organizationId) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
message: 'Organization is required to create todos',
|
|
23
|
+
status: 422,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { ok: true }
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const guards: MutationGuard[] = [todoLimitGuard]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SyncCrudEventPayload } from '@open-mercato/shared/lib/crud/sync-event-types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sync after-delete subscriber: logs a deletion audit trail.
|
|
5
|
+
*
|
|
6
|
+
* Fires after a todo has been deleted. After-event subscribers cannot block
|
|
7
|
+
* the operation — errors are swallowed with console.error. Demonstrates
|
|
8
|
+
* the sync after-event contract (m2).
|
|
9
|
+
*/
|
|
10
|
+
export const metadata = {
|
|
11
|
+
event: 'example.todo.deleted',
|
|
12
|
+
sync: true,
|
|
13
|
+
priority: 50,
|
|
14
|
+
id: 'example:audit-delete',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function handler(
|
|
18
|
+
payload: SyncCrudEventPayload,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
// eslint-disable-next-line no-console
|
|
21
|
+
console.log(
|
|
22
|
+
`[example:audit-delete] Todo ${payload.resourceId} deleted by user ${payload.userId} in org ${payload.organizationId}`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SyncCrudEventPayload, SyncCrudEventResult } from '@open-mercato/shared/lib/crud/sync-event-types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sync before-create subscriber: sets a default priority on new todos.
|
|
5
|
+
*
|
|
6
|
+
* When creating a todo without an explicit `priority` field, this subscriber
|
|
7
|
+
* injects `priority: 'normal'` into the payload. Demonstrates the sync
|
|
8
|
+
* before-event contract (m2).
|
|
9
|
+
*/
|
|
10
|
+
export const metadata = {
|
|
11
|
+
event: 'example.todo.creating',
|
|
12
|
+
sync: true,
|
|
13
|
+
priority: 50,
|
|
14
|
+
id: 'example:auto-default-priority',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function handler(
|
|
18
|
+
payload: SyncCrudEventPayload,
|
|
19
|
+
): Promise<SyncCrudEventResult | void> {
|
|
20
|
+
const body = payload.payload
|
|
21
|
+
if (body && typeof body === 'object' && !('priority' in body)) {
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
modifiedPayload: { ...body, priority: 'normal' },
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SyncCrudEventPayload, SyncCrudEventResult } from '@open-mercato/shared/lib/crud/sync-event-types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sync before-update subscriber: prevents reverting a completed todo.
|
|
5
|
+
*
|
|
6
|
+
* Once a todo is marked as done, this subscriber blocks any attempt to set
|
|
7
|
+
* `isDone` back to `false`. Demonstrates the sync before-event rejection
|
|
8
|
+
* contract (m2).
|
|
9
|
+
*/
|
|
10
|
+
export const metadata = {
|
|
11
|
+
event: 'example.todo.updating',
|
|
12
|
+
sync: true,
|
|
13
|
+
priority: 60,
|
|
14
|
+
id: 'example:prevent-uncomplete',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function handler(
|
|
18
|
+
payload: SyncCrudEventPayload,
|
|
19
|
+
): Promise<SyncCrudEventResult | void> {
|
|
20
|
+
const body = payload.payload
|
|
21
|
+
const previous = payload.previousData
|
|
22
|
+
|
|
23
|
+
if (!previous || !body || typeof body !== 'object') return
|
|
24
|
+
|
|
25
|
+
const wasDone = previous.isDone === true || previous.is_done === true
|
|
26
|
+
const wantUndone = ('isDone' in body && body.isDone === false) || ('is_done' in body && body.is_done === false)
|
|
27
|
+
|
|
28
|
+
if (wasDone && wantUndone) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
message: 'Completed todos cannot be reverted to pending',
|
|
32
|
+
status: 422,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|