@actuate-media/cms-admin 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/__tests__/components/comment-side-panel.test.js +93 -0
  2. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
  3. package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
  4. package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
  5. package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
  6. package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
  7. package/dist/__tests__/components/notification-bell.test.js +146 -0
  8. package/dist/__tests__/components/notification-bell.test.js.map +1 -1
  9. package/dist/__tests__/lib/mention-picker.test.d.ts +2 -0
  10. package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/mention-picker.test.js +122 -0
  12. package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
  13. package/dist/__tests__/lib/notifications-client.test.js +129 -1
  14. package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
  15. package/dist/actuate-admin.css +1 -1
  16. package/dist/components/CommentSidePanel.d.ts +9 -1
  17. package/dist/components/CommentSidePanel.d.ts.map +1 -1
  18. package/dist/components/CommentSidePanel.js +8 -7
  19. package/dist/components/CommentSidePanel.js.map +1 -1
  20. package/dist/components/MentionableTextarea.d.ts +68 -0
  21. package/dist/components/MentionableTextarea.d.ts.map +1 -0
  22. package/dist/components/MentionableTextarea.js +181 -0
  23. package/dist/components/MentionableTextarea.js.map +1 -0
  24. package/dist/components/NotificationBell.d.ts +30 -9
  25. package/dist/components/NotificationBell.d.ts.map +1 -1
  26. package/dist/components/NotificationBell.js +57 -5
  27. package/dist/components/NotificationBell.js.map +1 -1
  28. package/dist/lib/api.d.ts +8 -0
  29. package/dist/lib/api.d.ts.map +1 -1
  30. package/dist/lib/api.js +10 -0
  31. package/dist/lib/api.js.map +1 -1
  32. package/dist/lib/mention-picker.d.ts +69 -0
  33. package/dist/lib/mention-picker.d.ts.map +1 -0
  34. package/dist/lib/mention-picker.js +138 -0
  35. package/dist/lib/mention-picker.js.map +1 -0
  36. package/dist/lib/notifications-client.d.ts +53 -0
  37. package/dist/lib/notifications-client.d.ts.map +1 -1
  38. package/dist/lib/notifications-client.js +89 -1
  39. package/dist/lib/notifications-client.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
  42. package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
  43. package/src/__tests__/components/notification-bell.test.tsx +167 -1
  44. package/src/__tests__/lib/mention-picker.test.ts +145 -0
  45. package/src/__tests__/lib/notifications-client.test.ts +145 -1
  46. package/src/components/CommentSidePanel.tsx +37 -14
  47. package/src/components/MentionableTextarea.tsx +328 -0
  48. package/src/components/NotificationBell.tsx +84 -12
  49. package/src/lib/api.ts +11 -0
  50. package/src/lib/mention-picker.ts +180 -0
  51. package/src/lib/notifications-client.ts +147 -1
