diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts new file mode 100644 index 0000000..4f0d07a --- /dev/null +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -0,0 +1,466 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, +} from 'vitest'; + +import Fastify, { + type FastifyInstance, +} from 'fastify'; + +import type { PrismaClient } from '@prisma/client'; + +import { analyticsRoutes } from '../routes/analytics'; + +// ─── Shared mock data ──────────────────────────────────────────────────────── + +const MOCK_USER_ID = 'user-001'; + +// ─── Prisma mock ───────────────────────────────────────────────────────────── + +const prismaMock = { + cardView: { + count: vi.fn(), + findMany: vi.fn(), + groupBy: vi.fn(), + }, + followLog: { + count: vi.fn(), + }, +}; + +// ─── App factory ───────────────────────────────────────────────────────────── + +let mockJwtVerify = vi.fn(); + +async function buildApp(): Promise { + const app = Fastify({ + logger: false, + }); + + app.decorate( + 'prisma', + prismaMock as unknown as PrismaClient + ); + + app.decorateRequest( + 'jwtVerify', + function () { + return mockJwtVerify(); + } + ); + + app.decorate( + 'authenticate', + async function ( + request: any, + reply: any + ) { + try { + const user = + await request.jwtVerify(); + + request.user = user; + } catch (_err) { + return reply.status(401).send({ + error: 'Unauthorized', + }); + } + } + ); + + await app.register( + analyticsRoutes, + { + prefix: '/api/analytics', + } + ); + + await app.ready(); + return app; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function authHeader(): Record { + return { + Authorization: + 'Bearer mock-token', + }; +} + +// ─── Test Suite ────────────────────────────────────────────────────────────── + +describe( + 'Analytics API', + () => { + let app: FastifyInstance; + + beforeEach( + async () => { + vi.clearAllMocks(); + + mockJwtVerify.mockResolvedValue( + { + id: MOCK_USER_ID, + } + ); + + app = await buildApp(); + } + ); + + afterEach( + async () => { + await app.close(); + } + ); + + // ── GET /overview ─────────────────────────────────────────────────────── + + describe( + 'GET /api/analytics/overview', + () => { + it( + '200 — returns analytics overview', + async () => { + prismaMock.cardView.count + .mockResolvedValueOnce( + 100 + ) + .mockResolvedValueOnce( + 10 + ); + + prismaMock.followLog.count.mockResolvedValue( + 5 + ); + + prismaMock.cardView.findMany.mockResolvedValue( + [ + { + id: 'view-1', + viewer: { + displayName: + 'John', + avatarUrl: + null, + }, + card: { + title: + 'My Card', + }, + }, + ] + ); + + prismaMock.cardView.groupBy.mockResolvedValue( + [ + { + viewerId: + 'u1', + viewerIp: + null, + }, + { + viewerId: + 'u2', + viewerIp: + null, + }, + ] + ); + + const res = + await app.inject( + { + method: + 'GET', + url: + '/api/analytics/overview', + headers: + authHeader(), + } + ); + + expect( + res.statusCode + ).toBe(200); + + const body = + res.json(); + + expect( + body.totalViews + ).toBe(100); + + expect( + body.viewsToday + ).toBe(10); + + expect( + body.totalFollows + ).toBe(5); + + expect( + body.uniqueViewers + ).toBe(2); + + expect( + body.recentViews + ).toHaveLength( + 1 + ); + } + ); + + it( + '401 — rejects unauthenticated request', + async () => { + mockJwtVerify.mockRejectedValue( + new Error( + 'Unauthorized' + ) + ); + + const res = + await app.inject( + { + method: + 'GET', + url: + '/api/analytics/overview', + } + ); + + expect( + res.statusCode + ).toBe(401); + + expect( + res.json() + ).toMatchObject( + { + error: + 'Unauthorized', + } + ); + } + ); + } + ); + + // ── GET /views ────────────────────────────────────────────────────────── + + describe( + 'GET /api/analytics/views', + () => { + it( + '200 — returns paginated views', + async () => { + prismaMock.cardView.count.mockResolvedValue( + 45 + ); + + prismaMock.cardView.findMany.mockResolvedValue( + [ + { + id: + 'view-1', + viewer: + { + id: + 'viewer-1', + username: + 'john', + displayName: + 'John', + avatarUrl: + null, + }, + card: + { + id: + 'card-1', + title: + 'Portfolio', + }, + }, + ] + ); + + const res = + await app.inject( + { + method: + 'GET', + url: + '/api/analytics/views?page=2', + headers: + authHeader(), + } + ); + + expect( + res.statusCode + ).toBe(200); + + const body = + res.json(); + + expect( + body.data + ).toHaveLength( + 1 + ); + + expect( + body.meta + ).toMatchObject( + { + total: + 45, + page: 2, + limit: + 20, + totalPages: + 3, + } + ); + + expect( + prismaMock.cardView.findMany.mock.calls[0][0] + ).toMatchObject( + { + skip: + 20, + take: + 20, + } + ); + } + ); + + it( + '200 — filters by cardId when provided', + async () => { + prismaMock.cardView.count.mockResolvedValue( + 0 + ); + + prismaMock.cardView.findMany.mockResolvedValue( + [] + ); + + const res = + await app.inject( + { + method: + 'GET', + url: + '/api/analytics/views?cardId=card-123', + headers: + authHeader(), + } + ); + + expect( + res.statusCode + ).toBe(200); + + expect( + prismaMock.cardView.count.mock.calls[0][0] + ).toMatchObject( + { + where: + { + ownerId: + MOCK_USER_ID, + cardId: + 'card-123', + }, + } + ); + } + ); + + it( + '200 — defaults to page 1', + async () => { + prismaMock.cardView.count.mockResolvedValue( + 0 + ); + + prismaMock.cardView.findMany.mockResolvedValue( + [] + ); + + const res = + await app.inject( + { + method: + 'GET', + url: + '/api/analytics/views', + headers: + authHeader(), + } + ); + + expect( + res.statusCode + ).toBe(200); + + expect( + prismaMock.cardView.findMany.mock.calls[0][0] + ).toMatchObject( + { + skip: + 0, + take: + 20, + } + ); + } + ); + + it( + '401 — rejects unauthenticated request', + async () => { + mockJwtVerify.mockRejectedValue( + new Error( + 'Unauthorized' + ) + ); + + const res = + await app.inject( + { + method: + 'GET', + url: + '/api/analytics/views', + } + ); + + expect( + res.statusCode + ).toBe(401); + + expect( + res.json() + ).toMatchObject( + { + error: + 'Unauthorized', + } + ); + } + ); + } + ); + } +); \ No newline at end of file diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 6a937a8..f817eb4 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,30 +1,31 @@ -import Fastify from 'fastify'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import cookie from '@fastify/cookie'; import cors from '@fastify/cors'; import helmet from '@fastify/helmet'; import jwt from '@fastify/jwt'; -import cookie from '@fastify/cookie'; import multipart from '@fastify/multipart'; -import fastifyStatic from '@fastify/static'; import rateLimit from '@fastify/rate-limit'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import fastifyStatic from '@fastify/static'; +import Fastify, {type FastifyInstance} from 'fastify'; import { prismaPlugin } from './plugins/prisma.js'; import { redisPlugin } from './plugins/redis.js'; +import { analyticsRoutes } from './routes/analytics.js'; import { authRoutes } from './routes/auth.js'; -import { profileRoutes } from './routes/profiles.js'; import { cardRoutes } from './routes/cards.js'; -import { publicRoutes } from './routes/public.js'; -import { followRoutes } from './routes/follow.js'; import { connectRoutes } from './routes/connect.js'; -import { analyticsRoutes } from './routes/analytics.js'; -import { nfcRoutes } from './routes/nfc.js'; import { eventRoutes } from './routes/event.js'; +import { followRoutes } from './routes/follow.js'; +import { nfcRoutes } from './routes/nfc.js'; +import { profileRoutes } from './routes/profiles.js'; +import { publicRoutes } from './routes/public.js'; import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export async function buildApp() { +export async function buildApp():Promise { // Validate all required secrets before registering any plugin. // If validation fails the process exits here — no partially-initialised // auth state can exist because Fastify is not yet instantiated. @@ -93,7 +94,7 @@ export async function buildApp() { app.decorate('authenticate', async function (request: any, reply: any) { try { await request.jwtVerify(); - } catch (err) { + } catch (_err) { reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index e9a75bb..7f79f8b 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -1,101 +1,161 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; - -export async function analyticsRoutes(app: FastifyInstance) { - - app.get('/overview', { - preHandler: [app.authenticate], - }, async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const [totalViews, viewsToday, totalFollows, recentViews] = await Promise.all([ - // Total views of this user's cards/profile - app.prisma.cardView.count({ - where: { ownerId: userId }, - }), - // Views today - app.prisma.cardView.count({ - where: { ownerId: userId, createdAt: { gte: today } }, - }), - // Follows performed BY this user - app.prisma.followLog.count({ - where: { followerId: userId, status: 'success' }, - }), - // Recent views (last 5) - app.prisma.cardView.findMany({ - where: { ownerId: userId }, - orderBy: { createdAt: 'desc' }, - take: 5, - include: { - viewer: { - select: { displayName: true, avatarUrl: true }, +import type { + FastifyInstance, + FastifyRequest, + FastifyReply, +} from 'fastify'; + +export async function analyticsRoutes( + app: FastifyInstance +): Promise { + + app.get( + '/overview', + { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, + async ( + request: FastifyRequest, + _reply: FastifyReply + ) => { + const userId = (request.user as any).id; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const [totalViews, viewsToday, totalFollows, recentViews] = await Promise.all([ + // Total views of this user's cards/profile + app.prisma.cardView.count({ + where: { ownerId: userId }, + }), + + // Views today + app.prisma.cardView.count({ + where: { + ownerId: userId, + createdAt: { gte: today }, }, - card: { - select: { title: true }, + }), + + // Follows performed BY this user + app.prisma.followLog.count({ + where: { + followerId: userId, + status: 'success', }, - }, - }), - ]); - - // Count unique viewers - // In raw SQL this is `SELECT COUNT(DISTINCT viewer_id) FROM card_views WHERE owner_id = ?` - // Prisma group-by as workaround: - const uniqueViewersQuery = await app.prisma.cardView.groupBy({ - by: ['viewerId', 'viewerIp'], - where: { ownerId: userId }, - }); - const uniqueViewers = uniqueViewersQuery.length; - - return { - totalViews, - viewsToday, - totalFollows, - uniqueViewers, - recentViews, - }; - }); - - app.get('/views', { - 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 limit = 20; - const skip = (page - 1) * limit; - - const whereClause: any = { ownerId: userId }; - if (request.query.cardId) { - whereClause.cardId = request.query.cardId; - } + }), - const [total, views] = await Promise.all([ - app.prisma.cardView.count({ where: whereClause }), - app.prisma.cardView.findMany({ - where: whereClause, - orderBy: { createdAt: 'desc' }, - skip, - take: limit, - include: { - viewer: { - select: { id: true, username: true, displayName: true, avatarUrl: true }, + // Recent views (last 5) + app.prisma.cardView.findMany({ + where: { ownerId: userId }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + viewer: { + select: { + displayName: true, + avatarUrl: true, + }, + }, + card: { + select: { + title: true, + }, + }, }, - card: { - select: { id: true, title: true }, + }), + ]); + + // Count unique viewers + // In raw SQL this is `SELECT COUNT(DISTINCT viewer_id) FROM card_views WHERE owner_id = ?` + // Prisma group-by as workaround: + const uniqueViewersQuery = + await app.prisma.cardView.groupBy({ + by: ['viewerId', 'viewerIp'], + where: { ownerId: userId }, + }); + + const uniqueViewers = uniqueViewersQuery.length; + + return { + totalViews, + viewsToday, + totalFollows, + uniqueViewers, + recentViews, + }; + } + ); + + app.get<{ + Querystring: { + page?: string; + cardId?: string; + }; + }>( + '/views', + { + // eslint-disable-next-line @typescript-eslint/unbound-method + 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 limit = 20; + const skip = (page - 1) * limit; + + const whereClause: any = { ownerId: userId }; + + if (request.query.cardId) { + whereClause.cardId = request.query.cardId; + } + + const [total, views] = await Promise.all([ + app.prisma.cardView.count({ + where: whereClause, + }), + + app.prisma.cardView.findMany({ + where: whereClause, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { + viewer: { + select: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + card: { + select: { + id: true, + title: true, + }, + }, }, + }), + ]); + + return { + data: views, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), }, - }), - ]); - - return { - data: views, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; - }); -} + }; + } + ); +} \ No newline at end of file