diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 4f0d07a..a03ecbb 100644 --- a/apps/backend/src/__tests__/analytics.test.ts +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -1,20 +1,10 @@ -import { - describe, - it, - expect, - beforeEach, - afterEach, - vi, -} from 'vitest'; - -import Fastify, { - type FastifyInstance, -} from 'fastify'; - -import type { PrismaClient } from '@prisma/client'; +import Fastify, { type FastifyInstance, } from 'fastify'; +import { describe, it, expect, beforeEach, afterEach, vi, } from 'vitest'; import { analyticsRoutes } from '../routes/analytics'; +import type { PrismaClient } from '@prisma/client'; + // ─── Shared mock data ──────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-001'; @@ -30,11 +20,12 @@ const prismaMock = { followLog: { count: vi.fn(), }, + $queryRaw: vi.fn(), }; // ─── App factory ───────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ @@ -99,19 +90,16 @@ describe( () => { let app: FastifyInstance; - beforeEach( - async () => { - vi.clearAllMocks(); + beforeEach(async () => { + vi.clearAllMocks(); - mockJwtVerify.mockResolvedValue( - { - id: MOCK_USER_ID, - } - ); + mockJwtVerify.mockResolvedValue({ id: MOCK_USER_ID }); - app = await buildApp(); - } - ); + // Default: $queryRaw for uniqueViewers returns 0 + prismaMock.$queryRaw.mockResolvedValue([{ count: BigInt(0) }]); + + app = await buildApp(); + }); afterEach( async () => { @@ -157,21 +145,8 @@ describe( ] ); - prismaMock.cardView.groupBy.mockResolvedValue( - [ - { - viewerId: - 'u1', - viewerIp: - null, - }, - { - viewerId: - 'u2', - viewerIp: - null, - }, - ] + prismaMock.$queryRaw.mockResolvedValue( + [{ count: BigInt(2) }] ); const res = @@ -217,6 +192,62 @@ describe( } ); + it( + 'totalFollows counts rows by followerId (outbound follows), not by targetUsername', + async () => { + prismaMock.cardView.count + .mockResolvedValueOnce(0) // totalViews + .mockResolvedValueOnce(0); // viewsToday + + // The user performed 3 outbound follow actions + prismaMock.followLog.count.mockResolvedValue(3); + + prismaMock.cardView.findMany.mockResolvedValue([]); + + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/overview', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().totalFollows).toBe(3); + + // Assert the query used followerId, not targetUsername + const followCountCall = prismaMock.followLog.count.mock.calls[0][0]; + expect(followCountCall).toMatchObject({ + where: { + followerId: MOCK_USER_ID, + status: 'success', + }, + }); + expect(followCountCall.where).not.toHaveProperty('targetUsername'); + } + ); + + it( + 'totalFollows is 0 when user has no successful outbound follows', + async () => { + prismaMock.cardView.count + .mockResolvedValueOnce(50) + .mockResolvedValueOnce(5); + + // No successful follow rows for this user + prismaMock.followLog.count.mockResolvedValue(0); + + prismaMock.cardView.findMany.mockResolvedValue([]); + + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/overview', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().totalFollows).toBe(0); + } + ); + it( '401 — rejects unauthenticated request', async () => { @@ -250,6 +281,58 @@ describe( ); } ); + + it( + 'totalFollows counts rows by followerId (outbound follows), not by targetUsername', + async () => { + prismaMock.cardView.count + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0); + + prismaMock.followLog.count.mockResolvedValue(3); + prismaMock.cardView.findMany.mockResolvedValue([]); + + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/overview', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().totalFollows).toBe(3); + + // The query must use followerId, never targetUsername + const followCountCall = prismaMock.followLog.count.mock.calls[0][0]; + expect(followCountCall).toMatchObject({ + where: { + followerId: MOCK_USER_ID, + status: 'success', + }, + }); + expect(followCountCall.where).not.toHaveProperty('targetUsername'); + } + ); + + it( + 'totalFollows is 0 when user has no successful outbound follows', + async () => { + prismaMock.cardView.count + .mockResolvedValueOnce(50) + .mockResolvedValueOnce(5); + + prismaMock.followLog.count.mockResolvedValue(0); + prismaMock.cardView.findMany.mockResolvedValue([]); + + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/overview', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().totalFollows).toBe(0); + } + ); } ); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index 884c052..c255d5d 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -19,8 +19,7 @@ export async function analyticsRoutes( request: FastifyRequest, _reply: FastifyReply ) => { - const userId = request.user.id; - const username = request.user.username; + const userId = (request.user as any).id; const today = new Date(); today.setHours(0, 0, 0, 0); @@ -39,10 +38,10 @@ export async function analyticsRoutes( }, }), - // Follows performed BY this user + // Follows performed BY this user (outbound follow actions where this user is the actor) app.prisma.followLog.count({ where: { - targetUsername: username, + followerId: userId, status: 'success', }, }),