Barazo AppView backend
barazo.forum
1import { describe, it, expect, vi, beforeEach } from 'vitest'
2import { createNotificationService, extractMentions } from '../../../src/services/notification.js'
3import type { NotificationService } from '../../../src/services/notification.js'
4import { createMockDb, createChainableProxy, resetDbMocks } from '../../helpers/mock-db.js'
5import type { MockDb } from '../../helpers/mock-db.js'
6
7// ---------------------------------------------------------------------------
8// Test constants
9// ---------------------------------------------------------------------------
10
11const ACTOR_DID = 'did:plc:actor123'
12const TOPIC_AUTHOR_DID = 'did:plc:topicauthor456'
13const REPLY_AUTHOR_DID = 'did:plc:replyauthor789'
14const MODERATOR_DID = 'did:plc:mod999'
15const COMMUNITY_DID = 'did:plc:community123'
16
17const TOPIC_URI = `at://${TOPIC_AUTHOR_DID}/forum.barazo.topic.post/topic1`
18const REPLY_URI = `at://${ACTOR_DID}/forum.barazo.topic.reply/reply1`
19const PARENT_REPLY_URI = `at://${REPLY_AUTHOR_DID}/forum.barazo.topic.reply/parentreply1`
20
21// ---------------------------------------------------------------------------
22// Mock logger
23// ---------------------------------------------------------------------------
24
25const mockLogger = {
26 info: vi.fn(),
27 warn: vi.fn(),
28 error: vi.fn(),
29 debug: vi.fn(),
30 fatal: vi.fn(),
31 trace: vi.fn(),
32 child: vi.fn(() => mockLogger),
33 level: 'info',
34 silent: vi.fn(),
35}
36
37// ---------------------------------------------------------------------------
38// Setup
39// ---------------------------------------------------------------------------
40
41let mockDb: MockDb
42let service: NotificationService
43
44beforeEach(() => {
45 vi.clearAllMocks()
46 mockDb = createMockDb()
47 resetDbMocks(mockDb)
48 service = createNotificationService(mockDb as never, mockLogger as never)
49})
50
51// ===========================================================================
52// extractMentions
53// ===========================================================================
54
55describe('extractMentions', () => {
56 it('extracts single AT Protocol handle', () => {
57 const result = extractMentions('Hello @jay.bsky.team, welcome!')
58 expect(result).toEqual(['jay.bsky.team'])
59 })
60
61 it('extracts multiple handles', () => {
62 const result = extractMentions('cc @jay.bsky.team @alex.example.com')
63 expect(result).toEqual(['jay.bsky.team', 'alex.example.com'])
64 })
65
66 it('deduplicates handles (case-insensitive)', () => {
67 const result = extractMentions('@Jay.Bsky.Team and @jay.bsky.team')
68 expect(result).toEqual(['jay.bsky.team'])
69 })
70
71 it('ignores bare @word without a dot', () => {
72 const result = extractMentions('Hello @everyone, this is a test')
73 expect(result).toEqual([])
74 })
75
76 it('limits to 10 unique mentions', () => {
77 const handles = Array.from({ length: 15 }, (_, i) => `@user${String(i)}.bsky.social`)
78 const content = handles.join(' ')
79 const result = extractMentions(content)
80 expect(result).toHaveLength(10)
81 })
82
83 it('returns empty array for content without mentions', () => {
84 const result = extractMentions('No mentions here at all.')
85 expect(result).toEqual([])
86 })
87
88 it('handles handles with hyphens', () => {
89 const result = extractMentions('Hey @my-handle.bsky.social')
90 expect(result).toEqual(['my-handle.bsky.social'])
91 })
92
93 it('handles handles with subdomains', () => {
94 const result = extractMentions('@user.example.co.uk mentioned')
95 expect(result).toEqual(['user.example.co.uk'])
96 })
97})
98
99// ===========================================================================
100// notifyOnReply
101// ===========================================================================
102
103describe('notifyOnReply', () => {
104 it('notifies topic author when someone replies', async () => {
105 // Mock: select topic author
106 const selectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }])
107 mockDb.select.mockReturnValue(selectChain)
108
109 // Mock: insert notification
110 const insertChain = createChainableProxy()
111 mockDb.insert.mockReturnValue(insertChain)
112
113 await service.notifyOnReply({
114 replyUri: REPLY_URI,
115 actorDid: ACTOR_DID,
116 topicUri: TOPIC_URI,
117 parentUri: TOPIC_URI, // direct reply to topic
118 communityDid: COMMUNITY_DID,
119 })
120
121 expect(mockDb.insert).toHaveBeenCalled()
122 })
123
124 it('does not notify when replying to own topic', async () => {
125 // Actor IS the topic author
126 const selectChain = createChainableProxy([{ authorDid: ACTOR_DID }])
127 mockDb.select.mockReturnValue(selectChain)
128
129 const insertChain = createChainableProxy()
130 mockDb.insert.mockReturnValue(insertChain)
131
132 await service.notifyOnReply({
133 replyUri: REPLY_URI,
134 actorDid: ACTOR_DID,
135 topicUri: `at://${ACTOR_DID}/forum.barazo.topic.post/topic1`,
136 parentUri: `at://${ACTOR_DID}/forum.barazo.topic.post/topic1`,
137 communityDid: COMMUNITY_DID,
138 })
139
140 // insert should not be called for notifications (only select for topic lookup)
141 expect(mockDb.insert).not.toHaveBeenCalled()
142 })
143
144 it('notifies both topic author and parent reply author for nested replies', async () => {
145 // First select: topic author
146 const topicSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }])
147 // Second select: parent reply author
148 const parentSelectChain = createChainableProxy([{ authorDid: REPLY_AUTHOR_DID }])
149
150 mockDb.select.mockReturnValueOnce(topicSelectChain).mockReturnValueOnce(parentSelectChain)
151
152 const insertChain = createChainableProxy()
153 mockDb.insert.mockReturnValue(insertChain)
154
155 await service.notifyOnReply({
156 replyUri: REPLY_URI,
157 actorDid: ACTOR_DID,
158 topicUri: TOPIC_URI,
159 parentUri: PARENT_REPLY_URI, // nested reply
160 communityDid: COMMUNITY_DID,
161 })
162
163 // Should insert two notifications: one for topic author, one for parent reply author
164 expect(mockDb.insert).toHaveBeenCalledTimes(2)
165 })
166
167 it('does not duplicate notification when parent reply author is topic author', async () => {
168 // Same author for topic and parent reply
169 const topicSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }])
170 const parentSelectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }])
171
172 mockDb.select.mockReturnValueOnce(topicSelectChain).mockReturnValueOnce(parentSelectChain)
173
174 const insertChain = createChainableProxy()
175 mockDb.insert.mockReturnValue(insertChain)
176
177 await service.notifyOnReply({
178 replyUri: REPLY_URI,
179 actorDid: ACTOR_DID,
180 topicUri: TOPIC_URI,
181 parentUri: PARENT_REPLY_URI,
182 communityDid: COMMUNITY_DID,
183 })
184
185 // Only one notification (topic author = parent reply author)
186 expect(mockDb.insert).toHaveBeenCalledTimes(1)
187 })
188
189 it('logs error and does not throw on DB failure', async () => {
190 mockDb.select.mockReturnValue(createChainableProxy(Promise.reject(new Error('DB error'))))
191
192 await expect(
193 service.notifyOnReply({
194 replyUri: REPLY_URI,
195 actorDid: ACTOR_DID,
196 topicUri: TOPIC_URI,
197 parentUri: TOPIC_URI,
198 communityDid: COMMUNITY_DID,
199 })
200 ).resolves.toBeUndefined()
201
202 expect(mockLogger.error).toHaveBeenCalled()
203 })
204})
205
206// ===========================================================================
207// notifyOnReaction
208// ===========================================================================
209
210describe('notifyOnReaction', () => {
211 it('notifies topic author when their topic gets a reaction', async () => {
212 const selectChain = createChainableProxy([{ authorDid: TOPIC_AUTHOR_DID }])
213 mockDb.select.mockReturnValue(selectChain)
214
215 const insertChain = createChainableProxy()
216 mockDb.insert.mockReturnValue(insertChain)
217
218 await service.notifyOnReaction({
219 subjectUri: TOPIC_URI,
220 actorDid: ACTOR_DID,
221 communityDid: COMMUNITY_DID,
222 })
223
224 expect(mockDb.insert).toHaveBeenCalled()
225 })
226
227 it('notifies reply author when their reply gets a reaction', async () => {
228 // First select (topic lookup): no match
229 const noMatchChain = createChainableProxy([])
230 // Second select (reply lookup): match
231 const replyChain = createChainableProxy([{ authorDid: REPLY_AUTHOR_DID }])
232
233 mockDb.select.mockReturnValueOnce(noMatchChain).mockReturnValueOnce(replyChain)
234
235 const insertChain = createChainableProxy()
236 mockDb.insert.mockReturnValue(insertChain)
237
238 await service.notifyOnReaction({
239 subjectUri: PARENT_REPLY_URI,
240 actorDid: ACTOR_DID,
241 communityDid: COMMUNITY_DID,
242 })
243
244 expect(mockDb.insert).toHaveBeenCalled()
245 })
246
247 it('does not notify when reacting to own content', async () => {
248 const selectChain = createChainableProxy([{ authorDid: ACTOR_DID }])
249 mockDb.select.mockReturnValue(selectChain)
250
251 const insertChain = createChainableProxy()
252 mockDb.insert.mockReturnValue(insertChain)
253
254 await service.notifyOnReaction({
255 subjectUri: `at://${ACTOR_DID}/forum.barazo.topic.post/mytopic`,
256 actorDid: ACTOR_DID,
257 communityDid: COMMUNITY_DID,
258 })
259
260 expect(mockDb.insert).not.toHaveBeenCalled()
261 })
262})
263
264// ===========================================================================
265// notifyOnModAction
266// ===========================================================================
267
268describe('notifyOnModAction', () => {
269 it('notifies content author of moderation action', async () => {
270 const insertChain = createChainableProxy()
271 mockDb.insert.mockReturnValue(insertChain)
272
273 await service.notifyOnModAction({
274 targetUri: TOPIC_URI,
275 moderatorDid: MODERATOR_DID,
276 targetDid: TOPIC_AUTHOR_DID,
277 communityDid: COMMUNITY_DID,
278 })
279
280 expect(mockDb.insert).toHaveBeenCalled()
281 })
282
283 it('does not notify when moderator acts on own content', async () => {
284 const insertChain = createChainableProxy()
285 mockDb.insert.mockReturnValue(insertChain)
286
287 await service.notifyOnModAction({
288 targetUri: TOPIC_URI,
289 moderatorDid: MODERATOR_DID,
290 targetDid: MODERATOR_DID, // same person
291 communityDid: COMMUNITY_DID,
292 })
293
294 expect(mockDb.insert).not.toHaveBeenCalled()
295 })
296})
297
298// ===========================================================================
299// notifyOnMentions
300// ===========================================================================
301
302describe('notifyOnMentions', () => {
303 it('resolves handles to DIDs and creates mention notifications', async () => {
304 // Select: resolve handles
305 const userSelectChain = createChainableProxy([
306 { did: 'did:plc:mentioned1', handle: 'jay.bsky.team' },
307 ])
308 mockDb.select.mockReturnValue(userSelectChain)
309
310 const insertChain = createChainableProxy()
311 mockDb.insert.mockReturnValue(insertChain)
312
313 await service.notifyOnMentions({
314 content: 'Hey @jay.bsky.team check this out',
315 subjectUri: REPLY_URI,
316 actorDid: ACTOR_DID,
317 communityDid: COMMUNITY_DID,
318 })
319
320 expect(mockDb.insert).toHaveBeenCalled()
321 })
322
323 it('does not create notifications for unresolved handles', async () => {
324 // No users found for the handle
325 const emptySelectChain = createChainableProxy([])
326 mockDb.select.mockReturnValue(emptySelectChain)
327
328 await service.notifyOnMentions({
329 content: 'Hey @unknown.example.com',
330 subjectUri: REPLY_URI,
331 actorDid: ACTOR_DID,
332 communityDid: COMMUNITY_DID,
333 })
334
335 expect(mockDb.insert).not.toHaveBeenCalled()
336 })
337
338 it('does not create notification for self-mention', async () => {
339 const userSelectChain = createChainableProxy([{ did: ACTOR_DID, handle: 'me.bsky.social' }])
340 mockDb.select.mockReturnValue(userSelectChain)
341
342 const insertChain = createChainableProxy()
343 mockDb.insert.mockReturnValue(insertChain)
344
345 await service.notifyOnMentions({
346 content: 'I am @me.bsky.social',
347 subjectUri: REPLY_URI,
348 actorDid: ACTOR_DID,
349 communityDid: COMMUNITY_DID,
350 })
351
352 expect(mockDb.insert).not.toHaveBeenCalled()
353 })
354
355 it('skips when content has no mentions', async () => {
356 await service.notifyOnMentions({
357 content: 'No mentions here',
358 subjectUri: REPLY_URI,
359 actorDid: ACTOR_DID,
360 communityDid: COMMUNITY_DID,
361 })
362
363 // Should not even query the DB
364 expect(mockDb.select).not.toHaveBeenCalled()
365 expect(mockDb.insert).not.toHaveBeenCalled()
366 })
367})
368
369// ===========================================================================
370// notifyOnCrossPostFailure
371// ===========================================================================
372
373describe('notifyOnCrossPostFailure', () => {
374 it('creates a cross_post_failed notification for the topic author', async () => {
375 const insertChain = createChainableProxy()
376 mockDb.insert.mockReturnValue(insertChain)
377
378 await service.notifyOnCrossPostFailure({
379 topicUri: TOPIC_URI,
380 authorDid: ACTOR_DID,
381 service: 'bluesky',
382 communityDid: COMMUNITY_DID,
383 })
384
385 expect(mockDb.insert).toHaveBeenCalled()
386 })
387
388 it('creates separate notifications for different failed services', async () => {
389 const insertChain = createChainableProxy()
390 mockDb.insert.mockReturnValue(insertChain)
391
392 await service.notifyOnCrossPostFailure({
393 topicUri: TOPIC_URI,
394 authorDid: ACTOR_DID,
395 service: 'bluesky',
396 communityDid: COMMUNITY_DID,
397 })
398
399 await service.notifyOnCrossPostFailure({
400 topicUri: TOPIC_URI,
401 authorDid: ACTOR_DID,
402 service: 'frontpage',
403 communityDid: COMMUNITY_DID,
404 })
405
406 expect(mockDb.insert).toHaveBeenCalledTimes(2)
407 })
408
409 it('logs error and does not throw on DB failure', async () => {
410 const insertChain = createChainableProxy()
411 insertChain.values.mockRejectedValue(new Error('DB error'))
412 mockDb.insert.mockReturnValue(insertChain)
413
414 await expect(
415 service.notifyOnCrossPostFailure({
416 topicUri: TOPIC_URI,
417 authorDid: ACTOR_DID,
418 service: 'bluesky',
419 communityDid: COMMUNITY_DID,
420 })
421 ).resolves.toBeUndefined()
422
423 expect(mockLogger.error).toHaveBeenCalled()
424 })
425})