Barazo AppView backend barazo.forum
at main 365 lines 11 kB view raw
1import { describe, it, expect, vi, beforeEach } from 'vitest' 2import { createProfileSyncService } from '../../../src/services/profile-sync.js' 3import type { ProfileSyncService } from '../../../src/services/profile-sync.js' 4import type { Logger } from '../../../src/lib/logger.js' 5import type { Database } from '../../../src/db/index.js' 6 7// --------------------------------------------------------------------------- 8// Mock logger 9// --------------------------------------------------------------------------- 10 11function createMockLogger(): Logger { 12 return { 13 info: vi.fn(), 14 error: vi.fn(), 15 warn: vi.fn(), 16 debug: vi.fn(), 17 fatal: vi.fn(), 18 trace: vi.fn(), 19 child: vi.fn(), 20 silent: vi.fn(), 21 level: 'silent', 22 } as unknown as Logger 23} 24 25// --------------------------------------------------------------------------- 26// Mock helpers 27// --------------------------------------------------------------------------- 28 29function createMockDb(overrides?: { whereReturn?: ReturnType<typeof vi.fn> }) { 30 const whereFn = overrides?.whereReturn ?? vi.fn().mockResolvedValue(undefined) 31 const setFn = vi.fn().mockReturnValue({ where: whereFn }) 32 const updateFn = vi.fn().mockReturnValue({ set: setFn }) 33 34 return { 35 update: updateFn, 36 _mocks: { updateFn, setFn, whereFn }, 37 } as unknown as Database & { 38 _mocks: { 39 updateFn: ReturnType<typeof vi.fn> 40 setFn: ReturnType<typeof vi.fn> 41 whereFn: ReturnType<typeof vi.fn> 42 } 43 } 44} 45 46// --------------------------------------------------------------------------- 47// Fixtures 48// --------------------------------------------------------------------------- 49 50const TEST_DID = 'did:plc:testuser123456789012' 51 52const MOCK_PROFILE_RESPONSE = { 53 success: true, 54 data: { 55 did: TEST_DID, 56 handle: 'jay.bsky.team', 57 displayName: 'Jay', 58 avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:testuser123456789012/bafkreiabc@jpeg', 59 banner: 'https://cdn.bsky.app/img/banner/plain/did:plc:testuser123456789012/bafkreixyz@jpeg', 60 description: 'Exploring the decentralized web.', 61 followersCount: 150, 62 followsCount: 75, 63 postsCount: 230, 64 labels: [ 65 { 66 src: TEST_DID, 67 uri: `at://${TEST_DID}`, 68 val: 'adult-content', 69 neg: false, 70 cts: '2026-01-15T10:00:00.000Z', 71 }, 72 { 73 src: 'did:plc:ozone-mod-service', 74 uri: `at://${TEST_DID}`, 75 val: '!warn', 76 neg: false, 77 cts: '2026-01-20T12:00:00.000Z', 78 }, 79 { 80 src: TEST_DID, 81 uri: `at://${TEST_DID}`, 82 val: 'old-label', 83 neg: true, 84 cts: '2026-01-25T08:00:00.000Z', 85 }, 86 ], 87 }, 88} 89 90const MOCK_MINIMAL_PROFILE_RESPONSE = { 91 success: true, 92 data: { 93 did: TEST_DID, 94 handle: 'alex.bsky.team', 95 }, 96} 97 98// --------------------------------------------------------------------------- 99// Tests 100// --------------------------------------------------------------------------- 101 102describe('ProfileSyncService', () => { 103 let service: ProfileSyncService 104 let mockLogger: Logger 105 let mockDb: ReturnType<typeof createMockDb> 106 let mockGetProfile: ReturnType<typeof vi.fn> 107 108 beforeEach(() => { 109 mockLogger = createMockLogger() 110 mockGetProfile = vi.fn().mockResolvedValue(MOCK_PROFILE_RESPONSE) 111 mockDb = createMockDb() 112 113 service = createProfileSyncService(mockDb, mockLogger, { 114 agentFactory: { 115 createAgent: () => ({ 116 getProfile: mockGetProfile, 117 }), 118 }, 119 }) 120 }) 121 122 // ------------------------------------------------------------------------- 123 // Successful sync 124 // ------------------------------------------------------------------------- 125 126 it('returns profile data on successful fetch', async () => { 127 const result = await service.syncProfile(TEST_DID) 128 129 expect(result).toStrictEqual({ 130 displayName: 'Jay', 131 avatarUrl: 132 'https://cdn.bsky.app/img/avatar/plain/did:plc:testuser123456789012/bafkreiabc@jpeg', 133 bannerUrl: 134 'https://cdn.bsky.app/img/banner/plain/did:plc:testuser123456789012/bafkreixyz@jpeg', 135 bio: 'Exploring the decentralized web.', 136 followersCount: 150, 137 followsCount: 75, 138 atprotoPostsCount: 230, 139 hasBlueskyProfile: true, 140 labels: [ 141 { val: 'adult-content', src: TEST_DID, neg: false, cts: '2026-01-15T10:00:00.000Z' }, 142 { 143 val: '!warn', 144 src: 'did:plc:ozone-mod-service', 145 neg: false, 146 cts: '2026-01-20T12:00:00.000Z', 147 }, 148 ], 149 }) 150 }) 151 152 it('calls getProfile with the user DID', async () => { 153 await service.syncProfile(TEST_DID) 154 155 expect(mockGetProfile).toHaveBeenCalledWith({ actor: TEST_DID }) 156 }) 157 158 it('updates the users table with profile data and lastActiveAt', async () => { 159 await service.syncProfile(TEST_DID) 160 161 expect(mockDb._mocks.updateFn).toHaveBeenCalled() 162 }) 163 164 // ------------------------------------------------------------------------- 165 // Label capture 166 // ------------------------------------------------------------------------- 167 168 it('returns labels from profile, filtering out negated labels', async () => { 169 const result = await service.syncProfile(TEST_DID) 170 171 expect(result.labels).toStrictEqual([ 172 { val: 'adult-content', src: TEST_DID, neg: false, cts: '2026-01-15T10:00:00.000Z' }, 173 { 174 val: '!warn', 175 src: 'did:plc:ozone-mod-service', 176 neg: false, 177 cts: '2026-01-20T12:00:00.000Z', 178 }, 179 ]) 180 }) 181 182 it('returns empty labels array when profile has no labels', async () => { 183 mockGetProfile.mockResolvedValue(MOCK_MINIMAL_PROFILE_RESPONSE) 184 185 const result = await service.syncProfile(TEST_DID) 186 187 expect(result.labels).toStrictEqual([]) 188 }) 189 190 // ------------------------------------------------------------------------- 191 // No profile fields (minimal profile) 192 // ------------------------------------------------------------------------- 193 194 it('returns null values when profile has no optional fields', async () => { 195 mockGetProfile.mockResolvedValue(MOCK_MINIMAL_PROFILE_RESPONSE) 196 197 const result = await service.syncProfile(TEST_DID) 198 199 expect(result).toStrictEqual({ 200 displayName: null, 201 avatarUrl: null, 202 bannerUrl: null, 203 bio: null, 204 followersCount: 0, 205 followsCount: 0, 206 atprotoPostsCount: 0, 207 hasBlueskyProfile: true, 208 labels: [], 209 }) 210 }) 211 212 // ------------------------------------------------------------------------- 213 // getProfile failure 214 // ------------------------------------------------------------------------- 215 216 it('returns null values when getProfile throws', async () => { 217 mockGetProfile.mockRejectedValue(new Error('Network timeout')) 218 219 const result = await service.syncProfile(TEST_DID) 220 221 expect(result).toStrictEqual({ 222 displayName: null, 223 avatarUrl: null, 224 bannerUrl: null, 225 bio: null, 226 followersCount: 0, 227 followsCount: 0, 228 atprotoPostsCount: 0, 229 hasBlueskyProfile: false, 230 labels: [], 231 }) 232 }) 233 234 it('logs at debug level when getProfile fails', async () => { 235 mockGetProfile.mockRejectedValue(new Error('Network timeout')) 236 237 await service.syncProfile(TEST_DID) 238 239 const debugFn = mockLogger.debug as ReturnType<typeof vi.fn> 240 expect(debugFn).toHaveBeenCalledWith( 241 expect.objectContaining({ did: TEST_DID }) as Record<string, unknown>, 242 expect.stringContaining('profile sync failed') as string 243 ) 244 }) 245 246 // ------------------------------------------------------------------------- 247 // DB update failure 248 // ------------------------------------------------------------------------- 249 250 it('still returns profile data when DB update fails', async () => { 251 mockDb = createMockDb({ 252 whereReturn: vi.fn().mockRejectedValue(new Error('DB connection lost')), 253 }) 254 255 service = createProfileSyncService(mockDb, mockLogger, { 256 agentFactory: { 257 createAgent: () => ({ 258 getProfile: mockGetProfile, 259 }), 260 }, 261 }) 262 263 const result = await service.syncProfile(TEST_DID) 264 265 expect(result).toStrictEqual({ 266 displayName: 'Jay', 267 avatarUrl: 268 'https://cdn.bsky.app/img/avatar/plain/did:plc:testuser123456789012/bafkreiabc@jpeg', 269 bannerUrl: 270 'https://cdn.bsky.app/img/banner/plain/did:plc:testuser123456789012/bafkreixyz@jpeg', 271 bio: 'Exploring the decentralized web.', 272 followersCount: 150, 273 followsCount: 75, 274 atprotoPostsCount: 230, 275 hasBlueskyProfile: true, 276 labels: [ 277 { val: 'adult-content', src: TEST_DID, neg: false, cts: '2026-01-15T10:00:00.000Z' }, 278 { 279 val: '!warn', 280 src: 'did:plc:ozone-mod-service', 281 neg: false, 282 cts: '2026-01-20T12:00:00.000Z', 283 }, 284 ], 285 }) 286 }) 287 288 it('logs a warning when DB update fails', async () => { 289 mockDb = createMockDb({ 290 whereReturn: vi.fn().mockRejectedValue(new Error('DB connection lost')), 291 }) 292 293 service = createProfileSyncService(mockDb, mockLogger, { 294 agentFactory: { 295 createAgent: () => ({ 296 getProfile: mockGetProfile, 297 }), 298 }, 299 }) 300 301 await service.syncProfile(TEST_DID) 302 303 const warnFn = mockLogger.warn as ReturnType<typeof vi.fn> 304 expect(warnFn).toHaveBeenCalledWith( 305 expect.objectContaining({ did: TEST_DID }) as Record<string, unknown>, 306 expect.stringContaining('profile DB update failed') as string 307 ) 308 }) 309 310 // ------------------------------------------------------------------------- 311 // AT Protocol stats capture 312 // ------------------------------------------------------------------------- 313 314 it('captures followersCount, followsCount, and atprotoPostsCount from profile response', async () => { 315 const result = await service.syncProfile(TEST_DID) 316 317 expect(result.followersCount).toBe(150) 318 expect(result.followsCount).toBe(75) 319 expect(result.atprotoPostsCount).toBe(230) 320 }) 321 322 it('sets hasBlueskyProfile to true when fetch succeeds', async () => { 323 const result = await service.syncProfile(TEST_DID) 324 325 expect(result.hasBlueskyProfile).toBe(true) 326 }) 327 328 it('sets hasBlueskyProfile to false when fetch fails', async () => { 329 mockGetProfile.mockRejectedValue(new Error('Profile not found')) 330 331 const result = await service.syncProfile(TEST_DID) 332 333 expect(result.hasBlueskyProfile).toBe(false) 334 }) 335 336 // ------------------------------------------------------------------------- 337 // Display name sanitization 338 // ------------------------------------------------------------------------- 339 340 it('strips control characters from displayName', async () => { 341 mockGetProfile.mockResolvedValue({ 342 success: true, 343 data: { 344 ...MOCK_PROFILE_RESPONSE.data, 345 displayName: 'J\u200Bay', 346 }, 347 }) 348 349 const result = await service.syncProfile(TEST_DID) 350 expect(result.displayName).toBe('Jay') 351 }) 352 353 it('returns null displayName when name is all control characters', async () => { 354 mockGetProfile.mockResolvedValue({ 355 success: true, 356 data: { 357 ...MOCK_PROFILE_RESPONSE.data, 358 displayName: '\u200B\u200C\u200D', 359 }, 360 }) 361 362 const result = await service.syncProfile(TEST_DID) 363 expect(result.displayName).toBeNull() 364 }) 365})