diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts new file mode 100644 index 00000000..be7cbc2e --- /dev/null +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import { analyticsRoutes } from '../routes/analytics'; + +describe('GET /export - CSV Export', () => { + let app: ReturnType; + let prismaMock: any; + + beforeEach(async () => { + // 1. Setup a dummy Fastify instance for testing + app = Fastify(); + + // 2. Mock Prisma behavior (Database) + prismaMock = { + $queryRaw: vi.fn(), + // Mocking other models just so the plugin registers without errors + cardView: { count: vi.fn(), findMany: vi.fn(), groupBy: vi.fn() }, + followLog: { count: vi.fn() } + }; + app.decorate('prisma', prismaMock); + + // 3. Mock Authentication Middleware + app.decorate('authenticate', async (request: any, reply: any) => { + if (request.headers.authorization !== 'Bearer valid-token') { + reply.code(401).send({ error: 'Unauthorized' }); + return; + } + // Inject dummy user ID (simulating decoded JWT) + request.user = { id: 'user-123' }; + }); + + // 4. Register our actual routes + await app.register(analyticsRoutes); + }); + + it('should return 401 when unauthenticated', async () => { + // Expected: Request without valid Auth header returns 401 + const response = await app.inject({ + method: 'GET', + url: '/export', + }); + + expect(response.statusCode).toBe(401); + expect(JSON.parse(response.body)).toEqual({ error: 'Unauthorized' }); + }); + + it('should strictly return the users own data (No IDOR) and prevent 403 scenarios', async () => { + // Expected: The endpoint relies strictly on request.user.id + prismaMock.$queryRaw.mockResolvedValue([]); + + const response = await app.inject({ + method: 'GET', + url: '/export', + headers: { authorization: 'Bearer valid-token' } + }); + + // Validates that the request succeeded and Prisma was called + expect(response.statusCode).toBe(200); + expect(prismaMock.$queryRaw).toHaveBeenCalled(); + }); + + it('should return valid CSV structure with correct headers', async () => { + // Mock data mimicking Prisma's BigInt and Date return types + prismaMock.$queryRaw.mockResolvedValue([ + { date: new Date('2026-03-12T00:00:00.000Z'), count: 1n }, + { date: new Date('2026-03-13T00:00:00.000Z'), count: 5n } + ]); + + const response = await app.inject({ + method: 'GET', + url: '/export', + headers: { authorization: 'Bearer valid-token' } + }); + + // 1. Verify Status + expect(response.statusCode).toBe(200); + + // 2. Verify Headers + expect(response.headers['content-type']).toBe('text/csv'); + expect(response.headers['content-disposition']).toBe('attachment; filename="devcard-analytics.csv"'); + + // 3. Verify Body (CSV Format) + const body = response.body; + expect(body).toContain('date,platform,event_type,count\n'); + expect(body).toContain('2026-03-12,devcard,view,1\n'); + expect(body).toContain('2026-03-13,devcard,view,5\n'); + }); + +}); \ No newline at end of file diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index e9a75bb9..8e9ac53f 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { Readable } from 'stream'; export async function analyticsRoutes(app: FastifyInstance) { @@ -98,4 +99,56 @@ export async function analyticsRoutes(app: FastifyInstance) { }, }; }); -} + +// ─── Export Analytics CSV (Optimized) ─── + app.get('/export', { + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = (request.user as any).id; + + // 1. Set headers early for streaming + reply.header('Content-Type', 'text/csv'); + reply.header('Content-Disposition', 'attachment; filename="devcard-analytics.csv"'); + + try { + // 2. DB Aggregation: Let the Database do the heavy lifting + // Note: Prisma doesn't natively group by Date strings easily, so we use $queryRaw + // This query groups views by day directly in the DB. + const aggregatedViews = await app.prisma.$queryRaw>` + SELECT + DATE("createdAt") as date, + COUNT(*) as count + FROM "CardView" + WHERE "ownerId" = ${userId} + GROUP BY DATE("createdAt") + ORDER BY date DESC + `; + + // 3. Node.js Streams: Push data directly to the client without holding it in RAM + const csvStream = new Readable({ + read() {} // required implementation + }); + + // Push Header + csvStream.push('date,platform,event_type,count\n'); + + // Push Rows + for (const row of aggregatedViews) { + // Convert DB Date to YYYY-MM-DD + const dateStr = new Date(row.date).toISOString().split('T')[0]; + // Note: Prisma returns BigInt for COUNT(*), so we convert it to Number or String + csvStream.push(`${dateStr},devcard,view,${row.count.toString()}\n`); + } + + // End the stream + csvStream.push(null); + + // Fastify automatically handles piping the stream to the response + return reply.send(csvStream); + + } catch (error) { + request.log.error(error); + return reply.status(500).send({ error: 'Failed to generate CSV export' }); + } + }); +} \ No newline at end of file