Skip to content
Closed
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jobs:
- name: Install backend dependencies
run: npm --prefix apps/backend install

- name: Generate Prisma Client
run: cd apps/backend && npx prisma generate

- name: Backend lint
id: backend_lint
continue-on-error: true
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"postinstall": "prisma generate"
},
"dependencies": {
"@devcard/shared": "file:../../packages/shared",
Expand Down
11 changes: 6 additions & 5 deletions apps/backend/src/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Fastify, {
type FastifyInstance,
} from 'fastify';
import {
describe,
it,
Expand All @@ -7,13 +10,11 @@ import {
vi,
} from 'vitest';

import Fastify, {
type FastifyInstance,
} from 'fastify';

import { analyticsRoutes } from '../routes/analytics';

import type { PrismaClient } from '@prisma/client';

import { analyticsRoutes } from '../routes/analytics';

// ─── Shared mock data ────────────────────────────────────────────────────────

Expand All @@ -34,7 +35,7 @@ const prismaMock = {

// ─── App factory ─────────────────────────────────────────────────────────────

let mockJwtVerify = vi.fn();
const mockJwtVerify = vi.fn();

async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
process.env.NODE_ENV = 'test';

import { describe, it, expect } from 'vitest';

import { buildApp } from '../app';

process.env.NODE_ENV = 'test';

describe('GET /health', () => {
it('should return status ok', async () => {
const app = await buildApp();
Expand Down
34 changes: 27 additions & 7 deletions apps/backend/src/__tests__/event.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { FastifyInstance } from 'fastify';
import { PrismaClient } from '@prisma/client';

import { eventRoutes } from '../routes/event';

import type { PrismaClient } from '@prisma/client';

// ─── Shared mock data ────────────────────────────────────────────────────────

const MOCK_USER_ID = 'user-uuid-001';
Expand Down Expand Up @@ -64,7 +66,7 @@
//
// This mirrors the real app setup without touching a real DB or real JWT keys.

let mockJwtVerify = vi.fn();
const mockJwtVerify = vi.fn();

async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
Expand All @@ -74,8 +76,10 @@

// Decorate jwtVerify on the request prototype so request.jwtVerify() resolves
// to whatever the current test wants.
app.decorateRequest('jwtVerify', function () {
return mockJwtVerify();
app.decorateRequest('jwtVerify', async function (this: any) {
const payload = await mockJwtVerify();
this.user = payload;
return payload;
});

// Register with the same prefix used in production (app.ts) so that
Expand All @@ -93,7 +97,7 @@
}

/** Injects a POST /api/events request */
async function createEvent(

Check warning on line 100 in apps/backend/src/__tests__/event.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
app: FastifyInstance,
body: Record<string, unknown>,
authenticated = true,
Expand Down Expand Up @@ -252,6 +256,10 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
_count: { attendees: 42 },
organizer: {
username: 'johndoe',
displayName: 'John Doe',
},
});

const res = await app.inject({
Expand All @@ -264,8 +272,9 @@
expect(body.slug).toBe('devcard-conf-2025');
expect(body.attendeesCount).toBe(42);
expect(body.location).toBe('San Francisco, CA');
// organizerId is exposed (public info)
expect(body.organizerId).toBe(MOCK_USER_ID);
// organizer public fields are exposed
expect(body.organizerUsername).toBe('johndoe');
expect(body.organizerDisplayName).toBe('John Doe');
});

it('404 — returns 404 for unknown slug', async () => {
Expand All @@ -286,6 +295,10 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
_count: { attendees: 0 },
organizer: {
username: 'johndoe',
displayName: 'John Doe',
},
});

const res = await app.inject({
Expand Down Expand Up @@ -474,7 +487,7 @@

describe('GET /api/events/:slug/attendees — paginated attendee list', () => {
/** Builds a raw EventAttendee row as Prisma returns it (with nested user) */
function makeAttendeeRow(

Check warning on line 490 in apps/backend/src/__tests__/event.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
profile: typeof MOCK_USER_PROFILE | typeof MOCK_OTHER_USER_PROFILE,
) {
return {
Expand All @@ -495,6 +508,7 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
attendees: attendeeRows,
_count: { attendees: 2 },
});

const res = await app.inject({
Expand Down Expand Up @@ -523,6 +537,7 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)],
_count: { attendees: 1 },
});

const res = await app.inject({
Expand All @@ -545,6 +560,7 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
attendees: [],
_count: { attendees: 0 },
});

const res = await app.inject({
Expand All @@ -561,6 +577,7 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
attendees: [],
_count: { attendees: 0 },
});

const res = await app.inject({
Expand All @@ -577,6 +594,7 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
attendees: [],
_count: { attendees: 0 },
});

const res = await app.inject({
Expand All @@ -594,6 +612,7 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
attendees: [makeAttendeeRow(MOCK_USER_PROFILE)],
_count: { attendees: 1 },
});

const res = await app.inject({
Expand Down Expand Up @@ -632,6 +651,7 @@
prismaMock.event.findUnique.mockResolvedValue({
...MOCK_EVENT,
attendees: [],
_count: { attendees: 0 },
});

await app.inject({
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/__tests__/follow.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Fastify, { FastifyInstance } from 'fastify';
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, vi, beforeAll, beforeEach, afterAll } from 'vitest';

import { followRoutes } from '../routes/follow.js';
Expand Down
8 changes: 5 additions & 3 deletions apps/backend/src/__tests__/oauth-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
* flow so the two records are independent and can never overwrite each other.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { connectRoutes } from '../routes/connect.js';
import { followRoutes } from '../routes/follow.js';

import type { PrismaClient } from '@prisma/client';

// ── Mocks ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -42,20 +44,20 @@
return Buffer.from(JSON.stringify({ userId, nonce: 'test-nonce' })).toString('base64');
}

function buildConnectApp(mockPrisma: Partial<PrismaClient>) {

Check warning on line 47 in apps/backend/src/__tests__/oauth-scope.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma as PrismaClient);
app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; });
app.decorate('authenticate', async (request: any) => { request.user = { id: USER_ID }; });
app.register(connectRoutes, { prefix: '/api/connect' });
return app.ready().then(() => app);
}

// ── Follow-route test harness ─────────────────────────────────────────────────

function buildFollowApp(mockPrisma: Partial<PrismaClient>) {

Check warning on line 57 in apps/backend/src/__tests__/oauth-scope.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma as PrismaClient);
app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; });
app.decorate('authenticate', async (request: any) => { request.user = { id: USER_ID }; });
app.register(followRoutes, { prefix: '/api/follow' });
return app.ready().then(() => app);
}
Expand Down
10 changes: 6 additions & 4 deletions apps/backend/src/__tests__/public.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';
import jwt from '@fastify/jwt';
import Fastify from 'fastify';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { publicRoutes } from '../routes/public.js';
import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';

import type { PrismaClient } from '@prisma/client';


// ── Mock QR utilities ─────────────────────────────────────────────────────────
// Prevents real QR rasterisation (and any native canvas/image deps) from running
// during unit tests. The stubs return minimal valid values that satisfy the
Expand All @@ -13,8 +17,6 @@
generateQRSvg: vi.fn().mockResolvedValue('<svg>fake</svg>'),
}));

import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';

const mockUser = {
id: 'user-123',
username: 'testuser',
Expand Down Expand Up @@ -50,7 +52,7 @@
del: vi.fn().mockResolvedValue(1),
};

async function buildApp() {

Check warning on line 55 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const app = Fastify();
// Register JWT so app.jwt.sign() is available for the qr-session route.
// @fastify/jwt also adds request.jwtVerify(), which throws when no valid
Expand Down
13 changes: 8 additions & 5 deletions apps/backend/src/__tests__/team.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type PrismaClient, TeamRole } from '@prisma/client';
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { FastifyInstance } from 'fastify';
import { PrismaClient, TeamRole } from '@prisma/client';

import { teamRoutes } from '../routes/team';

// ─── Shared mock data ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -92,15 +93,17 @@

// ─── App factory ──────────────────────────────────────────────────────────────

let mockJwtVerify = vi.fn();
const mockJwtVerify = vi.fn();

async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });

