Barazo AppView backend
barazo.forum
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})