@actuate-media/cms-admin 0.12.0 → 0.12.1
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/dist/__tests__/components/comment-mark.test.d.ts +2 -0
- package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-mark.test.js +89 -0
- package/dist/__tests__/components/comment-mark.test.js.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.js +291 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
- package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +200 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -0
- package/dist/__tests__/components/offline-status.test.d.ts +2 -0
- package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
- package/dist/__tests__/components/offline-status.test.js +56 -0
- package/dist/__tests__/components/offline-status.test.js.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
- package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
- package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/active-editor.test.js +57 -0
- package/dist/__tests__/lib/active-editor.test.js.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.js +173 -0
- package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
- package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.js +173 -0
- package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
- package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
- package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comments-client.test.js +202 -0
- package/dist/__tests__/lib/comments-client.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +155 -0
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
- package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
- package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
- package/dist/__tests__/lib/slash-command.test.js +123 -0
- package/dist/__tests__/lib/slash-command.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
- package/dist/components/AICoauthorBubbleMenu.js +113 -0
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +28 -0
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
- package/dist/components/CoauthorResultPopover.js +42 -0
- package/dist/components/CoauthorResultPopover.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +138 -40
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/CommentMark.d.ts +54 -0
- package/dist/components/CommentMark.d.ts.map +1 -0
- package/dist/components/CommentMark.js +104 -0
- package/dist/components/CommentMark.js.map +1 -0
- package/dist/components/CommentSidePanel.d.ts +86 -0
- package/dist/components/CommentSidePanel.d.ts.map +1 -0
- package/dist/components/CommentSidePanel.js +165 -0
- package/dist/components/CommentSidePanel.js.map +1 -0
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +80 -16
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +45 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +90 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/OfflineStatus.d.ts +39 -0
- package/dist/components/OfflineStatus.d.ts.map +1 -0
- package/dist/components/OfflineStatus.js +47 -0
- package/dist/components/OfflineStatus.js.map +1 -0
- package/dist/components/PresenceChips.d.ts +31 -0
- package/dist/components/PresenceChips.d.ts.map +1 -0
- package/dist/components/PresenceChips.js +58 -0
- package/dist/components/PresenceChips.js.map +1 -0
- package/dist/components/TipTapEditor.d.ts +61 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +211 -8
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/active-editor.d.ts +6 -0
- package/dist/lib/active-editor.d.ts.map +1 -0
- package/dist/lib/active-editor.js +35 -0
- package/dist/lib/active-editor.js.map +1 -0
- package/dist/lib/coauthor-client.d.ts +72 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -0
- package/dist/lib/coauthor-client.js +98 -0
- package/dist/lib/coauthor-client.js.map +1 -0
- package/dist/lib/collaboration-provider.d.ts +142 -0
- package/dist/lib/collaboration-provider.d.ts.map +1 -0
- package/dist/lib/collaboration-provider.js +168 -0
- package/dist/lib/collaboration-provider.js.map +1 -0
- package/dist/lib/comment-anchor.d.ts +86 -0
- package/dist/lib/comment-anchor.d.ts.map +1 -0
- package/dist/lib/comment-anchor.js +139 -0
- package/dist/lib/comment-anchor.js.map +1 -0
- package/dist/lib/comments-client.d.ts +106 -0
- package/dist/lib/comments-client.d.ts.map +1 -0
- package/dist/lib/comments-client.js +138 -0
- package/dist/lib/comments-client.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +68 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +85 -0
- package/dist/lib/notifications-client.js.map +1 -0
- package/dist/lib/slash-command.d.ts +39 -0
- package/dist/lib/slash-command.d.ts.map +1 -0
- package/dist/lib/slash-command.js +50 -0
- package/dist/lib/slash-command.js.map +1 -0
- package/package.json +12 -3
- package/src/__tests__/components/comment-mark.test.ts +99 -0
- package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
- package/src/__tests__/components/notification-bell.test.tsx +213 -0
- package/src/__tests__/components/offline-status.test.tsx +66 -0
- package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
- package/src/__tests__/lib/active-editor.test.ts +70 -0
- package/src/__tests__/lib/coauthor-client.test.ts +213 -0
- package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
- package/src/__tests__/lib/comment-anchor.test.ts +188 -0
- package/src/__tests__/lib/comments-client.test.ts +239 -0
- package/src/__tests__/lib/notifications-client.test.ts +195 -0
- package/src/__tests__/lib/slash-command.test.ts +146 -0
- package/src/components/AICoauthorBubbleMenu.tsx +187 -0
- package/src/components/CoauthorResultPopover.tsx +193 -0
- package/src/components/CommandPalette.tsx +397 -209
- package/src/components/CommentMark.ts +138 -0
- package/src/components/CommentSidePanel.tsx +521 -0
- package/src/components/LivePreview.tsx +117 -11
- package/src/components/NotificationBell.tsx +242 -0
- package/src/components/OfflineStatus.tsx +90 -0
- package/src/components/PresenceChips.tsx +150 -0
- package/src/components/TipTapEditor.tsx +310 -12
- package/src/index.ts +20 -0
- package/src/lib/active-editor.ts +39 -0
- package/src/lib/coauthor-client.ts +160 -0
- package/src/lib/collaboration-provider.ts +306 -0
- package/src/lib/comment-anchor.ts +179 -0
- package/src/lib/comments-client.ts +208 -0
- package/src/lib/notifications-client.ts +146 -0
- package/src/lib/slash-command.ts +67 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const cmsApiMock = vi.fn()
|
|
4
|
+
vi.mock('../../lib/api.js', () => ({
|
|
5
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
createComment,
|
|
10
|
+
deleteComment,
|
|
11
|
+
groupCommentsIntoThreads,
|
|
12
|
+
listComments,
|
|
13
|
+
reopenComment,
|
|
14
|
+
resolveComment,
|
|
15
|
+
updateComment,
|
|
16
|
+
} = await import('../../lib/comments-client.js')
|
|
17
|
+
import type { Comment } from '../../lib/comments-client.js'
|
|
18
|
+
|
|
19
|
+
function commentRow(overrides: Partial<Comment> = {}): Comment {
|
|
20
|
+
return {
|
|
21
|
+
id: 'c-1',
|
|
22
|
+
documentId: 'doc-1',
|
|
23
|
+
parentId: null,
|
|
24
|
+
userId: 'user-1',
|
|
25
|
+
body: 'hi',
|
|
26
|
+
anchor: null,
|
|
27
|
+
resolvedAt: null,
|
|
28
|
+
resolvedById: null,
|
|
29
|
+
deletedAt: null,
|
|
30
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
31
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
32
|
+
isResolved: false,
|
|
33
|
+
...overrides,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
cmsApiMock.mockReset()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('createComment', () => {
|
|
42
|
+
it('posts to the document scoped endpoint and unwraps the envelope', async () => {
|
|
43
|
+
const row = commentRow({ id: 'c-99', body: 'fresh' })
|
|
44
|
+
cmsApiMock.mockResolvedValueOnce({ data: row, status: 201 })
|
|
45
|
+
|
|
46
|
+
const out = await createComment({ documentId: 'doc-1', body: ' fresh ' })
|
|
47
|
+
expect(out.ok).toBe(true)
|
|
48
|
+
if (!out.ok) return
|
|
49
|
+
expect(out.result.id).toBe('c-99')
|
|
50
|
+
|
|
51
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/documents/doc-1/comments', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
body: JSON.stringify({ body: 'fresh' }),
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('forwards anchor + parentId when supplied', async () => {
|
|
58
|
+
cmsApiMock.mockResolvedValueOnce({ data: commentRow(), status: 201 })
|
|
59
|
+
await createComment({
|
|
60
|
+
documentId: 'doc-1',
|
|
61
|
+
body: 'reply',
|
|
62
|
+
anchor: { from: 'AQ==', to: 'Ag==' },
|
|
63
|
+
parentId: 'c-1',
|
|
64
|
+
})
|
|
65
|
+
const [, init] = cmsApiMock.mock.calls[0] as [string, RequestInit]
|
|
66
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
67
|
+
body: 'reply',
|
|
68
|
+
anchor: { from: 'AQ==', to: 'Ag==' },
|
|
69
|
+
parentId: 'c-1',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rejects empty bodies before hitting the wire', async () => {
|
|
74
|
+
const out = await createComment({ documentId: 'doc-1', body: ' ' })
|
|
75
|
+
expect(out.ok).toBe(false)
|
|
76
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects missing documentId before hitting the wire', async () => {
|
|
80
|
+
const out = await createComment({ documentId: '', body: 'hi' })
|
|
81
|
+
expect(out.ok).toBe(false)
|
|
82
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('URL-encodes documentIds with awkward characters', async () => {
|
|
86
|
+
cmsApiMock.mockResolvedValueOnce({ data: commentRow(), status: 201 })
|
|
87
|
+
await createComment({ documentId: 'doc with space/?', body: 'hi' })
|
|
88
|
+
expect(cmsApiMock).toHaveBeenCalledWith(
|
|
89
|
+
`/documents/${encodeURIComponent('doc with space/?')}/comments`,
|
|
90
|
+
expect.anything(),
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('propagates server errors as { ok: false }', async () => {
|
|
95
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'CSRF rejected', status: 403 })
|
|
96
|
+
const out = await createComment({ documentId: 'doc-1', body: 'hi' })
|
|
97
|
+
expect(out.ok).toBe(false)
|
|
98
|
+
if (out.ok) return
|
|
99
|
+
expect(out.status).toBe(403)
|
|
100
|
+
expect(out.error).toBe('CSRF rejected')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('falls back to a generic message when the server omits one', async () => {
|
|
104
|
+
cmsApiMock.mockResolvedValueOnce({ status: 500 })
|
|
105
|
+
const out = await createComment({ documentId: 'doc-1', body: 'hi' })
|
|
106
|
+
expect(out.ok).toBe(false)
|
|
107
|
+
if (out.ok) return
|
|
108
|
+
expect(out.error).toContain('500')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('listComments', () => {
|
|
113
|
+
it('GETs the document endpoint with no query when no filters are set', async () => {
|
|
114
|
+
cmsApiMock.mockResolvedValueOnce({ data: [commentRow()], status: 200 })
|
|
115
|
+
const out = await listComments('doc-1')
|
|
116
|
+
expect(out.ok).toBe(true)
|
|
117
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/documents/doc-1/comments', { method: 'GET' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('appends ?includeResolved + ?includeDeleted + ?pageSize when set', async () => {
|
|
121
|
+
cmsApiMock.mockResolvedValueOnce({ data: [], status: 200 })
|
|
122
|
+
await listComments('doc-1', { includeResolved: true, includeDeleted: true, pageSize: 25 })
|
|
123
|
+
expect(cmsApiMock).toHaveBeenCalledWith(
|
|
124
|
+
'/documents/doc-1/comments?includeResolved=true&includeDeleted=true&pageSize=25',
|
|
125
|
+
{ method: 'GET' },
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('rejects empty documentId before hitting the wire', async () => {
|
|
130
|
+
const out = await listComments('')
|
|
131
|
+
expect(out.ok).toBe(false)
|
|
132
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns the data array unchanged', async () => {
|
|
136
|
+
const rows = [commentRow({ id: 'c-1' }), commentRow({ id: 'c-2' })]
|
|
137
|
+
cmsApiMock.mockResolvedValueOnce({ data: rows, status: 200 })
|
|
138
|
+
const out = await listComments('doc-1')
|
|
139
|
+
expect(out.ok).toBe(true)
|
|
140
|
+
if (!out.ok) return
|
|
141
|
+
expect(out.result.map((c) => c.id)).toEqual(['c-1', 'c-2'])
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('updateComment / resolveComment / reopenComment / deleteComment', () => {
|
|
146
|
+
it('PATCHes the comment endpoint with the trimmed body', async () => {
|
|
147
|
+
cmsApiMock.mockResolvedValueOnce({ data: commentRow({ body: 'next' }), status: 200 })
|
|
148
|
+
await updateComment('c-1', ' next ')
|
|
149
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/comments/c-1', {
|
|
150
|
+
method: 'PATCH',
|
|
151
|
+
body: JSON.stringify({ body: 'next' }),
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('rejects empty bodies on update', async () => {
|
|
156
|
+
const out = await updateComment('c-1', ' ')
|
|
157
|
+
expect(out.ok).toBe(false)
|
|
158
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('POSTs to /resolve and /reopen with an empty body', async () => {
|
|
162
|
+
cmsApiMock
|
|
163
|
+
.mockResolvedValueOnce({
|
|
164
|
+
data: commentRow({ resolvedAt: 'now', isResolved: true }),
|
|
165
|
+
status: 200,
|
|
166
|
+
})
|
|
167
|
+
.mockResolvedValueOnce({ data: commentRow(), status: 200 })
|
|
168
|
+
|
|
169
|
+
await resolveComment('c-1')
|
|
170
|
+
await reopenComment('c-1')
|
|
171
|
+
|
|
172
|
+
expect(cmsApiMock).toHaveBeenNthCalledWith(1, '/comments/c-1/resolve', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
body: '{}',
|
|
175
|
+
})
|
|
176
|
+
expect(cmsApiMock).toHaveBeenNthCalledWith(2, '/comments/c-1/reopen', {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
body: '{}',
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('DELETEs the comment endpoint and returns the soft-deleted DTO', async () => {
|
|
183
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
184
|
+
data: commentRow({ deletedAt: '2026-01-02T00:00:00.000Z' }),
|
|
185
|
+
status: 200,
|
|
186
|
+
})
|
|
187
|
+
const out = await deleteComment('c-1')
|
|
188
|
+
expect(out.ok).toBe(true)
|
|
189
|
+
if (!out.ok) return
|
|
190
|
+
expect(out.result.deletedAt).toBe('2026-01-02T00:00:00.000Z')
|
|
191
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/comments/c-1', { method: 'DELETE' })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('rejects missing id without hitting the wire', async () => {
|
|
195
|
+
expect((await updateComment('', 'x')).ok).toBe(false)
|
|
196
|
+
expect((await resolveComment('')).ok).toBe(false)
|
|
197
|
+
expect((await reopenComment('')).ok).toBe(false)
|
|
198
|
+
expect((await deleteComment('')).ok).toBe(false)
|
|
199
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('groupCommentsIntoThreads', () => {
|
|
204
|
+
it('returns roots in chronological order with their replies attached', () => {
|
|
205
|
+
const root1 = commentRow({
|
|
206
|
+
id: 'r-1',
|
|
207
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
208
|
+
})
|
|
209
|
+
const root2 = commentRow({
|
|
210
|
+
id: 'r-2',
|
|
211
|
+
createdAt: '2026-01-01T00:00:02.000Z',
|
|
212
|
+
})
|
|
213
|
+
const reply1 = commentRow({
|
|
214
|
+
id: 'reply-1',
|
|
215
|
+
parentId: 'r-1',
|
|
216
|
+
createdAt: '2026-01-01T00:00:01.000Z',
|
|
217
|
+
})
|
|
218
|
+
const reply2 = commentRow({
|
|
219
|
+
id: 'reply-2',
|
|
220
|
+
parentId: 'r-1',
|
|
221
|
+
createdAt: '2026-01-01T00:00:03.000Z',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const threads = groupCommentsIntoThreads([reply2, root1, root2, reply1])
|
|
225
|
+
expect(threads.map((t) => t.root.id)).toEqual(['r-1', 'r-2'])
|
|
226
|
+
expect(threads[0]!.replies.map((r) => r.id)).toEqual(['reply-1', 'reply-2'])
|
|
227
|
+
expect(threads[1]!.replies).toEqual([])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('drops replies whose root is missing from the input', () => {
|
|
231
|
+
const orphan = commentRow({ id: 'orphan', parentId: 'missing-root' })
|
|
232
|
+
const threads = groupCommentsIntoThreads([orphan])
|
|
233
|
+
expect(threads).toEqual([])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('handles an empty input', () => {
|
|
237
|
+
expect(groupCommentsIntoThreads([])).toEqual([])
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
}))
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
listNotifications,
|
|
10
|
+
markAllNotificationsRead,
|
|
11
|
+
markNotificationRead,
|
|
12
|
+
notificationSummary,
|
|
13
|
+
unreadCount,
|
|
14
|
+
} = await import('../../lib/notifications-client.js')
|
|
15
|
+
import type { Notification } from '../../lib/notifications-client.js'
|
|
16
|
+
|
|
17
|
+
function row(overrides: Partial<Notification> = {}): Notification {
|
|
18
|
+
return {
|
|
19
|
+
id: 'n-1',
|
|
20
|
+
userId: 'user-1',
|
|
21
|
+
kind: 'comment_reply',
|
|
22
|
+
documentId: 'doc-1',
|
|
23
|
+
commentId: 'c-1',
|
|
24
|
+
payload: {
|
|
25
|
+
kind: 'comment_reply',
|
|
26
|
+
actorId: 'someone',
|
|
27
|
+
actorName: 'Someone',
|
|
28
|
+
excerpt: 'hi',
|
|
29
|
+
},
|
|
30
|
+
readAt: null,
|
|
31
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
32
|
+
...overrides,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
cmsApiMock.mockReset()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('listNotifications', () => {
|
|
41
|
+
it('GETs /notifications without query when no options', async () => {
|
|
42
|
+
cmsApiMock.mockResolvedValueOnce({ data: [row()], status: 200 })
|
|
43
|
+
const out = await listNotifications()
|
|
44
|
+
expect(out.ok).toBe(true)
|
|
45
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications', { method: 'GET' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('appends ?includeRead and ?pageSize when set', async () => {
|
|
49
|
+
cmsApiMock.mockResolvedValueOnce({ data: [], status: 200 })
|
|
50
|
+
await listNotifications({ includeRead: true, pageSize: 25 })
|
|
51
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications?includeRead=true&pageSize=25', {
|
|
52
|
+
method: 'GET',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns the data array unchanged', async () => {
|
|
57
|
+
const rows = [row({ id: 'a' }), row({ id: 'b' })]
|
|
58
|
+
cmsApiMock.mockResolvedValueOnce({ data: rows, status: 200 })
|
|
59
|
+
const out = await listNotifications()
|
|
60
|
+
expect(out.ok).toBe(true)
|
|
61
|
+
if (!out.ok) return
|
|
62
|
+
expect(out.result.map((n) => n.id)).toEqual(['a', 'b'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('propagates errors as { ok: false }', async () => {
|
|
66
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'nope', status: 500 })
|
|
67
|
+
const out = await listNotifications()
|
|
68
|
+
expect(out.ok).toBe(false)
|
|
69
|
+
if (out.ok) return
|
|
70
|
+
expect(out.error).toBe('nope')
|
|
71
|
+
expect(out.status).toBe(500)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('unreadCount', () => {
|
|
76
|
+
it('returns the count from the envelope', async () => {
|
|
77
|
+
cmsApiMock.mockResolvedValueOnce({ data: { count: 7 }, status: 200 })
|
|
78
|
+
const out = await unreadCount()
|
|
79
|
+
expect(out.ok).toBe(true)
|
|
80
|
+
if (!out.ok) return
|
|
81
|
+
expect(out.result).toBe(7)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('propagates failure when the server returns no data', async () => {
|
|
85
|
+
cmsApiMock.mockResolvedValueOnce({ status: 503 })
|
|
86
|
+
const out = await unreadCount()
|
|
87
|
+
expect(out.ok).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('markNotificationRead', () => {
|
|
92
|
+
it('POSTs to the id-specific endpoint', async () => {
|
|
93
|
+
cmsApiMock.mockResolvedValueOnce({ data: row({ readAt: 'now' }), status: 200 })
|
|
94
|
+
const out = await markNotificationRead('n-1')
|
|
95
|
+
expect(out.ok).toBe(true)
|
|
96
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications/n-1/read', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
body: '{}',
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('URL-encodes weird ids', async () => {
|
|
103
|
+
cmsApiMock.mockResolvedValueOnce({ data: row(), status: 200 })
|
|
104
|
+
await markNotificationRead('n/1?')
|
|
105
|
+
expect(cmsApiMock).toHaveBeenCalledWith(
|
|
106
|
+
`/notifications/${encodeURIComponent('n/1?')}/read`,
|
|
107
|
+
expect.anything(),
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('rejects an empty id without hitting the wire', async () => {
|
|
112
|
+
const out = await markNotificationRead('')
|
|
113
|
+
expect(out.ok).toBe(false)
|
|
114
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('markAllNotificationsRead', () => {
|
|
119
|
+
it('POSTs /notifications/read-all and returns the count', async () => {
|
|
120
|
+
cmsApiMock.mockResolvedValueOnce({ data: { count: 4 }, status: 200 })
|
|
121
|
+
const out = await markAllNotificationsRead()
|
|
122
|
+
expect(out.ok).toBe(true)
|
|
123
|
+
if (!out.ok) return
|
|
124
|
+
expect(out.result).toBe(4)
|
|
125
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/notifications/read-all', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
body: '{}',
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('propagates a server error', async () => {
|
|
132
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 })
|
|
133
|
+
const out = await markAllNotificationsRead()
|
|
134
|
+
expect(out.ok).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('notificationSummary', () => {
|
|
139
|
+
it('formats comment_reply with the actor + excerpt', () => {
|
|
140
|
+
expect(
|
|
141
|
+
notificationSummary(
|
|
142
|
+
row({
|
|
143
|
+
payload: {
|
|
144
|
+
kind: 'comment_reply',
|
|
145
|
+
actorId: 'a',
|
|
146
|
+
actorName: 'Alice',
|
|
147
|
+
excerpt: 'good point',
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
).toBe('Alice replied: "good point"')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('formats comment_resolved with rootExcerpt', () => {
|
|
155
|
+
expect(
|
|
156
|
+
notificationSummary(
|
|
157
|
+
row({
|
|
158
|
+
kind: 'comment_resolved',
|
|
159
|
+
payload: {
|
|
160
|
+
kind: 'comment_resolved',
|
|
161
|
+
actorId: 'a',
|
|
162
|
+
actorName: 'Bob',
|
|
163
|
+
rootExcerpt: 'fix this',
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
).toBe('Bob resolved your thread: "fix this"')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('formats comment_mention with excerpt', () => {
|
|
171
|
+
expect(
|
|
172
|
+
notificationSummary(
|
|
173
|
+
row({
|
|
174
|
+
kind: 'comment_mention',
|
|
175
|
+
payload: {
|
|
176
|
+
kind: 'comment_mention',
|
|
177
|
+
actorId: 'a',
|
|
178
|
+
actorName: 'Carol',
|
|
179
|
+
excerpt: 'cc @you',
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
),
|
|
183
|
+
).toBe('Carol mentioned you: "cc @you"')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('falls back to "Someone" when actorName is null', () => {
|
|
187
|
+
expect(
|
|
188
|
+
notificationSummary(
|
|
189
|
+
row({
|
|
190
|
+
payload: { kind: 'comment_reply', actorId: null, actorName: null, excerpt: 'x' },
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
).toMatch(/^Someone replied/)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
parseAiSlashCommand,
|
|
4
|
+
isAiSlashCommandInput,
|
|
5
|
+
orderedAiActions,
|
|
6
|
+
} from '../../lib/slash-command.js'
|
|
7
|
+
|
|
8
|
+
describe('parseAiSlashCommand', () => {
|
|
9
|
+
it('parses /ai rewrite <text>', () => {
|
|
10
|
+
expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
|
|
11
|
+
action: 'rewrite',
|
|
12
|
+
text: 'hello world',
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses every canonical action', () => {
|
|
17
|
+
expect(parseAiSlashCommand('/ai expand foo')).toEqual({ action: 'expand', text: 'foo' })
|
|
18
|
+
expect(parseAiSlashCommand('/ai compress bar')).toEqual({ action: 'compress', text: 'bar' })
|
|
19
|
+
expect(parseAiSlashCommand('/ai proofread baz')).toEqual({ action: 'proofread', text: 'baz' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('is case-insensitive on both prefix and action', () => {
|
|
23
|
+
expect(parseAiSlashCommand('/AI Rewrite ALL CAPS')).toEqual({
|
|
24
|
+
action: 'rewrite',
|
|
25
|
+
text: 'ALL CAPS',
|
|
26
|
+
})
|
|
27
|
+
expect(parseAiSlashCommand('/Ai PROOFREAD x')).toEqual({ action: 'proofread', text: 'x' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('tolerates leading whitespace', () => {
|
|
31
|
+
expect(parseAiSlashCommand(' /ai rewrite x')).toEqual({ action: 'rewrite', text: 'x' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns empty text when nothing follows the action', () => {
|
|
35
|
+
expect(parseAiSlashCommand('/ai rewrite')).toEqual({ action: 'rewrite', text: '' })
|
|
36
|
+
expect(parseAiSlashCommand('/ai rewrite ')).toEqual({ action: 'rewrite', text: '' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('trims surrounding whitespace from the payload', () => {
|
|
40
|
+
expect(parseAiSlashCommand('/ai rewrite hello ')).toEqual({
|
|
41
|
+
action: 'rewrite',
|
|
42
|
+
text: 'hello',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('preserves interior whitespace in the payload', () => {
|
|
47
|
+
expect(parseAiSlashCommand('/ai rewrite hello world')).toEqual({
|
|
48
|
+
action: 'rewrite',
|
|
49
|
+
text: 'hello world',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns null for unknown actions', () => {
|
|
54
|
+
expect(parseAiSlashCommand('/ai summarize foo')).toBeNull()
|
|
55
|
+
expect(parseAiSlashCommand('/ai delete foo')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns null for non-AI slash commands', () => {
|
|
59
|
+
expect(parseAiSlashCommand('/help')).toBeNull()
|
|
60
|
+
expect(parseAiSlashCommand('/admin x')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns null for plain input without a slash', () => {
|
|
64
|
+
expect(parseAiSlashCommand('ai rewrite hello')).toBeNull()
|
|
65
|
+
expect(parseAiSlashCommand('hello world')).toBeNull()
|
|
66
|
+
expect(parseAiSlashCommand('')).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns null when /ai is the entire input', () => {
|
|
70
|
+
expect(parseAiSlashCommand('/ai')).toBeNull()
|
|
71
|
+
expect(parseAiSlashCommand('/ai ')).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns null for non-string inputs', () => {
|
|
75
|
+
// @ts-expect-error — runtime guard
|
|
76
|
+
expect(parseAiSlashCommand(null)).toBeNull()
|
|
77
|
+
// @ts-expect-error — runtime guard
|
|
78
|
+
expect(parseAiSlashCommand(undefined)).toBeNull()
|
|
79
|
+
// @ts-expect-error — runtime guard
|
|
80
|
+
expect(parseAiSlashCommand(42)).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('isAiSlashCommandInput', () => {
|
|
85
|
+
it('returns true while the user is mid-typing the prefix', () => {
|
|
86
|
+
expect(isAiSlashCommandInput('/')).toBe(true)
|
|
87
|
+
expect(isAiSlashCommandInput('/a')).toBe(true)
|
|
88
|
+
expect(isAiSlashCommandInput('/ai')).toBe(true)
|
|
89
|
+
expect(isAiSlashCommandInput('/ai ')).toBe(true)
|
|
90
|
+
expect(isAiSlashCommandInput('/ai re')).toBe(true)
|
|
91
|
+
expect(isAiSlashCommandInput('/ai rewrite something')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns true when the user typed AI with capitals', () => {
|
|
95
|
+
expect(isAiSlashCommandInput('/AI')).toBe(true)
|
|
96
|
+
expect(isAiSlashCommandInput('/Ai rew')).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns false for ordinary text', () => {
|
|
100
|
+
expect(isAiSlashCommandInput('')).toBe(false)
|
|
101
|
+
expect(isAiSlashCommandInput('hello')).toBe(false)
|
|
102
|
+
expect(isAiSlashCommandInput('ai rewrite')).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns false for non-AI slash inputs', () => {
|
|
106
|
+
expect(isAiSlashCommandInput('/help')).toBe(false)
|
|
107
|
+
expect(isAiSlashCommandInput('/bots')).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('orderedAiActions', () => {
|
|
112
|
+
it('returns the canonical order when nothing is parsed', () => {
|
|
113
|
+
expect(orderedAiActions('')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
|
|
114
|
+
expect(orderedAiActions('/ai')).toEqual(['rewrite', 'expand', 'compress', 'proofread'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('hoists the matched action to the front', () => {
|
|
118
|
+
expect(orderedAiActions('/ai compress foo')).toEqual([
|
|
119
|
+
'compress',
|
|
120
|
+
'rewrite',
|
|
121
|
+
'expand',
|
|
122
|
+
'proofread',
|
|
123
|
+
])
|
|
124
|
+
expect(orderedAiActions('/ai proofread bar')).toEqual([
|
|
125
|
+
'proofread',
|
|
126
|
+
'rewrite',
|
|
127
|
+
'expand',
|
|
128
|
+
'compress',
|
|
129
|
+
])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('returns canonical order for an unknown action', () => {
|
|
133
|
+
expect(orderedAiActions('/ai summarize foo')).toEqual([
|
|
134
|
+
'rewrite',
|
|
135
|
+
'expand',
|
|
136
|
+
'compress',
|
|
137
|
+
'proofread',
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('contains every canonical action exactly once', () => {
|
|
142
|
+
const result = orderedAiActions('/ai rewrite x')
|
|
143
|
+
expect(result.length).toBe(4)
|
|
144
|
+
expect(new Set(result).size).toBe(4)
|
|
145
|
+
})
|
|
146
|
+
})
|