diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts new file mode 100644 index 0000000..878f253 --- /dev/null +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import { analyticsRoutes } from '../routes/analytics.js'; +import type { PrismaClient } from '@prisma/client'; + +const mockPrisma = { + cardView: { + count: vi.fn(), + findMany: vi.fn(), + groupBy: vi.fn(), + }, + followLog: { + count: vi.fn(), + }, +}; + +async function buildApp() { + const app = Fastify(); + app.decorate('prisma', mockPrisma as unknown as PrismaClient); + app.decorate('authenticate', async (request: any) => { + request.user = { id: 'user-123' }; + }); + app.register(analyticsRoutes, { prefix: '/api/analytics' }); + await app.ready(); + return app; +} + +describe('Analytics Routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /api/analytics/overview', () => { + it('should return overview stats successfully', async () => { + mockPrisma.cardView.count.mockResolvedValueOnce(10); + mockPrisma.cardView.count.mockResolvedValueOnce(2); + mockPrisma.followLog.count.mockResolvedValueOnce(5); + mockPrisma.cardView.findMany.mockResolvedValueOnce([]); + mockPrisma.cardView.groupBy.mockResolvedValueOnce([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/overview', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.totalViews).toBe(10); + expect(body.totalFollows).toBe(5); + }); + }); + + describe('GET /api/analytics/views', () => { + it('should handle valid page parameter', async () => { + mockPrisma.cardView.count.mockResolvedValueOnce(100); + mockPrisma.cardView.findMany.mockResolvedValueOnce([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/views', + query: { page: '2' }, + }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.cardView.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 20, + }) + ); + expect(res.json().meta.page).toBe(2); + }); + + it('should default to page 1 for invalid non-numeric page parameter', async () => { + mockPrisma.cardView.count.mockResolvedValueOnce(100); + mockPrisma.cardView.findMany.mockResolvedValueOnce([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/views', + query: { page: 'abc' }, + }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.cardView.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 0, + take: 20, + }) + ); + expect(res.json().meta.page).toBe(1); + }); + + it('should default to page 1 for negative page parameter', async () => { + mockPrisma.cardView.count.mockResolvedValueOnce(100); + mockPrisma.cardView.findMany.mockResolvedValueOnce([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/analytics/views', + query: { page: '-5' }, + }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.cardView.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 0, + take: 20, + }) + ); + expect(res.json().meta.page).toBe(1); + }); + }); +}); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index e9a75bb..1dc8a63 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -61,7 +61,7 @@ export async function analyticsRoutes(app: FastifyInstance) { preHandler: [app.authenticate], }, async (request: FastifyRequest<{ Querystring: { page?: string, cardId?: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; - const page = parseInt(request.query.page || '1', 10); + const page = Math.max(1, Number(request.query.page) || 1); const limit = 20; const skip = (page - 1) * limit;