@codingfactory/socialkit-vue 0.7.23 → 0.7.24

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 (37) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/services/circles.d.ts +7 -0
  5. package/dist/services/circles.d.ts.map +1 -1
  6. package/dist/services/circles.js +34 -5
  7. package/dist/services/circles.js.map +1 -1
  8. package/dist/stores/__tests__/discussion.spec.d.ts +2 -0
  9. package/dist/stores/__tests__/discussion.spec.d.ts.map +1 -0
  10. package/dist/stores/__tests__/discussion.spec.js +768 -0
  11. package/dist/stores/__tests__/discussion.spec.js.map +1 -0
  12. package/dist/stores/circles.d.ts +144 -0
  13. package/dist/stores/circles.d.ts.map +1 -1
  14. package/dist/stores/circles.js +7 -1
  15. package/dist/stores/circles.js.map +1 -1
  16. package/dist/stores/content.d.ts.map +1 -1
  17. package/dist/stores/content.js +6 -3
  18. package/dist/stores/content.js.map +1 -1
  19. package/dist/stores/discussion.d.ts +714 -15
  20. package/dist/stores/discussion.d.ts.map +1 -1
  21. package/dist/stores/discussion.js +272 -65
  22. package/dist/stores/discussion.js.map +1 -1
  23. package/dist/types/content.d.ts +3 -2
  24. package/dist/types/content.d.ts.map +1 -1
  25. package/dist/types/content.js +2 -1
  26. package/dist/types/content.js.map +1 -1
  27. package/dist/types/discussion.d.ts +38 -0
  28. package/dist/types/discussion.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/index.ts +4 -0
  31. package/src/services/circles.ts +45 -5
  32. package/src/stores/__tests__/discussion.spec.ts +945 -0
  33. package/src/stores/circles.ts +7 -1
  34. package/src/stores/content.ts +6 -3
  35. package/src/stores/discussion.ts +333 -76
  36. package/src/types/content.ts +3 -2
  37. package/src/types/discussion.ts +43 -0
