Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/backend/src/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Fastify>;
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');
});

});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These appear to be placeholder tests currently. Could we add real API test coverage with mocks and assertions?

55 changes: 54 additions & 1 deletion apps/backend/src/routes/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { Readable } from 'stream';

export async function analyticsRoutes(app: FastifyInstance) {

Expand Down Expand Up @@ -98,4 +99,56 @@ export async function analyticsRoutes(app: FastifyInstance) {
},
};
});
}

// ─── Export Analytics CSV (Optimized) ───
app.get('/export', {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request schema is missing?

preHandler: [app.authenticate],
}, async (request: FastifyRequest, reply: FastifyReply) => {
const userId = (request.user as any).id;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling can be more better

// 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<Array<{ date: Date; count: bigint }>>`
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' });
}
});
}
Loading