Sifa professional network API (Fastify, AT Protocol, Jetstream)
sifa.id/
1import { describe, it, expect, vi, beforeEach } from 'vitest';
2
3// Mock pds-scanner
4const mockScanUserApps = vi.fn();
5vi.mock('../../src/services/pds-scanner.js', () => ({
6 scanUserApps: mockScanUserApps,
7}));
8
9// Mock drizzle-orm operators — return tagged objects so we can assert calls
10vi.mock('drizzle-orm', () => ({
11 eq: vi.fn((col, val) => ({ _op: 'eq', col, val })),
12 and: vi.fn((...args: unknown[]) => ({ _op: 'and', args })),
13 desc: vi.fn((col) => ({ _op: 'desc', col })),
14 lt: vi.fn((col, val) => ({ _op: 'lt', col, val })),
15 notInArray: vi.fn((col, vals) => ({ _op: 'notInArray', col, vals })),
16}));
17
18import type { AppScanResult } from '../../src/services/pds-scanner.js';
19import type { AppStatRow } from '../../src/services/app-stats.js';
20
21// ---- Mock DB builder ----
22function createMockDb() {
23 const mockDeleteWhere = vi.fn().mockResolvedValue(undefined);
24 const mockDeleteObj = { where: mockDeleteWhere };
25 const mockOnConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
26 const mockOnConflictDoNothing = vi.fn().mockResolvedValue(undefined);
27 const mockInsertValues = vi.fn().mockReturnValue({
28 onConflictDoUpdate: mockOnConflictDoUpdate,
29 onConflictDoNothing: mockOnConflictDoNothing,
30 });
31 const mockSelectWhere = vi.fn().mockReturnValue({
32 orderBy: vi.fn().mockResolvedValue([]),
33 });
34 const mockSelectFrom = vi.fn().mockReturnValue({
35 where: mockSelectWhere,
36 });
37
38 const db = {
39 select: vi.fn().mockReturnValue({ from: mockSelectFrom }),
40 insert: vi.fn().mockReturnValue({ values: mockInsertValues }),
41 delete: vi.fn().mockReturnValue(mockDeleteObj),
42 _mocks: {
43 selectFrom: mockSelectFrom,
44 selectWhere: mockSelectWhere,
45 insertValues: mockInsertValues,
46 onConflictDoUpdate: mockOnConflictDoUpdate,
47 onConflictDoNothing: mockOnConflictDoNothing,
48 deleteObj: mockDeleteObj,
49 deleteWhere: mockDeleteWhere,
50 },
51 } as unknown;
52
53 return db as ReturnType<typeof createMockDb> & {
54 select: ReturnType<typeof vi.fn>;
55 insert: ReturnType<typeof vi.fn>;
56 delete: ReturnType<typeof vi.fn>;
57 _mocks: {
58 selectFrom: ReturnType<typeof vi.fn>;
59 selectWhere: ReturnType<typeof vi.fn>;
60 insertValues: ReturnType<typeof vi.fn>;
61 onConflictDoUpdate: ReturnType<typeof vi.fn>;
62 onConflictDoNothing: ReturnType<typeof vi.fn>;
63 deleteObj: { where: ReturnType<typeof vi.fn> };
64 deleteWhere: ReturnType<typeof vi.fn>;
65 };
66 };
67}
68
69// ---- Mock Valkey ----
70function createMockValkey() {
71 return {
72 set: vi.fn(),
73 del: vi.fn().mockResolvedValue(1),
74 keys: vi.fn().mockResolvedValue([]),
75 } as unknown as ReturnType<typeof createMockValkey> & {
76 set: ReturnType<typeof vi.fn>;
77 del: ReturnType<typeof vi.fn>;
78 keys: ReturnType<typeof vi.fn>;
79 };
80}
81
82describe('app-stats service', () => {
83 let db: ReturnType<typeof createMockDb>;
84 let valkey: ReturnType<typeof createMockValkey>;
85
86 beforeEach(() => {
87 vi.clearAllMocks();
88 vi.useFakeTimers();
89 db = createMockDb();
90 valkey = createMockValkey();
91 });
92
93 afterEach(() => {
94 vi.useRealTimers();
95 });
96
97 // We import dynamically after mocks are set up
98 async function getModule() {
99 return import('../../src/services/app-stats.js');
100 }
101
102 describe('getVisibleAppStats', () => {
103 it('returns only visible rows ordered by recentCount DESC', async () => {
104 const { getVisibleAppStats } = await getModule();
105
106 const visibleRows: AppStatRow[] = [
107 {
108 did: 'did:plc:test',
109 appId: 'bluesky',
110 isActive: true,
111 recentCount: 42,
112 latestRecordAt: new Date(),
113 refreshedAt: new Date(),
114 visible: true,
115 createdAt: new Date(),
116 },
117 {
118 did: 'did:plc:test',
119 appId: 'whitewind',
120 isActive: true,
121 recentCount: 5,
122 latestRecordAt: new Date(),
123 refreshedAt: new Date(),
124 visible: true,
125 createdAt: new Date(),
126 },
127 ];
128
129 const mockOrderBy = vi.fn().mockResolvedValue(visibleRows);
130 db._mocks.selectWhere.mockReturnValue({ orderBy: mockOrderBy });
131
132 const result = await getVisibleAppStats(db as never, 'did:plc:test');
133
134 expect(result).toEqual(visibleRows);
135 expect(result[0]?.recentCount).toBeGreaterThan(result[1]?.recentCount ?? 0);
136 expect(db.select).toHaveBeenCalled();
137 });
138 });
139
140 describe('upsertScanResults', () => {
141 it('calls Drizzle upsert for each scan result', async () => {
142 const { upsertScanResults } = await getModule();
143
144 const results: AppScanResult[] = [
145 { appId: 'bluesky', isActive: true, recentCount: 10, latestRecordAt: new Date() },
146 { appId: 'whitewind', isActive: false, recentCount: 0, latestRecordAt: null },
147 ];
148
149 await upsertScanResults(db as never, 'did:plc:test', results);
150
151 // Should have called insert for the batch
152 expect(db.insert).toHaveBeenCalled();
153 expect(db._mocks.insertValues).toHaveBeenCalled();
154 expect(db._mocks.onConflictDoUpdate).toHaveBeenCalled();
155 });
156 });
157
158 describe('triggerRefreshIfStale', () => {
159 it('acquires Valkey lock before scanning', async () => {
160 const { triggerRefreshIfStale } = await getModule();
161
162 // No rows exist — stale
163 const mockOrderBy = vi.fn().mockResolvedValue([]);
164 db._mocks.selectWhere.mockReturnValue({ orderBy: mockOrderBy });
165
166 // Lock acquired
167 valkey.set.mockResolvedValue('OK');
168 mockScanUserApps.mockResolvedValue([]);
169
170 triggerRefreshIfStale(
171 db as never,
172 valkey as never,
173 'did:plc:test',
174 'https://pds.example.com',
175 );
176
177 // Let the microtask queue flush
178 await vi.advanceTimersByTimeAsync(0);
179
180 expect(valkey.set).toHaveBeenCalledWith(
181 'pds-scan:did:plc:test',
182 expect.any(String),
183 'EX',
184 120,
185 'NX',
186 );
187 expect(mockScanUserApps).toHaveBeenCalledWith('https://pds.example.com', 'did:plc:test');
188 });
189
190 it('skips if lock already held', async () => {
191 const { triggerRefreshIfStale } = await getModule();
192
193 // No rows — stale
194 const mockOrderBy = vi.fn().mockResolvedValue([]);
195 db._mocks.selectWhere.mockReturnValue({ orderBy: mockOrderBy });
196
197 // Lock NOT acquired
198 valkey.set.mockResolvedValue(null);
199
200 triggerRefreshIfStale(
201 db as never,
202 valkey as never,
203 'did:plc:test',
204 'https://pds.example.com',
205 );
206
207 await vi.advanceTimersByTimeAsync(0);
208
209 expect(valkey.set).toHaveBeenCalled();
210 expect(mockScanUserApps).not.toHaveBeenCalled();
211 });
212
213 it('skips if data is fresh (refreshedAt < 24h old)', async () => {
214 const { triggerRefreshIfStale } = await getModule();
215
216 const freshRow: AppStatRow = {
217 did: 'did:plc:test',
218 appId: 'bluesky',
219 isActive: true,
220 recentCount: 10,
221 latestRecordAt: new Date(),
222 refreshedAt: new Date(), // just now — fresh
223 visible: true,
224 createdAt: new Date(),
225 };
226
227 const mockOrderBy = vi.fn().mockResolvedValue([freshRow]);
228 db._mocks.selectWhere.mockReturnValue({ orderBy: mockOrderBy });
229
230 triggerRefreshIfStale(
231 db as never,
232 valkey as never,
233 'did:plc:test',
234 'https://pds.example.com',
235 );
236
237 await vi.advanceTimersByTimeAsync(0);
238
239 expect(valkey.set).not.toHaveBeenCalled();
240 expect(mockScanUserApps).not.toHaveBeenCalled();
241 });
242 });
243
244 describe('isDidSuppressed', () => {
245 it('returns true when DID exists in suppressed table', async () => {
246 const { isDidSuppressed } = await getModule();
247
248 const mockOrderBy = vi.fn().mockResolvedValue([{ did: 'did:plc:bad' }]);
249 db._mocks.selectWhere.mockReturnValue({ orderBy: mockOrderBy });
250 // For suppressedDids, we use a simpler select pattern
251 db._mocks.selectWhere.mockResolvedValue([{ did: 'did:plc:bad' }]);
252
253 const result = await isDidSuppressed(db as never, 'did:plc:bad');
254 expect(result).toBe(true);
255 });
256
257 it('returns false when DID is not suppressed', async () => {
258 const { isDidSuppressed } = await getModule();
259
260 db._mocks.selectWhere.mockResolvedValue([]);
261
262 const result = await isDidSuppressed(db as never, 'did:plc:good');
263 expect(result).toBe(false);
264 });
265 });
266
267 describe('suppressDid', () => {
268 it('inserts suppression, deletes stats, and clears Valkey keys', async () => {
269 const { suppressDid } = await getModule();
270
271 valkey.keys.mockResolvedValue(['activity:did:plc:bad:bluesky']);
272
273 await suppressDid(db as never, valkey as never, 'did:plc:bad');
274
275 // Should insert into suppressedDids
276 expect(db.insert).toHaveBeenCalled();
277 expect(db._mocks.onConflictDoNothing).toHaveBeenCalled();
278
279 // Should delete userAppStats
280 expect(db.delete).toHaveBeenCalled();
281
282 // Should delete Valkey keys
283 expect(valkey.del).toHaveBeenCalledWith('pds-scan:did:plc:bad');
284 expect(valkey.del).toHaveBeenCalledWith('activity-teaser:did:plc:bad');
285 expect(valkey.keys).toHaveBeenCalledWith('activity:did:plc:bad:*');
286 });
287 });
288});
289
290// Need afterEach at module level for vitest
291import { afterEach } from 'vitest';