@@ -0,0 +1,226 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import React, { useState } from 'react'
4
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ import { MentionableTextarea } from '../../components/MentionableTextarea.js'
7
+ import type { MentionUser } from '../../lib/mention-picker.js'
8
+
9
+ /**
10
+ * Mount the textarea against a state-managing host so we exercise the
11
+ * controlled-value contract. Returning the live `value` lets each
12
+ * test inspect what the parent ends up with after a selection.
13
+ */
14
+ function Host({
15
+ initialValue = '',
16
+ search,
17
+ testid = 'ta',
18
+ }: {
19
+ initialValue?: string
20
+ search: (q: string) => Promise<MentionUser[]>
21
+ testid?: string
22
+ }) {
23
+ const [value, setValue] = useState(initialValue)
24
+ return (
25
+ <>
26
+ <MentionableTextarea value={value} onChange={setValue} search={search} data-testid={testid} />
27
+ <output data-testid="value">{value}</output>
28
+ </>
29
+ )
30
+ }
31
+
32
+ function fakeSearch(rows: MentionUser[]) {
33
+ return vi.fn(async (q: string) => {
34
+ const ql = q.toLowerCase()
35
+ return rows.filter(
36
+ (u) => u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql),
37
+ )
38
+ })
39
+ }
40
+
41
+ const ROWS: MentionUser[] = [
42
+ { id: 'u_alice', name: 'Alice Anderson', email: 'alice@example.com' },
43
+ { id: 'u_bob', name: 'Bob Brown', email: 'bob@example.com' },
44
+ { id: 'u_carol', name: 'Carol Chen', email: 'carol@example.com' },
45
+ ]
46
+
47
+ async function typeAt(testid: string, value: string, caret = value.length) {
48
+ const ta = screen.getByTestId(testid) as HTMLTextAreaElement
49
+ await act(async () => {
50
+ // Manually drive the textarea: testing-library's
51
+ // `fireEvent.change` resets `selectionStart` to 0 in happy-dom,
52
+ // which would defeat the mention picker (caret 0 → no mention).
53
+ // We bypass React's value-tracker the same way testing-library
54
+ // does, then set the caret BEFORE dispatching `input` so the
55
+ // React handler sees the correct selection.
56
+ ta.focus()
57
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
58
+ if (setter) setter.call(ta, value)
59
+ else ta.value = value
60
+ ta.setSelectionRange(caret, caret)
61
+ ta.dispatchEvent(new Event('input', { bubbles: true }))
62
+ })
63
+ }
64
+
65
+ describe('MentionableTextarea — picker dropdown', () => {
66
+ it('does not show the picker before @ is typed', async () => {
67
+ const search = fakeSearch(ROWS)
68
+ render(<Host search={search} />)
69
+ await typeAt('ta', 'hello world')
70
+ expect(screen.queryByTestId('ta-picker')).toBeNull()
71
+ })
72
+
73
+ it('opens the picker the instant @ is typed and shows the empty state', async () => {
74
+ const search = fakeSearch(ROWS)
75
+ render(<Host search={search} />)
76
+ await typeAt('ta', '@')
77
+ await waitFor(() => expect(screen.getByTestId('ta-picker')).toBeTruthy())
78
+ })
79
+
80
+ it('populates the dropdown with users that match the query', async () => {
81
+ const search = fakeSearch(ROWS)
82
+ render(<Host search={search} />)
83
+ await typeAt('ta', '@ali')
84
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
85
+ // Bob/Carol should NOT match `ali`.
86
+ expect(screen.queryByTestId('ta-picker-row-u_bob')).toBeNull()
87
+ })
88
+
89
+ it('shows "No matches" when the query has no hits', async () => {
90
+ const search = fakeSearch(ROWS)
91
+ render(<Host search={search} />)
92
+ await typeAt('ta', '@zzzz')
93
+ await waitFor(() => expect(screen.getByText(/No matches/i)).toBeTruthy())
94
+ })
95
+
96
+ it('closes the picker on Escape without inserting anything', async () => {
97
+ const search = fakeSearch(ROWS)
98
+ render(<Host search={search} />)
99
+ await typeAt('ta', '@al')
100
+ await waitFor(() => expect(screen.getByTestId('ta-picker')).toBeTruthy())
101
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Escape' })
102
+ expect(screen.queryByTestId('ta-picker')).toBeNull()
103
+ expect(screen.getByTestId('value').textContent).toBe('@al')
104
+ })
105
+
106
+ it('inserts @<userId> + trailing space when a row is clicked', async () => {
107
+ const search = fakeSearch(ROWS)
108
+ render(<Host search={search} />)
109
+ await typeAt('ta', '@ali')
110
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
111
+ await act(async () => {
112
+ // We use mousedown intentionally (the component listens for
113
+ // mousedown to avoid losing focus before commit).
114
+ fireEvent.mouseDown(screen.getByTestId('ta-picker-row-u_alice'))
115
+ })
116
+ expect(screen.getByTestId('value').textContent).toBe('@u_alice ')
117
+ expect(screen.queryByTestId('ta-picker')).toBeNull()
118
+ })
119
+
120
+ it('Enter commits the highlighted row', async () => {
121
+ const search = fakeSearch(ROWS)
122
+ render(<Host search={search} />)
123
+ await typeAt('ta', '@b')
124
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_bob')).toBeTruthy())
125
+ await act(async () => {
126
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
127
+ })
128
+ expect(screen.getByTestId('value').textContent).toBe('@u_bob ')
129
+ })
130
+
131
+ it('ArrowDown then Enter commits the second result', async () => {
132
+ const search = fakeSearch(ROWS)
133
+ render(<Host search={search} />)
134
+ // Query 'e' hits Alice/Bob/Carol's emails — three matches.
135
+ await typeAt('ta', '@e')
136
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
137
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowDown' })
138
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowDown' })
139
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowUp' }) // back to row 2
140
+ await act(async () => {
141
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
142
+ })
143
+ // Highlight after Down/Down/Up: 0 → 1 → 2 → 1. Row index 1 should
144
+ // be Bob (alphabetised by name? Actually fakeSearch returns in
145
+ // ROWS order — Alice, Bob, Carol). So commit = u_bob.
146
+ expect(screen.getByTestId('value').textContent).toBe('@u_bob ')
147
+ })
148
+
149
+ it('keyboard does NOT swallow Enter when the picker is closed', async () => {
150
+ const search = fakeSearch(ROWS)
151
+ const onKeyDown = vi.fn()
152
+ const Wrapper = () => {
153
+ const [v, setV] = useState('')
154
+ return (
155
+ <MentionableTextarea
156
+ value={v}
157
+ onChange={setV}
158
+ search={search}
159
+ onKeyDown={onKeyDown}
160
+ data-testid="ta"
161
+ />
162
+ )
163
+ }
164
+ render(<Wrapper />)
165
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
166
+ expect(onKeyDown).toHaveBeenCalledTimes(1)
167
+ })
168
+
169
+ it('does NOT commit a stale row when Enter fires before a new query resolves (PR #159 review)', async () => {
170
+ // Regression for PR #159 Copilot finding: typing `@a`, getting
171
+ // Alice in the dropdown, then quickly typing `@bo` and hitting
172
+ // Enter before the `@bo` query resolves used to commit Alice
173
+ // (from the previous query) instead of doing nothing or
174
+ // committing Bob. The fix clears `users` on every new query so
175
+ // Enter has no row to commit until the new results land.
176
+ let resolveSlow: (rows: MentionUser[]) => void = () => undefined
177
+ const search = vi.fn((q: string) => {
178
+ if (q === 'bo') {
179
+ return new Promise<MentionUser[]>((resolve) => {
180
+ resolveSlow = resolve
181
+ })
182
+ }
183
+ return Promise.resolve(ROWS.filter((u) => u.name.toLowerCase().includes(q.toLowerCase())))
184
+ })
185
+ render(<Host search={search} />)
186
+ await typeAt('ta', '@a')
187
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
188
+ // Now type the new query that hangs. Enter should NOT commit.
189
+ await typeAt('ta', '@bo')
190
+ fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
191
+ // Value should still be `@bo` — no token was inserted.
192
+ expect(screen.getByTestId('value').textContent).toBe('@bo')
193
+ // Resolve the hung query and confirm Bob is then committable.
194
+ await act(async () => {
195
+ resolveSlow(ROWS.filter((u) => u.name.toLowerCase().includes('bo')))
196
+ })
197
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_bob')).toBeTruthy())
198
+ })
199
+
200
+ it('drops stale responses when typing quickly (race protection)', async () => {
201
+ let resolveSlow: (rows: MentionUser[]) => void = () => undefined
202
+ const search = vi.fn((q: string) => {
203
+ if (q === 'a') {
204
+ return new Promise<MentionUser[]>((resolve) => {
205
+ resolveSlow = resolve
206
+ })
207
+ }
208
+ // Fast path for `ali`.
209
+ return Promise.resolve(ROWS.filter((u) => u.name.toLowerCase().includes(q.toLowerCase())))
210
+ })
211
+ render(<Host search={search} />)
212
+ // Trigger the slow request.
213
+ await typeAt('ta', '@a')
214
+ // Trigger the fast request which will resolve first.
215
+ await typeAt('ta', '@ali')
216
+ await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
217
+ // Now resolve the older request with the wrong rows — should NOT
218
+ // appear in the dropdown.
219
+ await act(async () => {
220
+ resolveSlow(ROWS)
221
+ })
222
+ // Carol should still be filtered out (the fast `ali` query is
223
+ // what won the race).
224
+ expect(screen.queryByTestId('ta-picker-row-u_carol')).toBeNull()
225
+ })
226
+ })
@@ -4,7 +4,12 @@ import React from 'react'
4
4
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
5
 