app.decorate('prisma', prismaMock as unknown as PrismaClient);

app.decorateRequest('jwtVerify', function () {
return mockJwtVerify();
app.decorateRequest('jwtVerify', async function (this: any) {
const payload = await mockJwtVerify();
this.user = payload;
return payload;
});

await app.register(teamRoutes);
Expand All @@ -114,7 +117,7 @@
return { Authorization: 'Bearer mock-token' };
}

async function createTeam(

Check warning on line 120 in apps/backend/src/__tests__/team.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
app: FastifyInstance,
body: Record<string, unknown>,
authenticated = true,
Expand Down
11 changes: 7 additions & 4 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import helmet from '@fastify/helmet';
import jwt from '@fastify/jwt';
import multipart from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import Fastify, {type FastifyInstance} from 'fastify';

import { prismaPlugin } from './plugins/prisma.js';
Expand All @@ -21,15 +20,19 @@ 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';
import { teamRoutes } from './routes/team.js';
import { validateEnv } from './utils/validateEnv.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export async function buildApp():Promise<FastifyInstance> {
// 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.
if (process.env.NODE_ENV === 'test') {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-that-is-sufficiently-long-and-secure';
process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'test-encryption-key-for-testing-purposes-32-chars';
}
validateEnv();

const app = Fastify({
Expand Down Expand Up @@ -92,8 +95,8 @@ export async function buildApp():Promise<FastifyInstance> {
try {
// Ensure the verified payload is assigned to `request.user` like the original plugin.
const payload = await request.jwtVerify();
if (payload) request.user = payload;
} catch (error) {
if (payload) {request.user = payload;}
} catch {
reply.status(401).send({ error: 'Unauthorized' });
}
});
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import process from 'node:process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import dotenv from 'dotenv';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/plugins/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';
import fp from 'fastify-plugin';

import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

declare module 'fastify' {
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/plugins/redis.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fp from 'fastify-plugin';
import Redis from 'ioredis';

import type { FastifyInstance } from 'fastify';

declare module 'fastify' {
Expand All @@ -17,7 +18,7 @@ export const redisPlugin = fp(async (app: FastifyInstance) => {
try {
await redis.connect();
app.log.info('🔴 Redis connected');
} catch (error) {
} catch {
app.log.warn('⚠️ Redis connection failed — running without cache');
}

Expand Down
8 changes: 4 additions & 4 deletions apps/backend/src/routes/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export async function analyticsRoutes(
app.get(
'/overview',
{
// eslint-disable-next-line @typescript-eslint/unbound-method
preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }],
preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }],
},
async (
request: FastifyRequest,
Expand Down Expand Up @@ -96,8 +96,8 @@ export async function analyticsRoutes(
}>(
'/views',
{
// eslint-disable-next-line @typescript-eslint/unbound-method
preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }],
preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }],
},
async (
request: FastifyRequest<{
Expand Down
Loading