@open-mercato/ui 0.6.6-develop.6416.1.888fffc007 → 0.6.6-develop.6419.1.a632f417c4

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.
@@ -5,12 +5,15 @@
5
5
  import * as React from 'react'
6
6
  import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
7
7
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+ import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
8
9
  import { MessageComposer } from '../MessageComposer'
9
- import { apiCall } from '../../utils/apiCall'
10
+ import { apiCall, withScopedApiRequestHeaders } from '../../utils/apiCall'
10
11
  import { flash } from '../../FlashMessages'
12
+ import { dismissRecordConflict, getRecordConflictForTest } from '../../conflicts'
11
13
 
12
14
  jest.mock('../../utils/apiCall', () => ({
13
15
  apiCall: jest.fn(),
16
+ withScopedApiRequestHeaders: jest.fn((_headers: Record<string, string>, run: () => unknown) => run()),
14
17
  }))
15
18
 
16
19
  jest.mock('../../FlashMessages', () => ({
@@ -59,6 +62,10 @@ describe('MessageComposer draft flow', () => {
59
62
 
60
63
  beforeEach(() => {
61
64
  jest.resetAllMocks()
65
+ dismissRecordConflict()
66
+ ;(withScopedApiRequestHeaders as jest.Mock).mockImplementation(
67
+ (_headers: Record<string, string>, run: () => unknown) => run(),
68
+ )
62
69
  ;(apiCall as jest.Mock).mockImplementation((url: string, options?: { method?: string, body?: string }) => {
63
70
  if (url.startsWith('/api/messages/types')) {
64
71
  return Promise.resolve({
@@ -156,6 +163,172 @@ describe('MessageComposer draft flow', () => {
156
163
  expect(flash).toHaveBeenCalledWith('Message sent.', 'success')
157
164
  })
158
165
 
166
+ it('attaches the optimistic-lock header when sending an existing draft', async () => {
167
+ const expectedUpdatedAt = '2026-02-24T10:00:00.000Z'
168
+
169
+ renderWithProviders(
170
+ <MessageComposer
171
+ inline
172
+ variant="compose"
173
+ messageId="draft-1"
174
+ expectedUpdatedAt={expectedUpdatedAt}
175
+ defaultValues={{
176
+ recipients: ['11111111-1111-4111-8111-111111111111'],
177
+ subject: 'Existing draft',
178
+ body: 'Existing draft body',
179
+ }}
180
+ />,
181
+ { dict: {} },
182
+ )
183
+
184
+ fireEvent.click(await screen.findByRole('button', { name: 'Send' }))
185
+
186
+ await waitFor(() => {
187
+ expect(apiCall).toHaveBeenCalledWith(
188
+ '/api/messages/draft-1',
189
+ expect.objectContaining({ method: 'PATCH' }),
190
+ )
191
+ })
192
+
193
+ expect(withScopedApiRequestHeaders).toHaveBeenCalledWith(
194
+ { [OPTIMISTIC_LOCK_HEADER_NAME]: expectedUpdatedAt },
195
+ expect.any(Function),
196
+ )
197
+ })
198
+
199
+ it('surfaces a 409 optimistic-lock conflict on the shared banner instead of a generic flash', async () => {
200
+ const expectedUpdatedAt = '2026-02-24T10:00:00.000Z'
201
+
202
+ ;(apiCall as jest.Mock).mockImplementation((url: string, options?: { method?: string }) => {
203
+ if (url.startsWith('/api/messages/types')) {
204
+ return Promise.resolve({
205
+ ok: true,
206
+ status: 200,
207
+ result: {
208
+ items: [{
209
+ type: 'default',
210
+ module: 'messages',
211
+ labelKey: 'messages.types.default',
212
+ icon: 'mail',
213
+ allowReply: true,
214
+ allowForward: true,
215
+ }],
216
+ },
217
+ response: { status: 200 },
218
+ })
219
+ }
220
+
221
+ if (url === '/api/messages/draft-1' && options?.method === 'PATCH') {
222
+ return Promise.resolve({
223
+ ok: false,
224
+ status: 409,
225
+ result: {
226
+ error: 'record_modified',
227
+ code: 'optimistic_lock_conflict',
228
+ currentUpdatedAt: '2026-02-24T11:00:00.000Z',
229
+ expectedUpdatedAt,
230
+ },
231
+ response: { status: 409 },
232
+ })
233
+ }
234
+
235
+ return Promise.resolve({ ok: true, status: 200, result: { items: [] }, response: { status: 200 } })
236
+ })
237
+
238
+ renderWithProviders(
239
+ <MessageComposer
240
+ inline
241
+ variant="compose"
242
+ messageId="draft-1"
243
+ expectedUpdatedAt={expectedUpdatedAt}
244
+ defaultValues={{
245
+ recipients: ['11111111-1111-4111-8111-111111111111'],
246
+ subject: 'Existing draft',
247
+ body: 'Existing draft body',
248
+ }}
249
+ />,
250
+ { dict: {} },
251
+ )
252
+
253
+ fireEvent.click(await screen.findByRole('button', { name: 'Send' }))
254
+
255
+ await waitFor(() => {
256
+ const conflict = getRecordConflictForTest()
257
+ expect(conflict).not.toBeNull()
258
+ expect(conflict?.currentUpdatedAt).toBe('2026-02-24T11:00:00.000Z')
259
+ })
260
+
261
+ expect(flash).not.toHaveBeenCalledWith('Failed to send message.', 'error')
262
+ })
263
+
264
+ it('closes the compose dialog on a 409 conflict so the shared banner is not hidden behind it (TC-007)', async () => {
265
+ const expectedUpdatedAt = '2026-02-24T10:00:00.000Z'
266
+ const onOpenChange = jest.fn()
267
+
268
+ ;(apiCall as jest.Mock).mockImplementation((url: string, options?: { method?: string }) => {
269
+ if (url.startsWith('/api/messages/types')) {
270
+ return Promise.resolve({
271
+ ok: true,
272
+ status: 200,
273
+ result: {
274
+ items: [{
275
+ type: 'default',
276
+ module: 'messages',
277
+ labelKey: 'messages.types.default',
278
+ icon: 'mail',
279
+ allowReply: true,
280
+ allowForward: true,
281
+ }],
282
+ },
283
+ response: { status: 200 },
284
+ })
285
+ }
286
+
287
+ if (url === '/api/messages/draft-1' && options?.method === 'PATCH') {
288
+ return Promise.resolve({
289
+ ok: false,
290
+ status: 409,
291
+ result: {
292
+ error: 'record_modified',
293
+ code: 'optimistic_lock_conflict',
294
+ currentUpdatedAt: '2026-02-24T11:00:00.000Z',
295
+ expectedUpdatedAt,
296
+ },
297
+ response: { status: 409 },
298
+ })
299
+ }
300
+
301
+ return Promise.resolve({ ok: true, status: 200, result: { items: [] }, response: { status: 200 } })
302
+ })
303
+
304
+ renderWithProviders(
305
+ <MessageComposer
306
+ open
307
+ variant="compose"
308
+ messageId="draft-1"
309
+ expectedUpdatedAt={expectedUpdatedAt}
310
+ onOpenChange={onOpenChange}
311
+ defaultValues={{
312
+ recipients: ['11111111-1111-4111-8111-111111111111'],
313
+ subject: 'Existing draft',
314
+ body: 'Existing draft body',
315
+ }}
316
+ />,
317
+ { dict: {} },
318
+ )
319
+
320
+ const dialog = await screen.findByRole('dialog')
321
+ const sendButtons = within(dialog).getAllByRole('button', { name: 'Send' })
322
+ fireEvent.click(sendButtons[sendButtons.length - 1] as HTMLButtonElement)
323
+
324
+ await waitFor(() => {
325
+ expect(getRecordConflictForTest()).not.toBeNull()
326
+ })
327
+ // The page-level conflict banner is hidden behind the modal, so the dialog
328
+ // must close to reveal it (and its Refresh action) to the user.
329
+ expect(onOpenChange).toHaveBeenCalledWith(false)
330
+ })
331
+
159
332
  it('saves an existing draft via PATCH without a draft transition flag', async () => {
160
333
  renderWithProviders(
161
334
  <MessageComposer
@@ -60,6 +60,12 @@ export type MessageComposerProps = {
60
60
  contextObject?: MessageComposerContextObject | null
61
61
  requiredActionConfig?: MessageComposerRequiredActionConfig | null
62
62
  contextPreview?: React.ReactNode
63
+ /**
64
+ * Expected `updated_at` of the existing draft being edited. When present, the
65
+ * composer attaches the OSS optimistic-lock header to the draft save/send
66
+ * PATCH so a stale tab cannot silently overwrite a concurrently-changed draft.
67
+ */
68
+ expectedUpdatedAt?: string | null
63
69
  defaultValues?: {
64
70
  type?: string
65
71
  recipients?: string[]
@@ -2,7 +2,9 @@ import * as React from 'react'
2
2
  import { useQuery } from '@tanstack/react-query'
3
3
  import { useT } from '@open-mercato/shared/lib/i18n/context'
4
4
  import { flash } from '../FlashMessages'
5
- import { apiCall } from '../utils/apiCall'
5
+ import { apiCall, withScopedApiRequestHeaders } from '../utils/apiCall'
6
+ import { buildOptimisticLockHeader } from '../utils/optimisticLock'
7
+ import { surfaceRecordConflict } from '../conflicts'
6
8
  import type {
7
9
  AttachmentListResponse,
8
10
  MessageComposerProps,
@@ -127,6 +129,7 @@ export function useMessageCompose({
127
129
  requiredActionConfig = null,
128
130
  contextPreview = null,
129
131
  defaultValues,
132
+ expectedUpdatedAt = null,
130
133
  onSuccess,
131
134
  onCancel,
132
135
  }: UseMessageComposeParams): UseMessageComposeResult {
@@ -614,16 +617,37 @@ export function useMessageCompose({
614
617
  if (!shouldReturnFalse) {
615
618
  const { endpoint, method, payload } = operation.buildRequest({ attachmentIds: nextAttachmentIds })
616
619
 
617
- const call = await apiCall<{ id?: string }>(endpoint, {
618
- method,
619
- headers: { 'Content-Type': 'application/json' },
620
- body: JSON.stringify(payload),
621
- })
620
+ // Editing an existing draft is a PATCH on the message aggregate, so carry
621
+ // the OSS optimistic-lock header to reject a stale overwrite. New
622
+ // compose/reply/forward writes create fresh records and never lock.
623
+ const lockHeaders = isEditingExistingDraft
624
+ ? buildOptimisticLockHeader(expectedUpdatedAt)
625
+ : {}
626
+
627
+ const call = await withScopedApiRequestHeaders(lockHeaders, () =>
628
+ apiCall<{ id?: string }>(endpoint, {
629
+ method,
630
+ headers: { 'Content-Type': 'application/json' },
631
+ body: JSON.stringify(payload),
632
+ }),
633
+ )
622
634
 
623
635
  if (!call.ok) {
624
- const message = toErrorMessage(call.result) ?? t('messages.errors.sendFailed', 'Failed to send message.')
625
- setSubmitError(message)
626
- flash(message, 'error')
636
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) {
637
+ setSubmitError(
638
+ t('ui.forms.flash.recordModified', 'This record was modified by someone else. Refresh and try again.'),
639
+ )
640
+ // The shared conflict banner renders at page level, so it is hidden
641
+ // behind the compose modal. Close the dialog to reveal it (with its
642
+ // Refresh action); the stale draft must be reloaded anyway (#3260 QA).
643
+ if (!inline) {
644
+ onOpenChange?.(false)
645
+ }
646
+ } else {
647
+ const message = toErrorMessage(call.result) ?? t('messages.errors.sendFailed', 'Failed to send message.')
648
+ setSubmitError(message)
649
+ flash(message, 'error')
650
+ }
627
651
  shouldReturnFalse = true
628
652
  } else {
629
653
  flash(operation.successMessage, 'success')
@@ -665,6 +689,7 @@ export function useMessageCompose({
665
689
  attachmentIds,
666
690
  composeDraftOperation,
667
691
  composeSendOperation,
692
+ expectedUpdatedAt,
668
693
  forwardOperation,
669
694
  inline,
670
695
  loadAttachmentIds,