6
6
  import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
7
- import type { Notification, NotificationOutcome } from '../../lib/notifications-client.js'
7
+ import type {
8
+ Notification,
9
+ NotificationOutcome,
10
+ NotificationStreamHandle,
11
+ NotificationStreamOptions,
12
+ } from '../../lib/notifications-client.js'
8
13
 
9
14
  function ok<T>(value: T): NotificationOutcome<T> {
10
15
  return { ok: true, result: value }
@@ -211,3 +216,164 @@ describe('NotificationBell — dropdown', () => {
211
216
  await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
212
217
  })
213
218
  })
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // SSE push channel (Phase 7) — verify the bell wires up subscribe(),
222
+ // processes incoming notifications, and tears down on unmount.
223
+ // ---------------------------------------------------------------------------
224
+ describe('NotificationBell — SSE push', () => {
225
+ function withSubscribe(api: NotificationsApi) {
226
+ let captured: NotificationStreamOptions | undefined
227
+ let closed = false
228
+ const handle: NotificationStreamHandle = {
229
+ close: () => {
230
+ closed = true
231
+ },
232
+ }
233
+ const subscribe = vi.fn((opts: NotificationStreamOptions) => {
234
+ captured = opts
235
+ return handle
236
+ })
237
+ return {
238
+ api: { ...api, subscribe },
239
+ push: (n: Notification) => captured?.onNotification(n),
240
+ pushError: (e: Event) => captured?.onError?.(e),
241
+ isClosed: () => closed,
242
+ }
243
+ }
244
+
245
+ it('subscribes on mount and bumps the badge when a new notification arrives', async () => {
246
+ const fake = createFakeApi()
247
+ const wired = withSubscribe(fake.api)
248
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
249
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
250
+ await act(async () => {
251
+ wired.push(row({ id: 'sse-1' }))
252
+ })
253
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
254
+ })
255
+
256
+ it('does not double-count a notification that arrives via both SSE and REST', async () => {
257
+ const fake = createFakeApi()
258
+ const wired = withSubscribe(fake.api)
259
+ fake.setRows([row({ id: 'race' })])
260
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
261
+ // Open the dropdown so the row is in `items` from the REST list.
262
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
263
+ fireEvent.click(screen.getByTestId('bell-button'))
264
+ await waitFor(() => expect(screen.getByTestId('bell-row-race')).toBeTruthy())
265
+ // Now push the same row via SSE — must NOT add a duplicate or
266
+ // bump the badge above 1.
267
+ await act(async () => {
268
+ wired.push(row({ id: 'race' }))
269
+ })
270
+ expect(screen.getAllByTestId('bell-row-race')).toHaveLength(1)
271
+ expect(screen.getByTestId('bell-badge').textContent).toBe('1')
272
+ })
273
+
274
+ it('prepends new pushed rows to the open dropdown', async () => {
275
+ const fake = createFakeApi()
276
+ const wired = withSubscribe(fake.api)
277
+ fake.setRows([row({ id: 'old' })])
278
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
279
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
280
+ fireEvent.click(screen.getByTestId('bell-button'))
281
+ await waitFor(() => expect(screen.getByTestId('bell-row-old')).toBeTruthy())
282
+ await act(async () => {
283
+ wired.push(row({ id: 'fresh' }))
284
+ })
285
+ const rows = screen.getAllByTestId(/^bell-row-/)
286
+ expect(rows[0]!.getAttribute('data-testid')).toBe('bell-row-fresh')
287
+ })
288
+
289
+ it('does NOT bump the badge when the pushed notification is already read', async () => {
290
+ const fake = createFakeApi()
291
+ const wired = withSubscribe(fake.api)
292
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
293
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
294
+ await act(async () => {
295
+ wired.push(row({ id: 'already-read', readAt: '2026-01-02T00:00:00.000Z' }))
296
+ })
297
+ // Already-read notifications still appear in the list when the
298
+ // user opens the dropdown with includeRead=true, but they must
299
+ // NOT register a fresh unread bump.
300
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
301
+ })
302
+
303
+ it('closes the SSE subscription when the bell unmounts', async () => {
304
+ const fake = createFakeApi()
305
+ const wired = withSubscribe(fake.api)
306
+ const { unmount } = render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
307
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
308
+ unmount()
309
+ expect(wired.isClosed()).toBe(true)
310
+ })
311
+
312
+ it('does not surface transient SSE errors via onError (poll loop handles convergence)', async () => {
313
+ const fake = createFakeApi()
314
+ const wired = withSubscribe(fake.api)
315
+ const onError = vi.fn()
316
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
317
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
318
+ await act(async () => {
319
+ wired.pushError(new Event('error'))
320
+ })
321
+ // Deliberately quiet — see NotificationBell SSE handler comment.
322
+ expect(onError).not.toHaveBeenCalled()
323
+ })
324
+
325
+ it('mark-all-read failure does not drop SSE rows that arrived mid-flight', async () => {
326
+ const fake = createFakeApi()
327
+ const wired = withSubscribe(fake.api)
328
+ fake.setRows([row({ id: 'before' })])
329
+ // Hold the markAllRead promise so we can interleave an SSE push.
330
+ let resolveMark: (v: NotificationOutcome<number>) => void = () => undefined
331
+ ;(wired.api.markAllRead as ReturnType<typeof vi.fn>).mockImplementationOnce(
332
+ () =>
333
+ new Promise((resolve) => {
334
+ resolveMark = resolve
335
+ }),
336
+ )
337
+ const onError = vi.fn()
338
+ render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
339
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
340
+ fireEvent.click(screen.getByTestId('bell-button'))
341
+ await waitFor(() => expect(screen.getByTestId('bell-row-before')).toBeTruthy())
342
+
343
+ // Click mark-all — handler optimistically marks 'before' read,
344
+ // but the API promise is still pending.
345
+ await act(async () => {
346
+ fireEvent.click(screen.getByTestId('bell-mark-all'))
347
+ })
348
+ // While in flight, a fresh notification arrives via SSE.
349
+ await act(async () => {
350
+ wired.push(row({ id: 'mid-flight' }))
351
+ })
352
+
353
+ // Now resolve the API with a failure.
354
+ await act(async () => {
355
+ resolveMark({ ok: false, error: 'server', status: 500 })
356
+ // flush promise chain
357
+ await Promise.resolve()
358
+ })
359
+
360
+ expect(onError).toHaveBeenCalledWith('server')
361
+ // The original row was rolled back to unread...
362
+ expect(screen.getByTestId('bell-row-before').getAttribute('data-read')).toBe('false')
363
+ // ...and the SSE arrival is still present (not dropped by the
364
+ // legacy snapshot-replace rollback).
365
+ expect(screen.getByTestId('bell-row-mid-flight')).toBeTruthy()
366
+ })
367
+
368
+ it('still mounts cleanly when api.subscribe is undefined (no SSE support)', async () => {
369
+ const fake = createFakeApi()
370
+ // Strip the `subscribe` member entirely — defaultApi has it,
371
+ // but consumer-provided test APIs may not.
372
+ const apiWithoutSubscribe: NotificationsApi = { ...fake.api }
373
+ delete (apiWithoutSubscribe as { subscribe?: unknown }).subscribe
374
+ render(<NotificationBell api={apiWithoutSubscribe} pollIntervalMs={0} />)
375
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
376
+ // No crash, badge from unread() shows nothing, no items.
377
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
378
+ })
379
+ })
@@ -0,0 +1,145 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const cmsApiMock = vi.fn()
4
+ vi.mock('../../lib/api.js', () => ({
5
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
6
+ getApiBase: () => '/api/cms',
7
+ }))
8
+
9
+ const { detectMentionContext, insertMentionToken, searchMentionUsers } =
10
+ await import('../../lib/mention-picker.js')
11
+
12
+ beforeEach(() => {
13
+ cmsApiMock.mockReset()
14
+ })
15
+
16
+ describe('detectMentionContext', () => {
17
+ it('returns null when there is no @ before the caret', () => {
18
+ expect(detectMentionContext('hello world', 5)).toBeNull()
19
+ })
20
+
21
+ it('matches a fresh @ at the start of the field', () => {
22
+ expect(detectMentionContext('@ali', 4)).toEqual({ start: 0, end: 4, query: 'ali' })
23
+ })
24
+
25
+ it('matches @ after a leading space', () => {
26
+ expect(detectMentionContext('hey @bob', 8)).toEqual({ start: 4, end: 8, query: 'bob' })
27
+ })
28
+
29
+ it('opens immediately with an empty query the moment @ is typed', () => {
30
+ expect(detectMentionContext('@', 1)).toEqual({ start: 0, end: 1, query: '' })
31
+ })
32
+
33
+ it('does NOT open mid-word for email-like input (`foo@bar`)', () => {
34
+ expect(detectMentionContext('foo@bar', 7)).toBeNull()
35
+ })
36
+
37
+ it('terminates on a whitespace character between @ and caret', () => {
38
+ expect(detectMentionContext('@ali ce', 7)).toBeNull()
39
+ })
40
+
41
+ it('terminates on a non-mention punctuation character', () => {
42
+ expect(detectMentionContext('@ali.ce', 7)).toBeNull()
43
+ })
44
+
45
+ it('supports underscores and hyphens (id-shape charset)', () => {
46
+ expect(detectMentionContext('@user_id-42', 11)).toEqual({
47
+ start: 0,
48
+ end: 11,
49
+ query: 'user_id-42',
50
+ })
51
+ })
52
+
53
+ it('matches when the caret is mid-query (not at end of value)', () => {
54
+ // value: "@ali rest", caret after the `i`
55
+ expect(detectMentionContext('@ali rest', 4)).toEqual({ start: 0, end: 4, query: 'ali' })
56
+ })
57
+
58
+ it('handles newline as a boundary', () => {
59
+ expect(detectMentionContext('line\n@bob', 9)).toEqual({ start: 5, end: 9, query: 'bob' })
60
+ })
61
+
62
+ it('returns null when the caret is at offset 0', () => {
63
+ expect(detectMentionContext('@ali', 0)).toBeNull()
64
+ })
65
+
66
+ it('returns null when the caret is past the end of value', () => {
67
+ expect(detectMentionContext('@ali', 99)).toBeNull()
68
+ })
69
+
70
+ it('rejects queries that exceed the 64-char server cap', () => {
71
+ const long = 'a'.repeat(65)
72
+ expect(detectMentionContext(`@${long}`, 1 + long.length)).toBeNull()
73
+ })
74
+
75
+ it('returns null for non-string input (defensive)', () => {
76
+ // @ts-expect-error — runtime guard against caller misuse
77
+ expect(detectMentionContext(undefined, 0)).toBeNull()
78
+ })
79
+ })
80
+
81
+ describe('insertMentionToken', () => {
82
+ it('replaces the @<query> slice with @<userId> + trailing space', () => {
83
+ const ctx = { start: 0, end: 4, query: 'ali' }
84
+ expect(insertMentionToken('@ali rest', ctx, 'user_123')).toEqual({
85
+ value: '@user_123 rest',
86
+ caret: '@user_123 '.length,
87
+ })
88
+ })
89
+
90
+ it('does not duplicate a trailing space when one is already present', () => {
91
+ const ctx = { start: 0, end: 4, query: 'ali' }
92
+ const out = insertMentionToken('@ali extra', ctx, 'u1')
93
+ expect(out.value).toBe('@u1 extra')
94
+ expect(out.value.slice(out.caret)).toBe('extra')
95
+ })
96
+
97
+ it('preserves text before the @ token', () => {
98
+ const ctx = { start: 4, end: 8, query: 'bob' }
99
+ expect(insertMentionToken('hey @bob', ctx, 'u_99').value).toBe('hey @u_99 ')
100
+ })
101
+
102
+ it('places the caret immediately past the inserted token + space', () => {
103
+ const ctx = { start: 0, end: 1, query: '' }
104
+ const out = insertMentionToken('@', ctx, 'u_xyz')
105
+ expect(out.value).toBe('@u_xyz ')
106
+ expect(out.caret).toBe(out.value.length)
107
+ })
108
+ })
109
+
110
+ describe('searchMentionUsers', () => {
111
+ it('short-circuits an empty query without hitting the wire', async () => {
112
+ const out = await searchMentionUsers('')
113
+ expect(out).toEqual({ ok: true, result: [] })
114
+ expect(cmsApiMock).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('short-circuits a whitespace-only query', async () => {
118
+ const out = await searchMentionUsers(' ')
119
+ expect(out).toEqual({ ok: true, result: [] })
120
+ expect(cmsApiMock).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('URL-encodes the query and forwards the result array', async () => {
124
+ cmsApiMock.mockResolvedValueOnce({
125
+ data: [{ id: 'u-1', name: 'Alice', email: 'a@x.com' }],
126
+ status: 200,
127
+ })
128
+ const out = await searchMentionUsers('a li ce')
129
+ expect(cmsApiMock).toHaveBeenCalledWith(`/users/search?q=${encodeURIComponent('a li ce')}`, {
130
+ method: 'GET',
131
+ })
132
+ expect(out.ok).toBe(true)
133
+ if (!out.ok) return
134
+ expect(out.result.map((u) => u.id)).toEqual(['u-1'])
135
+ })
136
+
137
+ it('propagates server errors as { ok: false }', async () => {
138
+ cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 })
139
+ const out = await searchMentionUsers('ali')
140
+ expect(out.ok).toBe(false)
141
+ if (out.ok) return
142
+ expect(out.error).toBe('CSRF')
143
+ expect(out.status).toBe(403)
144
+ })
145
+ })