@@ -39,7 +39,9 @@ const mockPrisma = {
3939 followLog : {
4040 findMany : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
4141 } ,
42- card : { } as any ,
42+ card : {
43+ findUnique : vi . fn ( ) ,
44+ } ,
4345} ;
4446
4547// ── Redis mock ────────────────────────────────────────────────────────────────
@@ -464,3 +466,147 @@ describe('GET /api/public/:username/qr-session', () => {
464466 ) ;
465467 } ) ;
466468} ) ;
469+
470+ // ─── Direct card view tracking (Issue #495) ───────────────────────────────────
471+
472+ const mockCard = {
473+ id : 'card-abc' ,
474+ title : 'My Dev Card' ,
475+ user : {
476+ id : 'user-123' ,
477+ username : 'testuser' ,
478+ displayName : 'Test User' ,
479+ bio : null ,
480+ avatarUrl : null ,
481+ accentColor : '#ffffff' ,
482+ } ,
483+ cardLinks : [ ] ,
484+ } ;
485+
486+ describe ( 'GET /api/public/card/:cardId — direct card view tracking' , ( ) => {
487+ beforeEach ( ( ) => {
488+ vi . clearAllMocks ( ) ;
489+ mockRedis . get . mockResolvedValue ( null ) ;
490+ mockRedis . set . mockResolvedValue ( 'OK' ) ;
491+ mockPrisma . cardView . create . mockReturnValue ( { catch : vi . fn ( ) } ) ;
492+ mockPrisma . card . findUnique . mockResolvedValue ( mockCard ) ;
493+ } ) ;
494+
495+ it ( 'returns 200 with correct card shape' , async ( ) => {
496+ const app = await buildApp ( ) ;
497+ const res = await app . inject ( {
498+ method : 'GET' ,
499+ url : '/api/public/card/card-abc' ,
500+ } ) ;
501+
502+ expect ( res . statusCode ) . toBe ( 200 ) ;
503+ const body = res . json ( ) ;
504+ expect ( body . id ) . toBe ( 'card-abc' ) ;
505+ expect ( body . title ) . toBe ( 'My Dev Card' ) ;
506+ expect ( body . owner . username ) . toBe ( 'testuser' ) ;
507+ expect ( Array . isArray ( body . links ) ) . toBe ( true ) ;
508+ } ) ;
509+
510+ it ( 'returns 404 when card does not exist' , async ( ) => {
511+ mockPrisma . card . findUnique . mockResolvedValue ( null ) ;
512+
513+ const app = await buildApp ( ) ;
514+ const res = await app . inject ( {
515+ method : 'GET' ,
516+ url : '/api/public/card/nonexistent' ,
517+ } ) ;
518+
519+ expect ( res . statusCode ) . toBe ( 404 ) ;
520+ expect ( res . json ( ) . error ) . toBe ( 'Card not found' ) ;
521+ } ) ;
522+
523+ it ( 'records CardView when authenticated viewer requests card' , async ( ) => {
524+ const app = await buildApp ( ) ;
525+
526+ // Sign a JWT for a different user (viewer-456 ≠ owner user-123)
527+ const token = app . jwt . sign ( { id : 'viewer-456' } ) ;
528+
529+ const res = await app . inject ( {
530+ method : 'GET' ,
531+ url : '/api/public/card/card-abc' ,
532+ headers : { Authorization : `Bearer ${ token } ` } ,
533+ } ) ;
534+
535+ expect ( res . statusCode ) . toBe ( 200 ) ;
536+ expect ( mockPrisma . cardView . create ) . toHaveBeenCalledWith (
537+ expect . objectContaining ( {
538+ data : expect . objectContaining ( {
539+ ownerId : 'user-123' ,
540+ cardId : 'card-abc' ,
541+ viewerId : 'viewer-456' ,
542+ } ) ,
543+ } ) ,
544+ ) ;
545+ } ) ;
546+
547+ it ( 'does not record CardView for anonymous (unauthenticated) request' , async ( ) => {
548+ const app = await buildApp ( ) ;
549+
550+ const res = await app . inject ( {
551+ method : 'GET' ,
552+ url : '/api/public/card/card-abc' ,
553+ } ) ;
554+
555+ expect ( res . statusCode ) . toBe ( 200 ) ;
556+ expect ( mockPrisma . cardView . create ) . not . toHaveBeenCalled ( ) ;
557+ } ) ;
558+
559+ it ( 'does not record CardView when viewer is the card owner' , async ( ) => {
560+ const app = await buildApp ( ) ;
561+
562+ // Sign JWT as the owner (user-123 === card.user.id)
563+ const token = app . jwt . sign ( { id : 'user-123' } ) ;
564+
565+ const res = await app . inject ( {
566+ method : 'GET' ,
567+ url : '/api/public/card/card-abc' ,
568+ headers : { Authorization : `Bearer ${ token } ` } ,
569+ } ) ;
570+
571+ expect ( res . statusCode ) . toBe ( 200 ) ;
572+ expect ( mockPrisma . cardView . create ) . not . toHaveBeenCalled ( ) ;
573+ } ) ;
574+
575+ it ( 'uses "link" as default source for direct card views' , async ( ) => {
576+ const app = await buildApp ( ) ;
577+ const token = app . jwt . sign ( { id : 'viewer-456' } ) ;
578+
579+ await app . inject ( {
580+ method : 'GET' ,
581+ url : '/api/public/card/card-abc' ,
582+ headers : { Authorization : `Bearer ${ token } ` } ,
583+ } ) ;
584+
585+ expect ( mockPrisma . cardView . create ) . toHaveBeenCalledWith (
586+ expect . objectContaining ( {
587+ data : expect . objectContaining ( {
588+ source : 'link' ,
589+ } ) ,
590+ } ) ,
591+ ) ;
592+ } ) ;
593+
594+ it ( 'records CardView with custom source query param when provided' , async ( ) => {
595+ const app = await buildApp ( ) ;
596+ const token = app . jwt . sign ( { id : 'viewer-456' } ) ;
597+
598+ await app . inject ( {
599+ method : 'GET' ,
600+ url : '/api/public/card/card-abc?source=web' ,
601+ headers : { Authorization : `Bearer ${ token } ` } ,
602+ } ) ;
603+
604+ expect ( mockPrisma . cardView . create ) . toHaveBeenCalledWith (
605+ expect . objectContaining ( {
606+ data : expect . objectContaining ( {
607+ source : 'web' ,
608+ } ) ,
609+ } ) ,
610+ ) ;
611+ } ) ;
612+ } ) ;
0 commit comments