1- import { describe , it , expect , beforeEach , vi } from 'vitest' ;
2- import Fastify from 'fastify' ;
31import jwt from '@fastify/jwt' ;
2+ import Fastify from 'fastify' ;
3+ import { describe , it , expect , beforeEach , vi } from 'vitest' ;
4+
45import { publicRoutes } from '../routes/public.js' ;
6+ import { generateOgImage } from '../utils/og-image.js' ;
7+ import { generateQRBuffer , generateQRSvg } from '../utils/qr.js' ;
8+
59import type { PrismaClient } from '@prisma/client' ;
610
11+
712// ── Mock QR utilities ─────────────────────────────────────────────────────────
813// Prevents real QR rasterisation (and any native canvas/image deps) from running
914// during unit tests. The stubs return minimal valid values that satisfy the
@@ -13,7 +18,12 @@ vi.mock('../utils/qr.js', () => ({
1318 generateQRSvg : vi . fn ( ) . mockResolvedValue ( '<svg>fake</svg>' ) ,
1419} ) ) ;
1520
16- import { generateQRBuffer , generateQRSvg } from '../utils/qr.js' ;
21+ // ── Mock OG image utility ─────────────────────────────────────────────────────
22+ // Prevents actual Resvg/resvg-js and external avatar-fetch calls in tests.
23+ vi . mock ( '../utils/og-image.js' , ( ) => ( {
24+ generateOgImage : vi . fn ( ) . mockResolvedValue ( Buffer . from ( 'fake-og-png' ) ) ,
25+ } ) ) ;
26+
1727
1828const mockUser = {
1929 id : 'user-123' ,
@@ -50,7 +60,7 @@ const mockRedis = {
5060 del : vi . fn ( ) . mockResolvedValue ( 1 ) ,
5161} ;
5262
53- async function buildApp ( ) {
63+ async function buildApp ( ) : Promise < ReturnType < typeof Fastify > > {
5464 const app = Fastify ( ) ;
5565 // Register JWT so app.jwt.sign() is available for the qr-session route.
5666 // @fastify /jwt also adds request.jwtVerify(), which throws when no valid
@@ -464,3 +474,132 @@ describe('GET /api/public/:username/qr-session', () => {
464474 ) ;
465475 } ) ;
466476} ) ;
477+
478+ // ─── OG image endpoint ────────────────────────────────────────────────────────
479+
480+ // The minimal user shape returned by the OG image DB query (select projection).
481+ const mockOgUser = {
482+ displayName : 'Test User' ,
483+ bio : 'Building cool things.' ,
484+ avatarUrl : null ,
485+ accentColor : '#6366f1' ,
486+ _count : { platformLinks : 3 } ,
487+ platformLinks : [
488+ { platform : 'github' } ,
489+ { platform : 'linkedin' } ,
490+ { platform : 'twitter' } ,
491+ ] ,
492+ } ;
493+
494+ describe ( 'GET /api/public/:username/og-image' , ( ) => {
495+ beforeEach ( ( ) => {
496+ vi . clearAllMocks ( ) ;
497+ // Restore default mock behaviours after clearAllMocks.
498+ ( generateOgImage as ReturnType < typeof vi . fn > ) . mockResolvedValue (
499+ Buffer . from ( 'fake-og-png' ) ,
500+ ) ;
501+ ( generateQRBuffer as ReturnType < typeof vi . fn > ) . mockResolvedValue (
502+ Buffer . from ( 'fake-png' ) ,
503+ ) ;
504+ ( generateQRSvg as ReturnType < typeof vi . fn > ) . mockResolvedValue (
505+ '<svg>fake</svg>' ,
506+ ) ;
507+ mockRedis . get . mockResolvedValue ( null ) ;
508+ mockRedis . set . mockResolvedValue ( 'OK' ) ;
509+ mockPrisma . cardView . create . mockReturnValue ( { catch : vi . fn ( ) } ) ;
510+ } ) ;
511+
512+ it ( 'returns 200 with image/png content-type for an existing user' , async ( ) => {
513+ mockPrisma . user . findUnique . mockResolvedValue ( mockOgUser ) ;
514+ const app = await buildApp ( ) ;
515+
516+ const res = await app . inject ( {
517+ method : 'GET' ,
518+ url : '/api/public/testuser/og-image' ,
519+ } ) ;
520+
521+ expect ( res . statusCode ) . toBe ( 200 ) ;
522+ expect ( res . headers [ 'content-type' ] ) . toMatch ( / i m a g e \/ p n g / ) ;
523+ expect ( res . headers [ 'cache-control' ] ) . toBe (
524+ 'public, max-age=86400, stale-while-revalidate=3600' ,
525+ ) ;
526+ } ) ;
527+
528+ it ( 'returns 404 for an unknown username' , async ( ) => {
529+ mockPrisma . user . findUnique . mockResolvedValue ( null ) ;
530+ const app = await buildApp ( ) ;
531+
532+ const res = await app . inject ( {
533+ method : 'GET' ,
534+ url : '/api/public/nobody/og-image' ,
535+ } ) ;
536+
537+ expect ( res . statusCode ) . toBe ( 404 ) ;
538+ expect ( res . json ( ) . error ) . toBe ( 'User not found' ) ;
539+ } ) ;
540+
541+ it ( 'returns X-Cache: MISS and calls generateOgImage on a fresh request' , async ( ) => {
542+ mockPrisma . user . findUnique . mockResolvedValue ( mockOgUser ) ;
543+ const app = await buildApp ( ) ;
544+
545+ const res = await app . inject ( {
546+ method : 'GET' ,
547+ url : '/api/public/testuser/og-image' ,
548+ } ) ;
549+
550+ expect ( res . statusCode ) . toBe ( 200 ) ;
551+ expect ( res . headers [ 'x-cache' ] ) . toBe ( 'MISS' ) ;
552+ expect ( generateOgImage ) . toHaveBeenCalledOnce ( ) ;
553+ } ) ;
554+
555+ it ( 'writes the generated PNG to Redis with a 24 h TTL' , async ( ) => {
556+ mockPrisma . user . findUnique . mockResolvedValue ( mockOgUser ) ;
557+ const app = await buildApp ( ) ;
558+
559+ await app . inject ( {
560+ method : 'GET' ,
561+ url : '/api/public/testuser/og-image' ,
562+ } ) ;
563+
564+ expect ( mockRedis . set ) . toHaveBeenCalledWith (
565+ 'og-image:testuser' ,
566+ expect . any ( String ) , // base64-encoded PNG
567+ 'EX' ,
568+ 86400 ,
569+ ) ;
570+ } ) ;
571+
572+ it ( 'returns X-Cache: HIT and skips DB + generateOgImage when cached' , async ( ) => {
573+ // Simulate a warm Redis cache entry (base64-encoded PNG).
574+ const cachedPng = Buffer . from ( 'fake-og-png' ) . toString ( 'base64' ) ;
575+ mockRedis . get . mockResolvedValue ( cachedPng ) ;
576+
577+ const app = await buildApp ( ) ;
578+ const res = await app . inject ( {
579+ method : 'GET' ,
580+ url : '/api/public/testuser/og-image' ,
581+ } ) ;
582+
583+ expect ( res . statusCode ) . toBe ( 200 ) ;
584+ expect ( res . headers [ 'x-cache' ] ) . toBe ( 'HIT' ) ;
585+ // DB must not be queried and the generator must not run.
586+ expect ( mockPrisma . user . findUnique ) . not . toHaveBeenCalled ( ) ;
587+ expect ( generateOgImage ) . not . toHaveBeenCalled ( ) ;
588+ } ) ;
589+
590+ it ( 'returns 500 when generateOgImage throws' , async ( ) => {
591+ mockPrisma . user . findUnique . mockResolvedValue ( mockOgUser ) ;
592+ ( generateOgImage as ReturnType < typeof vi . fn > ) . mockRejectedValueOnce (
593+ new Error ( 'resvg render error' ) ,
594+ ) ;
595+ const app = await buildApp ( ) ;
596+
597+ const res = await app . inject ( {
598+ method : 'GET' ,
599+ url : '/api/public/testuser/og-image' ,
600+ } ) ;
601+
602+ expect ( res . statusCode ) . toBe ( 500 ) ;
603+ expect ( res . json ( ) . error ) . toBe ( 'Failed to generate OG image' ) ;
604+ } ) ;
605+ } ) ;
0 commit comments