11import { describe , it , expect , beforeEach , vi } from 'vitest' ;
22import Fastify from 'fastify' ;
3+ import jwt from '@fastify/jwt' ;
34import { publicRoutes } from '../routes/public.js' ;
45import type { PrismaClient } from '@prisma/client' ;
56
@@ -41,13 +42,23 @@ const mockPrisma = {
4142 card : { } as any ,
4243} ;
4344
45+ // ── Redis mock ────────────────────────────────────────────────────────────────
46+ // Simulates ioredis behaviour: get returns null (MISS) by default.
47+ const mockRedis = {
48+ get : vi . fn ( ) . mockResolvedValue ( null ) ,
49+ set : vi . fn ( ) . mockResolvedValue ( 'OK' ) ,
50+ del : vi . fn ( ) . mockResolvedValue ( 1 ) ,
51+ } ;
52+
4453async function buildApp ( ) {
4554 const app = Fastify ( ) ;
55+ // Register JWT so app.jwt.sign() is available for the qr-session route.
56+ // @fastify /jwt also adds request.jwtVerify(), which throws when no valid
57+ // Authorization header is present — matching the soft-auth pattern in the routes.
58+ await app . register ( jwt , { secret : 'test-secret-for-unit-tests-only' } ) ;
4659 app . decorate ( 'prisma' , mockPrisma as unknown as PrismaClient ) ;
47- // Soft auth: jwtVerify rejects by default (unauthenticated visitor)
48- app . decorateRequest ( 'jwtVerify' , async function ( ) {
49- throw new Error ( 'no token' ) ;
50- } ) ;
60+ // Decorate with the Redis mock so cache branches execute in tests.
61+ app . decorate ( 'redis' , mockRedis as any ) ;
5162 app . register ( publicRoutes , { prefix : '/api/public' } ) ;
5263 await app . ready ( ) ;
5364 return app ;
@@ -61,6 +72,8 @@ describe('GET /api/public/:username/qr — size validation', () => {
6172 // Re-attach default mock behaviour cleared by clearAllMocks
6273 ( generateQRBuffer as ReturnType < typeof vi . fn > ) . mockResolvedValue ( Buffer . from ( 'fake-png' ) ) ;
6374 ( generateQRSvg as ReturnType < typeof vi . fn > ) . mockResolvedValue ( '<svg>fake</svg>' ) ;
75+ mockRedis . get . mockResolvedValue ( null ) ;
76+ mockRedis . set . mockResolvedValue ( 'OK' ) ;
6477 } ) ;
6578
6679 // ── Reject before DB touch ─────────────────────────────────────────────────
@@ -222,3 +235,232 @@ describe('GET /api/public/:username/qr — size validation', () => {
222235 expect ( res . json ( ) . error ) . toBe ( 'QR code generation failed' ) ;
223236 } ) ;
224237} ) ;
238+
239+ // ─── Redis cache HIT / MISS behaviour ────────────────────────────────────────
240+
241+ describe ( 'GET /api/public/:username — Redis cache' , ( ) => {
242+ beforeEach ( ( ) => {
243+ vi . clearAllMocks ( ) ;
244+ mockRedis . get . mockResolvedValue ( null ) ;
245+ mockRedis . set . mockResolvedValue ( 'OK' ) ;
246+ mockPrisma . followLog . findMany . mockResolvedValue ( [ ] ) ;
247+ mockPrisma . cardView . create . mockReturnValue ( { catch : vi . fn ( ) } ) ;
248+ } ) ;
249+
250+ it ( 'returns X-Cache: MISS and queries DB on first request' , async ( ) => {
251+ mockPrisma . user . findUnique . mockResolvedValue ( mockUser ) ;
252+ const app = await buildApp ( ) ;
253+
254+ const res = await app . inject ( {
255+ method : 'GET' ,
256+ url : '/api/public/testuser' ,
257+ } ) ;
258+
259+ expect ( res . statusCode ) . toBe ( 200 ) ;
260+ expect ( res . headers [ 'x-cache' ] ) . toBe ( 'MISS' ) ;
261+ expect ( res . headers [ 'cache-control' ] ) . toBe ( 'public, max-age=300, stale-while-revalidate=60' ) ;
262+ // DB was queried since Redis returned null
263+ expect ( mockPrisma . user . findUnique ) . toHaveBeenCalledOnce ( ) ;
264+ // Profile should be written to Redis after the DB fetch
265+ expect ( mockRedis . set ) . toHaveBeenCalledWith (
266+ 'profile:testuser' ,
267+ expect . any ( String ) ,
268+ 'EX' ,
269+ 300 ,
270+ ) ;
271+ } ) ;
272+
273+ it ( 'returns X-Cache: HIT and skips DB on cached request' , async ( ) => {
274+ // Simulate a warm cache entry
275+ const cached = JSON . stringify ( {
276+ _userId : 'user-123' ,
277+ username : 'testuser' ,
278+ displayName : 'Test User' ,
279+ bio : null ,
280+ pronouns : null ,
281+ role : null ,
282+ company : null ,
283+ avatarUrl : null ,
284+ accentColor : '#ffffff' ,
285+ links : [ ] ,
286+ } ) ;
287+ mockRedis . get . mockResolvedValue ( cached ) ;
288+
289+ const app = await buildApp ( ) ;
290+ const res = await app . inject ( {
291+ method : 'GET' ,
292+ url : '/api/public/testuser' ,
293+ } ) ;
294+
295+ expect ( res . statusCode ) . toBe ( 200 ) ;
296+ expect ( res . headers [ 'x-cache' ] ) . toBe ( 'HIT' ) ;
297+ // DB must NOT be queried when cache is warm
298+ expect ( mockPrisma . user . findUnique ) . not . toHaveBeenCalled ( ) ;
299+ } ) ;
300+
301+ it ( 'response body on cache HIT matches the cached profile' , async ( ) => {
302+ const cached = JSON . stringify ( {
303+ _userId : 'user-123' ,
304+ username : 'testuser' ,
305+ displayName : 'Test User' ,
306+ bio : 'A bio' ,
307+ pronouns : null ,
308+ role : 'Engineer' ,
309+ company : null ,
310+ avatarUrl : null ,
311+ accentColor : '#123456' ,
312+ links : [ ] ,
313+ } ) ;
314+ mockRedis . get . mockResolvedValue ( cached ) ;
315+
316+ const app = await buildApp ( ) ;
317+ const res = await app . inject ( { method : 'GET' , url : '/api/public/testuser' } ) ;
318+ const body = res . json ( ) ;
319+
320+ expect ( body . username ) . toBe ( 'testuser' ) ;
321+ expect ( body . accentColor ) . toBe ( '#123456' ) ;
322+ // Internal _userId field must not leak into the HTTP response
323+ expect ( body . _userId ) . toBeUndefined ( ) ;
324+ } ) ;
325+
326+ it ( 'falls through to DB when Redis.get throws' , async ( ) => {
327+ mockRedis . get . mockRejectedValue ( new Error ( 'Redis down' ) ) ;
328+ mockPrisma . user . findUnique . mockResolvedValue ( mockUser ) ;
329+
330+ const app = await buildApp ( ) ;
331+ const res = await app . inject ( { method : 'GET' , url : '/api/public/testuser' } ) ;
332+
333+ expect ( res . statusCode ) . toBe ( 200 ) ;
334+ // DB was reached despite the Redis failure
335+ expect ( mockPrisma . user . findUnique ) . toHaveBeenCalledOnce ( ) ;
336+ } ) ;
337+
338+ it ( 'returns 404 when user does not exist (cache MISS)' , async ( ) => {
339+ mockPrisma . user . findUnique . mockResolvedValue ( null ) ;
340+
341+ const app = await buildApp ( ) ;
342+ const res = await app . inject ( { method : 'GET' , url : '/api/public/nobody' } ) ;
343+
344+ expect ( res . statusCode ) . toBe ( 404 ) ;
345+ expect ( res . json ( ) . error ) . toBe ( 'User not found' ) ;
346+ } ) ;
347+ } ) ;
348+
349+ // ─── QR session endpoint ──────────────────────────────────────────────────────
350+
351+ describe ( 'GET /api/public/:username/qr-session' , ( ) => {
352+ beforeEach ( ( ) => {
353+ vi . clearAllMocks ( ) ;
354+ mockRedis . get . mockResolvedValue ( null ) ;
355+ mockRedis . set . mockResolvedValue ( 'OK' ) ;
356+ } ) ;
357+
358+ it ( 'returns 404 when the user does not exist' , async ( ) => {
359+ mockPrisma . user . findUnique . mockResolvedValue ( null ) ;
360+
361+ const app = await buildApp ( ) ;
362+ const res = await app . inject ( {
363+ method : 'GET' ,
364+ url : '/api/public/nobody/qr-session' ,
365+ } ) ;
366+
367+ expect ( res . statusCode ) . toBe ( 404 ) ;
368+ expect ( res . json ( ) . error ) . toBe ( 'User not found' ) ;
369+ } ) ;
370+
371+ it ( 'returns a JWT token with correct shape on DB fetch (cache MISS)' , async ( ) => {
372+ mockPrisma . user . findUnique . mockResolvedValue ( mockUser ) ;
373+
374+ const app = await buildApp ( ) ;
375+ const res = await app . inject ( {
376+ method : 'GET' ,
377+ url : '/api/public/testuser/qr-session' ,
378+ } ) ;
379+
380+ expect ( res . statusCode ) . toBe ( 200 ) ;
381+ const body = res . json ( ) ;
382+ expect ( typeof body . token ) . toBe ( 'string' ) ;
383+ expect ( body . tokenType ) . toBe ( 'JWT' ) ;
384+ expect ( body . expiresIn ) . toBe ( 600 ) ;
385+ expect ( typeof body . expiresAt ) . toBe ( 'string' ) ;
386+ // expiresAt must be a valid ISO 8601 date string
387+ expect ( new Date ( body . expiresAt ) . getTime ( ) ) . toBeGreaterThan ( Date . now ( ) ) ;
388+ } ) ;
389+
390+ it ( 'token payload encodes the public profile snapshot' , async ( ) => {
391+ mockPrisma . user . findUnique . mockResolvedValue ( mockUser ) ;
392+
393+ const app = await buildApp ( ) ;
394+ const res = await app . inject ( {
395+ method : 'GET' ,
396+ url : '/api/public/testuser/qr-session' ,
397+ } ) ;
398+
399+ const { token } = res . json ( ) ;
400+ // Decode without verifying so we can inspect the payload in the test
401+ const decoded = JSON . parse (
402+ Buffer . from ( token . split ( '.' ) [ 1 ] , 'base64url' ) . toString ( ) ,
403+ ) ;
404+ expect ( decoded . sub ) . toBe ( 'testuser' ) ;
405+ expect ( decoded . profile . username ) . toBe ( 'testuser' ) ;
406+ expect ( decoded . profile . displayName ) . toBe ( 'Test User' ) ;
407+ } ) ;
408+
409+ it ( 'serves snapshot from Redis cache without querying DB' , async ( ) => {
410+ const cached = JSON . stringify ( {
411+ _userId : 'user-123' ,
412+ username : 'testuser' ,
413+ displayName : 'Cached User' ,
414+ bio : null ,
415+ pronouns : null ,
416+ role : null ,
417+ company : null ,
418+ avatarUrl : null ,
419+ accentColor : '#ffffff' ,
420+ links : [ ] ,
421+ } ) ;
422+ mockRedis . get . mockResolvedValue ( cached ) ;
423+
424+ const app = await buildApp ( ) ;
425+ const res = await app . inject ( {
426+ method : 'GET' ,
427+ url : '/api/public/testuser/qr-session' ,
428+ } ) ;
429+
430+ expect ( res . statusCode ) . toBe ( 200 ) ;
431+ // DB must not be reached when the cache is warm
432+ expect ( mockPrisma . user . findUnique ) . not . toHaveBeenCalled ( ) ;
433+
434+ const { token } = res . json ( ) ;
435+ const decoded = JSON . parse (
436+ Buffer . from ( token . split ( '.' ) [ 1 ] , 'base64url' ) . toString ( ) ,
437+ ) ;
438+ expect ( decoded . profile . displayName ) . toBe ( 'Cached User' ) ;
439+ } ) ;
440+
441+ it ( 'includes Cache-Control header in qr-session response' , async ( ) => {
442+ mockPrisma . user . findUnique . mockResolvedValue ( mockUser ) ;
443+
444+ const app = await buildApp ( ) ;
445+ const res = await app . inject ( {
446+ method : 'GET' ,
447+ url : '/api/public/testuser/qr-session' ,
448+ } ) ;
449+
450+ expect ( res . headers [ 'cache-control' ] ) . toBe ( 'public, max-age=300, stale-while-revalidate=60' ) ;
451+ } ) ;
452+
453+ it ( 'caches the profile in Redis when served from DB' , async ( ) => {
454+ mockPrisma . user . findUnique . mockResolvedValue ( mockUser ) ;
455+
456+ const app = await buildApp ( ) ;
457+ await app . inject ( { method : 'GET' , url : '/api/public/testuser/qr-session' } ) ;
458+
459+ expect ( mockRedis . set ) . toHaveBeenCalledWith (
460+ 'profile:testuser' ,
461+ expect . any ( String ) ,
462+ 'EX' ,
463+ 300 ,
464+ ) ;
465+ } ) ;
466+ } ) ;
0 commit comments