@@ -0,0 +1,945 @@
1
+ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type GenericAbortSignal, type InternalAxiosRequestConfig } from 'axios'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { ref } from 'vue'
5
+ import { createDiscussionStoreDefinition, type DiscussionStoreReturn } from '../discussion.js'
6
+ import type { DiscussionLoadingState, Reply, Space, Thread } from '../../types/discussion.js'
7
+
8
+ function createLoadingState(): DiscussionLoadingState {
9
+ const isLoading = ref(false)
10
+ const error = ref<Error | null>(null)
11
+ const isEmpty = ref(false)
12
+
13
+ return {
14
+ isLoading,
15
+ error,
16
+ isEmpty,
17
+ setLoading(loading: boolean): void {
18
+ isLoading.value = loading
19
+ },
20
+ setError(nextError: Error | null): void {
21
+ error.value = nextError
22
+ },
23
+ setEmpty(empty: boolean): void {
24
+ isEmpty.value = empty
25
+ },
26
+ }
27
+ }
28
+
29
+ function createDeferred<T>(): {
30
+ promise: Promise<T>
31
+ resolve: (value: T | PromiseLike<T>) => void
32
+ reject: (reason?: unknown) => void
33
+ } {
34
+ let resolve!: (value: T | PromiseLike<T>) => void
35
+ let reject!: (reason?: unknown) => void
36
+
37
+ const promise = new Promise<T>((innerResolve, innerReject) => {
38
+ resolve = innerResolve
39
+ reject = innerReject
40
+ })
41
+
42
+ return {
43
+ promise,
44
+ resolve,
45
+ reject,
46
+ }
47
+ }
48
+
49
+ function createResponse<T>(config: AxiosRequestConfig, data: T): AxiosResponse<T> {
50
+ return {
51
+ data,
52
+ status: 200,
53
+ statusText: 'OK',
54
+ headers: {},
55
+ // Axios response objects carry the same request config shape in practice;
56
+ // the tests only need a stable config echo, not internal adapter headers.
57
+ config: config as InternalAxiosRequestConfig,
58
+ }
59
+ }
60
+
61
+ function readRequestBody(config: AxiosRequestConfig): unknown {
62
+ if (typeof config.data === 'string') {
63
+ return JSON.parse(config.data) as unknown
64
+ }
65
+
66
+ return config.data
67
+ }
68
+
69
+ function createSpace(spaceId: string, slug: string, name: string): Space {
70
+ return {
71
+ id: spaceId,
72
+ slug,
73
+ name,
74
+ kind: 'forum',
75
+ created_at: '2026-03-08T06:00:00.000Z',
76
+ }
77
+ }
78
+
79
+ function createThread(threadId: string, overrides: Partial<Thread> = {}): Thread {
80
+ return {
81
+ id: threadId,
82
+ space_id: overrides.space_id ?? 'space-1',
83
+ author_id: overrides.author_id ?? 'author-1',
84
+ title: overrides.title ?? 'Thread title',
85
+ body: overrides.body ?? 'Thread body',
86
+ audience: overrides.audience ?? 'public',
87
+ status: overrides.status ?? 'open',
88
+ is_pinned: overrides.is_pinned ?? false,
89
+ reply_count: overrides.reply_count ?? 0,
90
+ last_activity_at: overrides.last_activity_at ?? '2026-03-08T06:23:34.000Z',
91
+ created_at: overrides.created_at ?? '2026-03-08T06:00:00.000Z',
92
+ ...(overrides.author ? { author: overrides.author } : {}),
93
+ ...(overrides.slug ? { slug: overrides.slug } : {}),
94
+ ...(overrides.body !== undefined ? { body: overrides.body } : {}),
95
+ ...(overrides.meta ? { meta: overrides.meta } : {}),
96
+ ...(overrides.space ? { space: overrides.space } : {}),
97
+ ...(overrides.latest_reply ? { latest_reply: overrides.latest_reply } : {}),
98
+ ...(overrides.tags ? { tags: overrides.tags } : {}),
99
+ ...(overrides.deleted_at !== undefined ? { deleted_at: overrides.deleted_at } : {}),
100
+ }
101
+ }
102
+
103
+ function createReply(replyId: string): Reply {
104
+ return {
105
+ id: replyId,
106
+ thread_id: 'thread-1',
107
+ author_id: 'author-1',
108
+ body: 'Reply body',
109
+ parent_reply_id: null,
110
+ depth: 0,
111
+ display_depth: 0,
112
+ created_at: '2026-03-08T06:10:00.000Z',
113
+ updated_at: '2026-03-08T06:10:00.000Z',
114
+ }
115
+ }
116
+
117
+ interface FakeEchoChannel {
118
+ listen: (event: string, callback: (payload: unknown) => void) => FakeEchoChannel
119
+ }
120
+
121
+ interface FakeEcho {
122
+ private: (channelName: string) => FakeEchoChannel
123
+ leave: (channelName: string) => void
124
+ }
125
+
126
+ interface CreateStoreOptions {
127
+ getEcho?: () => FakeEcho | null
128
+ }
129
+
130
+ function hasAbortListener(
131
+ signal: GenericAbortSignal | undefined,
132
+ ): signal is GenericAbortSignal & {
133
+ addEventListener: NonNullable<GenericAbortSignal['addEventListener']>
134
+ } {
135
+ return typeof signal?.addEventListener === 'function'
136
+ }
137
+
138
+ function createEchoHarness(): {
139
+ echo: FakeEcho
140
+ threadListeners: Record<string, (payload: unknown) => void>
141
+ privateMock: ReturnType<typeof vi.fn<(channelName: string) => FakeEchoChannel>>
142
+ leaveMock: ReturnType<typeof vi.fn<(channelName: string) => void>>
143
+ } {
144
+ const threadListeners: Record<string, (payload: unknown) => void> = {}
145
+ const channel: FakeEchoChannel = {
146
+ listen(event: string, callback: (payload: unknown) => void): FakeEchoChannel {
147
+ threadListeners[event] = callback
148
+ return channel
149
+ },
150
+ }
151
+ const privateMock = vi.fn<(channelName: string) => FakeEchoChannel>(() => channel)
152
+ const leaveMock = vi.fn<(channelName: string) => void>()
153
+
154
+ return {
155
+ echo: {
156
+ private: privateMock,
157
+ leave: leaveMock,
158
+ },
159
+ threadListeners,
160
+ privateMock,
161
+ leaveMock,
162
+ }
163
+ }
164
+
165
+ function createStore(client: AxiosInstance, options: CreateStoreOptions = {}): DiscussionStoreReturn {
166
+ const useDiscussionStore = createDiscussionStoreDefinition({
167
+ client,
168
+ getCurrentUserId: () => 'author-me',
169
+ getEcho: options.getEcho ?? (() => null),
170
+ onEchoInitialized: () => undefined,
171
+ onEchoReconnected: () => undefined,
172
+ createLoadingState,
173
+ logger: {
174
+ error: () => undefined,
175
+ },
176
+ storeId: `discussion-test-${Math.random().toString(36).slice(2)}`,
177
+ })
178
+
179
+ return useDiscussionStore()
180
+ }
181
+
182
+ describe('discussion store', () => {
183
+ beforeEach(() => {
184
+ setActivePinia(createPinia())
185
+ })
186
+
187
+ it('populates the latest browse list and normalizes reply counts', async () => {
188
+ const latestThread = createThread('thread-latest', { reply_count: 4 })
189
+
190
+ const client = axios.create({
191
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
192
+ if ((config.method ?? 'get').toLowerCase() === 'get' && config.url === '/v1/discussion/threads/latest') {
193
+ return createResponse(config, {
194
+ data: [latestThread],
195
+ next_cursor: 'cursor-latest-2',
196
+ })
197
+ }
198
+
199
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${config.url ?? ''}`)
200
+ },
201
+ })
202
+
203
+ const store = createStore(client)
204
+
205
+ await store.browseThreads('latest')
206
+
207
+ expect(store.currentBrowseMode).toBe('latest')
208
+ expect(store.browseThreadList).toHaveLength(1)
209
+ expect(store.browseThreadList[0]?.id).toBe('thread-latest')
210
+ expect(store.browseThreadList[0]?.reply_count).toBe(3)
211
+ expect(store.browseNextCursor).toBe('cursor-latest-2')
212
+ })
213
+
214
+ it('replaces the browse list when switching from latest to unanswered', async () => {
215
+ const latestThread = createThread('thread-latest')
216
+ const unansweredThread = createThread('thread-unanswered', { reply_count: 1 })
217
+
218
+ const client = axios.create({
219
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
220
+ const url = config.url ?? ''
221
+ if ((config.method ?? 'get').toLowerCase() !== 'get') {
222
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${url}`)
223
+ }
224
+
225
+ if (url === '/v1/discussion/threads/latest') {
226
+ return createResponse(config, {
227
+ data: [latestThread],
228
+ next_cursor: 'cursor-latest-2',
229
+ })
230
+ }
231
+
232
+ if (url === '/v1/discussion/threads/unanswered') {
233
+ return createResponse(config, {
234
+ data: [unansweredThread],
235
+ next_cursor: null,
236
+ })
237
+ }
238
+
239
+ throw new Error(`Unhandled request: GET ${url}`)
240
+ },
241
+ })
242
+
243
+ const store = createStore(client)
244
+
245
+ await store.browseThreads('latest')
246
+ await store.browseThreads('unanswered')
247
+
248
+ expect(store.currentBrowseMode).toBe('unanswered')
249
+ expect(store.browseThreadList).toHaveLength(1)
250
+ expect(store.browseThreadList[0]?.id).toBe('thread-unanswered')
251
+ expect(store.browseNextCursor).toBeNull()
252
+ })
253
+
254
+ it('loads a thread and subscribes to the thread realtime channel', async () => {
255
+ const threadId = '123e4567-e89b-42d3-a456-426614174000'
256
+ const space = createSpace('space-1', 'glass-space', 'Glass Space')
257
+ const echoHarness = createEchoHarness()
258
+
259
+ const client = axios.create({
260
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
261
+ if ((config.method ?? 'get').toLowerCase() === 'get' && config.url === `/v1/discussion/threads/${threadId}`) {
262
+ return createResponse(config, {
263
+ data: createThread(threadId, {
264
+ space_id: space.id,
265
+ reply_count: 4,
266
+ }),
267
+ })
268
+ }
269
+
270
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${config.url ?? ''}`)
271
+ },
272
+ })
273
+
274
+ const store = createStore(client, {
275
+ getEcho: () => echoHarness.echo,
276
+ })
277
+ store.spaces = [space]
278
+
279
+ await store.loadThread(space.slug, threadId)
280
+
281
+ expect(store.currentThread?.id).toBe(threadId)
282
+ expect(store.currentThread?.reply_count).toBe(3)
283
+ expect(echoHarness.privateMock).toHaveBeenCalledWith(`discussions.thread.${threadId}`)
284
+ })
285
+
286
+ it('keeps the shared loading state true until overlapping requests settle', async () => {
287
+ const spacesDeferred = createDeferred<AxiosResponse<unknown>>()
288
+ const searchDeferred = createDeferred<AxiosResponse<unknown>>()
289
+
290
+ const client = axios.create({
291
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
292
+ const url = config.url ?? ''
293
+ const method = (config.method ?? 'get').toLowerCase()
294
+
295
+ if (method === 'get' && url === '/v1/discussion/spaces?tree=1') {
296
+ return spacesDeferred.promise
297
+ }
298
+
299
+ if (method === 'get' && url.startsWith('/v1/discussion/search/threads?')) {
300
+ return searchDeferred.promise
301
+ }
302
+
303
+ throw new Error(`Unhandled request: ${method.toUpperCase()} ${url}`)
304
+ },
305
+ })
306
+
307
+ const store = createStore(client)
308
+
309
+ const loadSpacesPromise = store.loadSpaces()
310
+ const searchPromise = store.searchThreads('glass')
311
+
312
+ expect(store.loading).toBe(true)
313
+
314
+ spacesDeferred.resolve(createResponse({ url: '/v1/discussion/spaces?tree=1' }, { items: [] }))
315
+ await loadSpacesPromise
316
+
317
+ expect(store.loading).toBe(true)
318
+
319
+ searchDeferred.resolve(createResponse({ url: '/v1/discussion/search/threads?q=glass' }, { results: [] }))
320
+ await searchPromise
321
+
322
+ expect(store.loading).toBe(false)
323
+ })
324
+
325
+ it('loads spaces with scoped query parameters for management surfaces', async () => {
326
+ const scopedSpace = createSpace('space-1', 'group-root', 'Group Root')
327
+
328
+ const client = axios.create({
329
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
330
+ expect((config.method ?? 'get').toLowerCase()).toBe('get')
331
+ expect(config.url).toBe('/v1/discussion/spaces?tree=1&kind=forum&scope_id=circle-1')
332
+
333
+ return createResponse(config, {
334
+ items: [scopedSpace],
335
+ })
336
+ },
337
+ })
338
+
339
+ const store = createStore(client)
340
+
341
+ await store.loadSpaces({
342
+ kind: 'forum',
343
+ scope_id: 'circle-1',
344
+ })
345
+
346
+ expect(store.spaceTree).toHaveLength(1)
347
+ expect(store.spaceTree[0]?.slug).toBe('group-root')
348
+ expect(store.spaces).toHaveLength(1)
349
+ expect(store.spaces[0]?.id).toBe('space-1')
350
+ })
351
+
352
+ it('creates, moves, and reorders spaces through the management endpoints', async () => {
353
+ const requests: Array<{ method: string, url: string, body: unknown }> = []
354
+
355
+ const client = axios.create({
356
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
357
+ const method = (config.method ?? 'get').toLowerCase()
358
+ const url = config.url ?? ''
359
+ requests.push({
360
+ method,
361
+ url,
362
+ body: readRequestBody(config),
363
+ })
364
+
365
+ if (method === 'post' && url === '/v1/discussion/spaces') {
366
+ return createResponse(config, {
367
+ data: createSpace('space-1', 'tips', 'Tips'),
368
+ })
369
+ }
370
+
371
+ if (method === 'post' && url === '/v1/discussion/spaces/space-1/move') {
372
+ return createResponse(config, {
373
+ data: createSpace('space-1', 'tips', 'Tips'),
374
+ })
375
+ }
376
+
377
+ if (method === 'post' && url === '/v1/discussion/spaces/reorder') {
378
+ return createResponse(config, {
379
+ data: true,
380
+ })
381
+ }
382
+
383
+ throw new Error(`Unhandled request: ${method.toUpperCase()} ${url}`)
384
+ },
385
+ })
386
+
387
+ const store = createStore(client)
388
+
389
+ await store.createSpace({
390
+ slug: 'tips',
391
+ name: 'Tips',
392
+ kind: 'forum',
393
+ scope_type: 'circle',
394
+ scope_id: 'circle-1',
395
+ visibility: 'private',
396
+ parent_id: 'space-root',
397
+ description: 'Scoped discussion tips',
398
+ })
399
+ await store.moveSpace('space-1', 'space-root')
400
+ await store.reorderSpaces(['space-1', 'space-2'])
401
+
402
+ expect(requests).toEqual([
403
+ {
404
+ method: 'post',
405
+ url: '/v1/discussion/spaces',
406
+ body: {
407
+ slug: 'tips',
408
+ name: 'Tips',
409
+ kind: 'forum',
410
+ scope_type: 'circle',
411
+ scope_id: 'circle-1',
412
+ visibility: 'private',
413
+ parent_id: 'space-root',
414
+ description: 'Scoped discussion tips',
415
+ },
416
+ },
417
+ {
418
+ method: 'post',
419
+ url: '/v1/discussion/spaces/space-1/move',
420
+ body: {
421
+ parent_id: 'space-root',
422
+ },
423
+ },
424
+ {
425
+ method: 'post',
426
+ url: '/v1/discussion/spaces/reorder',
427
+ body: {
428
+ ids: ['space-1', 'space-2'],
429
+ },
430
+ },
431
+ ])
432
+ })
433
+
434
+ it('clears reply state and resets the reply sort before loading a thread', async () => {
435
+ const threadDeferred = createDeferred<AxiosResponse<unknown>>()
436
+ const space = createSpace('space-1', 'glass-space', 'Glass Space')
437
+ const threadId = '123e4567-e89b-42d3-a456-426614174000'
438
+
439
+ const client = axios.create({
440
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
441
+ const url = config.url ?? ''
442
+ const method = (config.method ?? 'get').toLowerCase()
443
+
444
+ if (method === 'get' && url === `/v1/discussion/threads/${threadId}`) {
445
+ return threadDeferred.promise
446
+ }
447
+
448
+ throw new Error(`Unhandled request: ${method.toUpperCase()} ${url}`)
449
+ },
450
+ })
451
+
452
+ const store = createStore(client)
453
+ store.spaces = [space]
454
+ store.currentSpace = space
455
+ store.replies = [createReply('reply-old')]
456
+ store.repliesNextCursor = 'cursor-1'
457
+ store.currentReplySort = 'new'
458
+
459
+ const loadThreadPromise = store.loadThread('glass-space', threadId)
460
+
461
+ expect(store.replies).toEqual([])
462
+ expect(store.repliesNextCursor).toBeNull()
463
+ expect(store.currentReplySort).toBe('best')
464
+ expect(store.loading).toBe(true)
465
+
466
+ threadDeferred.resolve(createResponse({ url: `/v1/discussion/threads/${threadId}` }, {
467
+ data: createThread(threadId, { space_id: space.id }),
468
+ }))
469
+ await loadThreadPromise
470
+
471
+ expect(store.currentThread?.id).toBe(threadId)
472
+ })
473
+
474
+ it('populates replies, stores the reply sort, and tracks the next cursor', async () => {
475
+ const threadId = 'thread-1'
476
+ const firstReply = createReply('reply-1')
477
+ const secondReply = createReply('reply-2')
478
+
479
+ const client = axios.create({
480
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
481
+ if ((config.method ?? 'get').toLowerCase() === 'get' && config.url === `/v1/discussion/threads/${threadId}/replies?sort=new&format=tree&flatten=1`) {
482
+ return createResponse(config, {
483
+ data: [firstReply, secondReply],
484
+ next_cursor: 'cursor-replies-2',
485
+ meta: {
486
+ total: 2,
487
+ },
488
+ })
489
+ }
490
+
491
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${config.url ?? ''}`)
492
+ },
493
+ })
494
+
495
+ const store = createStore(client)
496
+ store.currentThread = createThread(threadId, { body: '' })
497
+
498
+ await store.loadReplies(threadId, undefined, 'new')
499
+
500
+ expect(store.replies.map((reply) => reply.id)).toEqual(['reply-1', 'reply-2'])
501
+ expect(store.currentReplySort).toBe('new')
502
+ expect(store.repliesNextCursor).toBe('cursor-replies-2')
503
+ })
504
+
505
+ it('merges paginated replies without duplicating existing ids', async () => {
506
+ const threadId = 'thread-1'
507
+ const firstReply = createReply('reply-1')
508
+ const secondReply = createReply('reply-2')
509
+ const thirdReply = createReply('reply-3')
510
+
511
+ const client = axios.create({
512
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
513
+ const url = config.url ?? ''
514
+ if ((config.method ?? 'get').toLowerCase() !== 'get') {
515
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${url}`)
516
+ }
517
+
518
+ if (url === `/v1/discussion/threads/${threadId}/replies?sort=best&format=tree&flatten=1`) {
519
+ return createResponse(config, {
520
+ data: [firstReply, secondReply],
521
+ next_cursor: 'cursor-replies-2',
522
+ meta: {
523
+ total: 3,
524
+ },
525
+ })
526
+ }
527
+
528
+ if (url === `/v1/discussion/threads/${threadId}/replies?cursor=cursor-replies-2&sort=best&format=tree&flatten=1`) {
529
+ return createResponse(config, {
530
+ data: [secondReply, thirdReply],
531
+ next_cursor: null,
532
+ meta: {
533
+ total: 3,
534
+ },
535
+ })
536
+ }
537
+
538
+ throw new Error(`Unhandled request: GET ${url}`)
539
+ },
540
+ })
541
+
542
+ const store = createStore(client)
543
+ store.currentThread = createThread(threadId, { body: '' })
544
+
545
+ await store.loadReplies(threadId)
546
+ await store.loadReplies(threadId, 'cursor-replies-2')
547
+
548
+ expect(store.replies.map((reply) => reply.id)).toEqual(['reply-1', 'reply-2', 'reply-3'])
549
+ expect(store.repliesNextCursor).toBeNull()
550
+ })
551
+
552
+ it('adds a created reply to the active thread and increments reply counts', async () => {
553
+ const threadId = 'thread-1'
554
+ const createdReply = createReply('reply-created')
555
+
556
+ const client = axios.create({
557
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
558
+ if ((config.method ?? 'post').toLowerCase() === 'post' && config.url === `/v1/discussion/threads/${threadId}/replies`) {
559
+ return createResponse(config, {
560
+ data: createdReply,
561
+ })
562
+ }
563
+
564
+ throw new Error(`Unhandled request: ${(config.method ?? 'post').toUpperCase()} ${config.url ?? ''}`)
565
+ },
566
+ })
567
+
568
+ const store = createStore(client)
569
+ store.currentThread = createThread(threadId, { body: '', reply_count: 0 })
570
+ store.threads = [createThread(threadId, { body: '', reply_count: 0 })]
571
+
572
+ await store.createReply(threadId, {
573
+ body: 'Fresh reply',
574
+ })
575
+
576
+ expect(store.replies.map((reply) => reply.id)).toEqual(['reply-created'])
577
+ expect(store.currentThread?.reply_count).toBe(1)
578
+ expect(store.threads[0]?.reply_count).toBe(1)
579
+ })
580
+
581
+ it('inserts new realtime top-level replies at the top when the active sort is new', async () => {
582
+ const threadId = 'thread-1'
583
+ const firstReply = createReply('reply-1')
584
+ const realtimeReply = createReply('reply-live')
585
+ const echoHarness = createEchoHarness()
586
+
587
+ const client = axios.create({
588
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
589
+ if ((config.method ?? 'get').toLowerCase() === 'get' && config.url === `/v1/discussion/threads/${threadId}/replies?sort=new&format=tree&flatten=1`) {
590
+ return createResponse(config, {
591
+ data: [firstReply],
592
+ next_cursor: null,
593
+ meta: {
594
+ total: 1,
595
+ },
596
+ })
597
+ }
598
+
599
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${config.url ?? ''}`)
600
+ },
601
+ })
602
+
603
+ const store = createStore(client, {
604
+ getEcho: () => echoHarness.echo,
605
+ })
606
+ store.currentThread = createThread(threadId, { body: '', reply_count: 1 })
607
+
608
+ await store.loadReplies(threadId, undefined, 'new')
609
+ echoHarness.threadListeners['.Discussion.ReplyCreated']?.(realtimeReply)
610
+
611
+ expect(store.replies.map((reply) => reply.id)).toEqual(['reply-live', 'reply-1'])
612
+ })
613
+
614
+ it('inserts new realtime top-level replies at the bottom for non-new sorts', async () => {
615
+ const threadId = 'thread-1'
616
+ const firstReply = createReply('reply-1')
617
+ const realtimeReply = createReply('reply-live')
618
+ const echoHarness = createEchoHarness()
619
+
620
+ const client = axios.create({
621
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
622
+ if ((config.method ?? 'get').toLowerCase() === 'get' && config.url === `/v1/discussion/threads/${threadId}/replies?sort=best&format=tree&flatten=1`) {
623
+ return createResponse(config, {
624
+ data: [firstReply],
625
+ next_cursor: null,
626
+ meta: {
627
+ total: 1,
628
+ },
629
+ })
630
+ }
631
+
632
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${config.url ?? ''}`)
633
+ },
634
+ })
635
+
636
+ const store = createStore(client, {
637
+ getEcho: () => echoHarness.echo,
638
+ })
639
+ store.currentThread = createThread(threadId, { body: '', reply_count: 1 })
640
+
641
+ await store.loadReplies(threadId)
642
+ echoHarness.threadListeners['.Discussion.ReplyCreated']?.(realtimeReply)
643
+
644
+ expect(store.replies.map((reply) => reply.id)).toEqual(['reply-1', 'reply-live'])
645
+ })
646
+
647
+ it('does not set an error when a global thread search is aborted', async () => {
648
+ const client = axios.create({
649
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
650
+ const signal = config.signal
651
+ if (!hasAbortListener(signal)) {
652
+ throw new Error('Expected axios request signal support in test adapter')
653
+ }
654
+
655
+ return await new Promise<AxiosResponse>((_resolve, reject) => {
656
+ signal.addEventListener('abort', () => {
657
+ reject(new Error('Request aborted'))
658
+ }, { once: true })
659
+ })
660
+ },
661
+ })
662
+
663
+ const store = createStore(client)
664
+ const controller = new AbortController()
665
+
666
+ const searchPromise = store.searchThreadsGlobally('glass', { signal: controller.signal })
667
+ controller.abort()
668
+
669
+ await expect(searchPromise).resolves.toEqual([])
670
+ expect(store.error).toBeNull()
671
+ })
672
+
673
+ it('populates space summary on global search results when space fields are present', async () => {
674
+ const client = axios.create({
675
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
676
+ return createResponse(config, {
677
+ data: {
678
+ results: [
679
+ {
680
+ id: 'thread-gs-1',
681
+ title: 'Fume extraction setup',
682
+ space_id: 'space-safety',
683
+ space_name: 'Safety & Ventilation',
684
+ space_slug: 'safety-ventilation',
685
+ author_id: 'author-1',
686
+ author_name: 'Artist One',
687
+ author_handle: 'artist-one',
688
+ created_at: '2026-03-10T09:00:00.000Z',
689
+ updated_at: '2026-03-10T09:30:00.000Z',
690
+ replies_count: 4,
691
+ },
692
+ ],
693
+ },
694
+ })
695
+ },
696
+ })
697
+
698
+ const store = createStore(client)
699
+ const results = await store.searchThreadsGlobally('fume')
700
+
701
+ expect(results).toHaveLength(1)
702
+ expect(results[0]?.space).toEqual({
703
+ id: 'space-safety',
704
+ slug: 'safety-ventilation',
705
+ name: 'Safety & Ventilation',
706
+ })
707
+ })
708
+
709
+ it('forwards AbortSignal to in-space thread searches', async () => {
710
+ let capturedSignal: GenericAbortSignal | undefined
711
+ const controller = new AbortController()
712
+
713
+ const client = axios.create({
714
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
715
+ capturedSignal = config.signal
716
+
717
+ return createResponse(config, {
718
+ data: {
719
+ results: [
720
+ {
721
+ id: 'thread-search-1',
722
+ title: 'Torch settings for clean seals',
723
+ space_id: 'space-1',
724
+ author_id: 'author-1',
725
+ author_name: 'Artist One',
726
+ author_handle: 'artist-one',
727
+ created_at: '2026-03-08T06:00:00.000Z',
728
+ updated_at: '2026-03-08T06:10:00.000Z',
729
+ replies_count: 2,
730
+ },
731
+ ],
732
+ },
733
+ })
734
+ },
735
+ })
736
+
737
+ const store = createStore(client)
738
+
739
+ const invokeSearch = (
740
+ search: (query: string, spaceId: string, options?: { signal?: AbortSignal }) => Promise<Thread[]>,
741
+ ): Promise<Thread[]> => {
742
+ return search('torch', 'space-1', { signal: controller.signal })
743
+ }
744
+
745
+ const results = await invokeSearch(store.searchThreadsInSpace)
746
+
747
+ expect(capturedSignal).toBe(controller.signal)
748
+ expect(results.map((thread) => thread.id)).toEqual(['thread-search-1'])
749
+ })
750
+
751
+ it('sets a not-found error when loading a missing thread', async () => {
752
+ const threadId = 'missing-thread'
753
+ const client = axios.create({
754
+ adapter: async (_config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
755
+ throw {
756
+ isAxiosError: true,
757
+ response: {
758
+ status: 404,
759
+ },
760
+ }
761
+ },
762
+ })
763
+
764
+ const store = createStore(client)
765
+
766
+ await expect(store.loadThread('glass-space', threadId)).rejects.toMatchObject({
767
+ response: {
768
+ status: 404,
769
+ },
770
+ })
771
+ expect(store.error).toBe('Thread not found or has been deleted')
772
+ })
773
+
774
+ it('sets a permission error when loading a forbidden thread', async () => {
775
+ const threadId = 'forbidden-thread'
776
+ const client = axios.create({
777
+ adapter: async (_config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
778
+ throw {
779
+ isAxiosError: true,
780
+ response: {
781
+ status: 403,
782
+ },
783
+ }
784
+ },
785
+ })
786
+
787
+ const store = createStore(client)
788
+
789
+ await expect(store.loadThread('glass-space', threadId)).rejects.toMatchObject({
790
+ response: {
791
+ status: 403,
792
+ },
793
+ })
794
+ expect(store.error).toBe('You do not have permission to view this thread')
795
+ })
796
+
797
+ it('replaces stale destination space metadata when moving a thread', async () => {
798
+ const moveDeferred = createDeferred<AxiosResponse<unknown>>()
799
+ const sourceSpace = createSpace('space-source', 'source-space', 'Source Space')
800
+ const destinationSpace = createSpace('space-destination', 'destination-space', 'Destination Space')
801
+ const sourceSummary = {
802
+ id: sourceSpace.id,
803
+ slug: sourceSpace.slug,
804
+ name: sourceSpace.name,
805
+ }
806
+ const destinationSummary = {
807
+ id: destinationSpace.id,
808
+ slug: destinationSpace.slug,
809
+ name: destinationSpace.name,
810
+ }
811
+ const staleMovedThread = createThread('thread-1', {
812
+ space_id: destinationSpace.id,
813
+ space: sourceSummary,
814
+ })
815
+
816
+ const client = axios.create({
817
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
818
+ const url = config.url ?? ''
819
+ const method = (config.method ?? 'get').toLowerCase()
820
+
821
+ if (method === 'post' && url === '/v1/discussion/threads/thread-1/move') {
822
+ return moveDeferred.promise
823
+ }
824
+
825
+ throw new Error(`Unhandled request: ${method.toUpperCase()} ${url}`)
826
+ },
827
+ })
828
+
829
+ const store = createStore(client)
830
+ store.spaces = [sourceSpace, destinationSpace]
831
+ store.currentSpace = destinationSpace
832
+ store.currentThread = createThread('thread-1', {
833
+ space_id: sourceSpace.id,
834
+ space: sourceSummary,
835
+ })
836
+ store.threads = [
837
+ createThread('thread-1', {
838
+ space_id: sourceSpace.id,
839
+ space: sourceSummary,
840
+ }),
841
+ ]
842
+
843
+ const moveThreadPromise = store.moveThread('thread-1', 'destination-space')
844
+
845
+ moveDeferred.resolve(createResponse({ url: '/v1/discussion/threads/thread-1/move' }, {
846
+ data: staleMovedThread,
847
+ }))
848
+ await moveThreadPromise
849
+
850
+ expect(store.currentThread?.space).toEqual(destinationSummary)
851
+ expect(store.currentThread?.space_id).toBe(destinationSpace.id)
852
+ expect(store.threads[0]?.space).toEqual(destinationSummary)
853
+ expect(store.threads[0]?.space_id).toBe(destinationSpace.id)
854
+ })
855
+
856
+ it('preserves is_author flag when a realtime ReplyUpdated broadcast overwrites a reply', async () => {
857
+ const threadId = 'thread-1'
858
+ const replyWithAuthorFlag = {
859
+ ...createReply('reply-1'),
860
+ is_author: true,
861
+ }
862
+ const echoHarness = createEchoHarness()
863
+
864
+ const client = axios.create({
865
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
866
+ if ((config.method ?? 'get').toLowerCase() === 'get'
867
+ && config.url === `/v1/discussion/threads/${threadId}/replies?sort=best&format=tree&flatten=1`) {
868
+ return createResponse(config, {
869
+ data: [replyWithAuthorFlag],
870
+ next_cursor: null,
871
+ meta: { total: 1 },
872
+ })
873
+ }
874
+
875
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${config.url ?? ''}`)
876
+ },
877
+ })
878
+
879
+ const store = createStore(client, {
880
+ getEcho: () => echoHarness.echo,
881
+ })
882
+ store.currentThread = createThread(threadId, { body: '', reply_count: 1 })
883
+
884
+ await store.loadReplies(threadId)
885
+
886
+ // Verify the initial state has is_author: true
887
+ expect(Reflect.get(store.replies[0] as object, 'is_author')).toBe(true)
888
+
889
+ // Simulate a broadcast ReplyUpdated event without is_author (broadcasts
890
+ // cannot determine authorship because they run without an authenticated user)
891
+ const broadcastPayload = {
892
+ ...createReply('reply-1'),
893
+ body: 'Updated body from broadcast',
894
+ is_author: false,
895
+ }
896
+ echoHarness.threadListeners['.Discussion.ReplyUpdated']?.(broadcastPayload)
897
+
898
+ // The body should update but is_author must be preserved from the original
899
+ expect(store.replies[0]?.body).toBe('Updated body from broadcast')
900
+ expect(Reflect.get(store.replies[0] as object, 'is_author')).toBe(true)
901
+ })
902
+
903
+ it('preserves is_author flag when a realtime ReplyCreated broadcast updates an existing reply', async () => {
904
+ const threadId = 'thread-1'
905
+ const replyWithAuthorFlag = {
906
+ ...createReply('reply-1'),
907
+ is_author: true,
908
+ }
909
+ const echoHarness = createEchoHarness()
910
+
911
+ const client = axios.create({
912
+ adapter: async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
913
+ if ((config.method ?? 'get').toLowerCase() === 'get'
914
+ && config.url === `/v1/discussion/threads/${threadId}/replies?sort=best&format=tree&flatten=1`) {
915
+ return createResponse(config, {
916
+ data: [replyWithAuthorFlag],
917
+ next_cursor: null,
918
+ meta: { total: 1 },
919
+ })
920
+ }
921
+
922
+ throw new Error(`Unhandled request: ${(config.method ?? 'get').toUpperCase()} ${config.url ?? ''}`)
923
+ },
924
+ })
925
+
926
+ const store = createStore(client, {
927
+ getEcho: () => echoHarness.echo,
928
+ })
929
+ store.currentThread = createThread(threadId, { body: '', reply_count: 1 })
930
+
931
+ await store.loadReplies(threadId)
932
+
933
+ // Simulate a ReplyCreated broadcast for the same reply ID (duplicate delivery).
934
+ // The broadcast payload has is_author: false since it lacks user context.
935
+ const broadcastPayload = {
936
+ ...createReply('reply-1'),
937
+ body: 'Duplicate delivery',
938
+ is_author: false,
939
+ }
940
+ echoHarness.threadListeners['.Discussion.ReplyCreated']?.(broadcastPayload)
941
+
942
+ // is_author must be preserved even through insertReplyIntoActiveThread merge
943
+ expect(Reflect.get(store.replies[0] as object, 'is_author')).toBe(true)
944
+ })
945
+ })