Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/
at main 291 lines 9.3 kB view raw
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';