Barazo AppView backend barazo.forum
at main 425 lines 14 kB view raw
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})