test(integration): add real-stack integration test suite with PGlite
Docker Image CI / test (push) Successful in 1m47s
Tests / unit (push) Successful in 57s
Tests / integration (push) Successful in 1m38s
Docker Image CI / deploy (push) Failing after 8s

- PGlite (in-memory WASM Postgres) hosted in a child process, exposed
  via TCP socket; real migrations applied with prisma migrate deploy
- Vitest harness (vitest.integration.config.ts, global-setup spawns
  PGlite + built Nuxt server, per-test TRUNCATE, factories, cookie-jar
  API client); no mocks, zero production code changes
- 50 tests across auth, hunt, team, submit, review, showcase, diashow
  incl. SuperJSON envelope check; *.itest.ts keeps bun test untouched
- CI: parallel unit + integration jobs in .github/workflows/test.yml
- tests/ covered by nuxt typecheck via tests/tsconfig.json reference
This commit is contained in:
2026-08-13 23:10:07 +02:00
parent 28714a8cbc
commit 01d1e5fb60
22 changed files with 1813 additions and 49 deletions
+69
View File
@@ -0,0 +1,69 @@
import { baseUrl } from '../integration/setup';
/**
* Minimal fetch wrapper bound to the test server with a per-instance
* cookie jar, so each client represents one browser session.
*/
export class ApiClient {
private readonly cookies = new Map<string, string>();
constructor(private readonly base: string) {}
async request(path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
if (this.cookies.size > 0) {
headers.set(
'cookie',
[...this.cookies].map(([name, value]) => `${name}=${value}`).join('; ')
);
}
const res = await fetch(`${this.base}${path}`, { ...init, headers });
for (const cookie of res.headers.getSetCookie()) {
const [pair, ...attributes] = cookie.split(';');
const separator = pair.indexOf('=');
const name = pair.slice(0, separator).trim();
const value = pair.slice(separator + 1).trim();
const expired = attributes.some((attr) =>
/^expires=Thu, 01 Jan 1970/i.test(attr.trim())
);
if (value === '' || expired) {
this.cookies.delete(name);
} else {
this.cookies.set(name, value);
}
}
return res;
}
get(path: string) {
return this.request(path);
}
post(path: string, body?: unknown) {
const init: RequestInit =
body instanceof FormData
? { method: 'POST', body }
: {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body ?? {})
};
return this.request(path, init);
}
async register(email: string, name: string, password: string) {
return this.post('/api/auth/register', { email, name, password });
}
async login(email: string, password: string) {
return this.post('/api/auth/login', { email, password });
}
}
export const api = new ApiClient(baseUrl);
export function newClient() {
return new ApiClient(baseUrl);
}
+19
View File
@@ -0,0 +1,19 @@
import { prisma } from '../integration/setup';
/**
* Truncates every table in the public schema (except _prisma_migrations)
* so each test starts from an empty database. Sequences are reset to keep
* autoincrement ids predictable.
*/
export async function resetDatabase() {
const tables = await prisma.$queryRawUnsafe<{ tablename: string }[]>(
`SELECT tablename::text AS tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'`
);
if (tables.length === 0) return;
const list = tables.map(({ tablename }) => `"public"."${tablename}"`);
await prisma.$executeRawUnsafe(
`TRUNCATE ${list.join(', ')} RESTART IDENTITY CASCADE;`
);
}
+147
View File
@@ -0,0 +1,147 @@
import type { Prisma, UserRole } from '#shared/generated/prisma/client';
import { hash } from 'argon2';
import { randomBytes, randomInt } from 'node:crypto';
import { prisma } from '../integration/setup';
/** Default plaintext password used by createUser(); login tests rely on it. */
export const TEST_PASSWORD = 'super-secret-123';
let seq = 0;
// argon2 hashing is comparatively slow, so cache one hash per password.
const passwordHashes = new Map<string, Promise<string>>();
function hashPassword(password: string) {
if (!passwordHashes.has(password)) {
passwordHashes.set(password, hash(password));
}
return passwordHashes.get(password)!;
}
export async function createUser(
overrides: {
email?: string;
name?: string;
password?: string;
role?: UserRole;
} = {}
) {
seq += 1;
return prisma.user.create({
data: {
email: overrides.email ?? `user-${seq}-${randomInt(100_000)}@test.dev`,
name: overrides.name ?? `User ${seq}`,
password: await hashPassword(overrides.password ?? TEST_PASSWORD),
role: overrides.role
}
});
}
export async function createHunt(
creatorId: number,
overrides: Partial<Prisma.HuntUncheckedCreateInput> = {}
) {
seq += 1;
return prisma.hunt.create({
data: {
name_en: `Hunt ${seq}`,
name_de: `Jagd ${seq}`,
description_en: `Description ${seq}`,
description_de: `Beschreibung ${seq}`,
creatorId,
...overrides
}
});
}
export async function createHuntMember(huntId: number, memberId: number) {
return prisma.huntMember.create({
data: { huntId, memberId }
});
}
export async function createQuest(
huntId: number,
overrides: Partial<Prisma.HuntQuestUncheckedCreateInput> = {}
) {
seq += 1;
return prisma.huntQuest.create({
data: {
title_en: `Quest ${seq}`,
title_de: `Aufgabe ${seq}`,
huntId,
...overrides
}
});
}
export async function createTeam(
huntId: number,
ownerId: number,
overrides: Partial<Prisma.HuntTeamUncheckedCreateInput> = {}
) {
seq += 1;
return prisma.huntTeam.create({
data: {
name: `Team ${seq}`,
// same format as the production endpoint: 6 hex chars
password: randomBytes(3).toString('hex'),
huntId,
ownerId,
...overrides
}
});
}
export async function createTeamMember(teamId: number, memberId: number) {
return prisma.teamMember.create({
data: { teamId, memberId }
});
}
export async function createAnswer(
{
teamId,
questId,
memberId
}: { teamId: number; questId: number; memberId: number },
overrides: Partial<Prisma.QuestAnswerUncheckedCreateInput> = {}
) {
return prisma.questAnswer.create({
data: {
teamId,
questId,
memberId,
...overrides
}
});
}
export async function createFile(
creatorId: number,
overrides: Partial<Prisma.FileUncheckedCreateInput> = {}
) {
seq += 1;
const uuid = `file-${seq}-${randomInt(100_000)}`;
return prisma.file.create({
data: {
uuid,
url: `https://bucket.test/${uuid}`,
creatorId,
...overrides
}
});
}
export async function createReview(
answerId: number,
reviewerId: number,
overrides: Partial<Prisma.ReviewUncheckedCreateInput> = {}
) {
return prisma.review.create({
data: {
answerId,
reviewerId,
...overrides
}
});
}
+124
View File
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { newClient } from '../helpers/api';
import { resetDatabase } from '../helpers/database';
import { createUser, TEST_PASSWORD } from '../helpers/factories';
beforeEach(resetDatabase);
describe('register', () => {
it('creates a user and starts a session', async () => {
const client = newClient();
const res = await client.register(
'new-user@test.dev',
'New User',
'verysecret1'
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.user.id).toBeTypeOf('number');
// The register response already carries a session cookie.
const profile = await client.get('/api/auth/profile');
expect(profile.status).toBe(200);
expect(await profile.json()).toMatchObject({
name: 'New User',
email: 'new-user@test.dev',
uploadedFiles: []
});
});
it('rejects invalid input with 400', async () => {
const client = newClient();
// password too short
expect(
(await client.register('short@test.dev', 'Short', 'short')).status
).toBe(400);
// invalid email
expect(
(await client.register('not-an-email', 'NoMail', 'longenough1')).status
).toBe(400);
// empty name
expect(
(await client.register('empty@test.dev', '', 'longenough1')).status
).toBe(400);
});
it('rejects duplicate email with 409', async () => {
await createUser({ email: 'dupe@test.dev' });
const client = newClient();
const res = await client.register('dupe@test.dev', 'Dupe', 'verysecret1');
expect(res.status).toBe(409);
});
});
describe('login', () => {
it('logs in with correct credentials', async () => {
const user = await createUser({ email: 'login@test.dev' });
const client = newClient();
const res = await client.login('login@test.dev', TEST_PASSWORD);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
success: true,
user: { id: user.id }
});
const profile = await client.get('/api/auth/profile');
expect(profile.status).toBe(200);
});
it('rejects wrong password and unknown email with 404', async () => {
await createUser({ email: 'known@test.dev' });
const client = newClient();
expect(
(await client.login('known@test.dev', 'wrong-password-1')).status
).toBe(404);
expect((await client.login('unknown@test.dev', TEST_PASSWORD)).status).toBe(
404
);
});
});
describe('profile', () => {
it('requires a session', async () => {
const res = await newClient().get('/api/auth/profile');
expect(res.status).toBe(401);
});
it('updates name and email', async () => {
await createUser({ email: 'update-me@test.dev' });
const client = newClient();
await client.login('update-me@test.dev', TEST_PASSWORD);
const res = await client.post('/api/auth/profile', {
name: 'Updated Name',
email: 'updated@test.dev'
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
const profile = await client.get('/api/auth/profile');
expect(await profile.json()).toMatchObject({
name: 'Updated Name',
email: 'updated@test.dev'
});
// login works with the new email afterwards
expect(
(await newClient().login('updated@test.dev', TEST_PASSWORD)).status
).toBe(200);
});
it('rejects taking an existing email with 409', async () => {
await createUser({ email: 'first@test.dev' });
await createUser({ email: 'second@test.dev' });
const client = newClient();
await client.login('first@test.dev', TEST_PASSWORD);
const res = await client.post('/api/auth/profile', {
name: 'First User',
email: 'second@test.dev'
});
expect(res.status).toBe(409);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { newClient } from '../helpers/api';
import { resetDatabase } from '../helpers/database';
import {
createAnswer,
createFile,
createHunt,
createQuest,
createTeam,
createUser,
TEST_PASSWORD
} from '../helpers/factories';
import { prisma } from './setup';
beforeEach(resetDatabase);
/**
* Hunt with one quest and three finalized answers:
* - a public picture (shown)
* - a private picture (hidden)
* - no picture at all (hidden)
*/
async function seedDiashow(reveal: boolean) {
const creator = await createUser();
const hunt = await createHunt(creator.id, {
revealAnswers: reveal,
revealQuests: reveal
});
const quest = await createQuest(hunt.id);
const owner = await createUser();
const team = await createTeam(hunt.id, owner.id);
const answerPublic = await createAnswer({
teamId: team.id,
questId: quest.id,
memberId: owner.id
});
const publicFile = await createFile(owner.id, { answerId: answerPublic.id });
await prisma.questAnswer.update({
where: { id: answerPublic.id },
data: { pictureId: publicFile.id, final: true }
});
const answerPrivate = await createAnswer({
teamId: team.id,
questId: quest.id,
memberId: owner.id
});
const privateFile = await createFile(owner.id, {
answerId: answerPrivate.id,
private: true
});
await prisma.questAnswer.update({
where: { id: answerPrivate.id },
data: { pictureId: privateFile.id, final: true }
});
await createAnswer(
{ teamId: team.id, questId: quest.id, memberId: owner.id },
{ final: true, text: 'no picture' }
);
return { hunt, quest, team, publicFile };
}
describe('diashow (GET /api/hunt/[huntId]/diashow)', () => {
it('returns 404 for unknown hunts', async () => {
const res = await newClient().get('/api/hunt/999999/diashow');
expect(res.status).toBe(404);
});
it('blocks anonymous users before answers are revealed', async () => {
const { hunt } = await seedDiashow(false);
const res = await newClient().get(`/api/hunt/${hunt.id}/diashow`);
expect(res.status).toBe(403);
});
it('lets the hunt creator peek before reveal', async () => {
const { hunt } = await seedDiashow(false);
const creator = await prisma.user.findFirstOrThrow({
where: { huntsCreated: { some: { id: hunt.id } } }
});
const client = newClient();
await client.login(creator.email, TEST_PASSWORD);
const res = await client.get(`/api/hunt/${hunt.id}/diashow`);
expect(res.status).toBe(200);
});
it('only shows finalized answers with public pictures', async () => {
const { hunt, quest, team, publicFile } = await seedDiashow(true);
const res = await newClient().get(`/api/hunt/${hunt.id}/diashow`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(hunt.id);
expect(body.quests).toHaveLength(1);
expect(body.quests[0].id).toBe(quest.id);
expect(body.quests[0].answers).toHaveLength(1);
expect(body.quests[0].answers[0].picture.url).toBe(publicFile.url);
expect(body.quests[0].answers[0].team).toMatchObject({
id: team.id,
name: team.name
});
});
it('omits quests whose final answers are all private', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id, {
revealAnswers: true,
revealQuests: true
});
const quest = await createQuest(hunt.id);
const owner = await createUser();
const team = await createTeam(hunt.id, owner.id);
const answer = await createAnswer({
teamId: team.id,
questId: quest.id,
memberId: owner.id
});
const privateFile = await createFile(owner.id, {
answerId: answer.id,
private: true
});
await prisma.questAnswer.update({
where: { id: answer.id },
data: { pictureId: privateFile.id, final: true }
});
const res = await newClient().get(`/api/hunt/${hunt.id}/diashow`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.quests).toEqual([]);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import type { TestProject } from 'vitest/node';
// Fixed test-only secret, >= 32 chars as required by nuxt-auth-utils.
const SESSION_PASSWORD = 'integration-tests-only-not-for-prod-use!';
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('Could not determine a free port'));
return;
}
server.close(() => resolve(address.port));
});
});
}
function waitForLine(child: ChildProcess, pattern: RegExp): Promise<string[]> {
return new Promise((resolve, reject) => {
let buffer = '';
const onData = (chunk: Buffer) => {
buffer += chunk.toString();
for (const line of buffer.split('\n')) {
const match = line.match(pattern);
if (match) {
child.stdout?.off('data', onData);
resolve(match.slice(1));
return;
}
}
};
child.stdout?.on('data', onData);
child.on('exit', (code) =>
reject(new Error(`Child exited with code ${code} before being ready`))
);
child.on('error', reject);
});
}
function run(command: string, args: string[], env: NodeJS.ProcessEnv = {}) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
env: { ...process.env, ...env }
});
child.on('exit', (code) =>
code === 0
? resolve()
: reject(new Error(`${command} ${args.join(' ')} exited with ${code}`))
);
child.on('error', reject);
});
}
async function waitForHealth(baseUrl: string, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
let lastError: unknown = new Error('server never became healthy');
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseUrl}/api/health`);
if (res.ok) return;
lastError = new Error(`health responded with ${res.status}`);
} catch (e) {
lastError = e;
}
await new Promise((r) => setTimeout(r, 250));
}
throw lastError;
}
function kill(child: ChildProcess) {
return new Promise<void>((resolve) => {
if (child.exitCode !== null) return resolve();
const timer = setTimeout(() => {
child.kill('SIGKILL');
}, 3_000);
child.on('exit', () => {
clearTimeout(timer);
resolve();
});
child.kill('SIGTERM');
});
}
export default async function globalSetup(project: TestProject) {
// 1. Boot in-memory PGlite behind a Postgres wire protocol socket.
// It runs in a child process: teardown under Bun exits with code 99
// (Emscripten), and isolating it keeps the vitest process clean.
const dbHost = spawn('node', ['tests/integration/pglite-host.mjs'], {
stdio: ['ignore', 'pipe', 'inherit']
});
const [databaseUrl] = await waitForLine(dbHost, /^PGLITE_READY (.+)$/);
// 2. Apply all migrations against the socket (proven path from Phase 0).
await run('bunx', ['prisma', 'migrate', 'deploy'], {
DATABASE_URL: databaseUrl
});
// 3. Build the Nuxt server once for all test files.
if (process.env.SKIP_BUILD !== '1') {
await run('bun', ['run', 'build']);
}
// 4. Spawn the built server against the WASM database.
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
const server = spawn('node', ['.output/server/index.mjs'], {
stdio: 'inherit',
env: {
...process.env,
NODE_ENV: 'production',
PORT: String(port),
DATABASE_URL: databaseUrl,
NUXT_SESSION_PASSWORD: SESSION_PASSWORD
}
});
server.on('error', (e) => {
throw e;
});
try {
await waitForHealth(baseUrl);
} catch (e) {
await kill(server);
await kill(dbHost);
throw e;
}
project.provide('baseUrl', baseUrl);
project.provide('databaseUrl', databaseUrl);
return async () => {
await kill(server);
await kill(dbHost);
};
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { api } from '../helpers/api';
// Proves the full loop: PGlite -> migrations -> build -> server -> fetch.
describe('health', () => {
it('reports a working database connection', async () => {
const res = await api.get('/api/health');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, db: true });
});
});
+183
View File
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { newClient } from '../helpers/api';
import { resetDatabase } from '../helpers/database';
import {
createHunt,
createHuntMember,
createQuest,
createTeam,
createTeamMember,
createUser,
TEST_PASSWORD
} from '../helpers/factories';
import { prisma } from './setup';
beforeEach(resetDatabase);
describe('hunt listing (GET /api/home)', () => {
it('lists only public, joinable hunts that have not started yet', async () => {
const creator = await createUser();
await createHunt(creator.id, { allowJoin: true });
await createHunt(creator.id, { allowJoin: true, public: false });
await createHunt(creator.id, { allowJoin: false });
await createHunt(creator.id, {
allowJoin: true,
start: new Date('2020-01-01')
});
await createHunt(creator.id, {
allowJoin: true,
start: new Date(Date.now() + 24 * 60 * 60 * 1000)
});
await createHunt(creator.id, {
allowJoin: true,
deletedAt: new Date()
});
const res = await newClient().get('/api/home');
expect(res.status).toBe(200);
const body = await res.json();
expect(body.own).toBeNull();
// one hunt without start + one future hunt
expect(body.others).toHaveLength(2);
});
it('lists hunts the user participates in under own', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id, { allowJoin: true });
const player = await createUser({ email: 'player@test.dev' });
const team = await createTeam(hunt.id, creator.id);
await createTeamMember(team.id, player.id);
const client = newClient();
await client.login('player@test.dev', TEST_PASSWORD);
const res = await client.get('/api/home');
const body = await res.json();
expect(body.own).toHaveLength(1);
expect(body.own[0].id).toBe(hunt.id);
// the hunt is excluded from others because the user already plays it
expect(body.others).toHaveLength(0);
});
});
describe('hunt detail (GET /api/hunt/[huntId])', () => {
it('returns 404 for unknown hunts', async () => {
const res = await newClient().get('/api/hunt/999999');
expect(res.status).toBe(404);
});
it('hides quests from anonymous users when revealQuests is false', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id);
await createQuest(hunt.id);
const res = await newClient().get(`/api/hunt/${hunt.id}`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.quests).toBeNull();
expect(body.isMember).toBe(false);
expect(body.loggedIn).toBe(false);
expect(body.ownTeam).toBeNull();
});
it('shows quests to hunt members even without revealQuests', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id);
await createQuest(hunt.id);
const member = await createUser({ email: 'member@test.dev' });
await createHuntMember(hunt.id, member.id);
const client = newClient();
await client.login('member@test.dev', TEST_PASSWORD);
const res = await client.get(`/api/hunt/${hunt.id}`);
const body = await res.json();
expect(body.isMember).toBe(true);
expect(body.quests).toHaveLength(1);
});
it('exposes the own team to team owners', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id, { revealQuests: true });
const team = await createTeam(hunt.id, creator.id, { name: 'Owners' });
const client = newClient();
await client.login(creator.email, TEST_PASSWORD);
const res = await client.get(`/api/hunt/${hunt.id}`);
const body = await res.json();
expect(body.ownTeam).toMatchObject({ id: team.id, name: 'Owners' });
});
});
describe('join hunt (POST /api/hunt/[huntId]/join)', () => {
it('requires a session', async () => {
const res = await newClient().post('/api/hunt/1/join', {
team: 1,
password: 'x'
});
expect(res.status).toBe(401);
});
it('joins a team with the correct invite password', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id, { allowJoin: true });
const team = await createTeam(hunt.id, creator.id, { password: 'abc123' });
const player = await createUser({ email: 'joiner@test.dev' });
const client = newClient();
await client.login('joiner@test.dev', TEST_PASSWORD);
const res = await client.post(`/api/hunt/${hunt.id}/join`, {
team: team.id,
password: 'abc123'
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
expect(
await prisma.teamMember.count({
where: { teamId: team.id, memberId: player.id }
})
).toBe(1);
});
it('rejects wrong team or password with 404', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id, { allowJoin: true });
const team = await createTeam(hunt.id, creator.id, { password: 'abc123' });
await createUser({ email: 'joiner@test.dev' });
const client = newClient();
await client.login('joiner@test.dev', TEST_PASSWORD);
expect(
(
await client.post(`/api/hunt/${hunt.id}/join`, {
team: team.id,
password: 'wrong'
})
).status
).toBe(404);
expect(
(
await client.post(`/api/hunt/${hunt.id}/join`, {
team: team.id + 999,
password: 'abc123'
})
).status
).toBe(404);
});
it('rejects joining twice with 409', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id, { allowJoin: true });
const team = await createTeam(hunt.id, creator.id, { password: 'abc123' });
const player = await createUser({ email: 'joiner@test.dev' });
await createTeamMember(team.id, player.id);
const client = newClient();
await client.login('joiner@test.dev', TEST_PASSWORD);
const res = await client.post(`/api/hunt/${hunt.id}/join`, {
team: team.id,
password: 'abc123'
});
expect(res.status).toBe(409);
});
});
+26
View File
@@ -0,0 +1,26 @@
// Hosts an in-memory PGlite database behind a TCP socket speaking the
// Postgres wire protocol. Spawned by global-setup.ts as a child process.
//
// Deliberately plain .mjs so it can run under a bare `node` with no
// TS loader, and deliberately never calls db.close()/server.stop():
// under Bun, PGlite teardown triggers an Emscripten exit(99) which would
// kill the process with a non-zero code. Cleanup happens via SIGTERM/SIGKILL.
import { PGlite } from '@electric-sql/pglite';
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
const db = new PGlite();
await db.waitReady;
const server = new PGLiteSocketServer({
db,
port: 0,
host: '127.0.0.1',
maxConnections: 20
});
await server.start();
const [host, port] = server.getServerConn().split(':');
console.log(`PGLITE_READY postgresql://postgres@${host}:${port}/postgres`);
process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
+186
View File
@@ -0,0 +1,186 @@
import superjson from 'superjson';
import { beforeEach, describe, expect, it } from 'vitest';
import { newClient } from '../helpers/api';
import { resetDatabase } from '../helpers/database';
import {
createAnswer,
createHunt,
createHuntMember,
createQuest,
createTeam,
createUser,
TEST_PASSWORD
} from '../helpers/factories';
import { prisma } from './setup';
beforeEach(resetDatabase);
/** Hunt with one quest, one team and a submitted (non-final) answer. */
async function seedAnswer() {
const creator = await createUser();
const hunt = await createHunt(creator.id);
const quest = await createQuest(hunt.id);
const owner = await createUser();
const team = await createTeam(hunt.id, owner.id);
const answer = await createAnswer({
teamId: team.id,
questId: quest.id,
memberId: owner.id
});
return { creator, hunt, quest, owner, team, answer };
}
const reviewBody = {
score: 42,
review: 'Nice shot!',
showcase: true,
internalNote: 'check exposure',
rankingValue: 7
};
describe('review answer (POST /api/admin/answer/[answerId]/review)', () => {
it('requires a session', async () => {
const res = await newClient().post('/api/admin/answer/1/review', {
...reviewBody,
showcase: false
});
expect(res.status).toBe(401);
});
it('returns 404 for unknown answers', async () => {
const { hunt } = await seedAnswer();
const organizer = await createUser({ email: 'organizer@test.dev' });
await createHuntMember(hunt.id, organizer.id);
const client = newClient();
await client.login('organizer@test.dev', TEST_PASSWORD);
const res = await client.post('/api/admin/answer/999999/review', {
...reviewBody,
showcase: false
});
expect(res.status).toBe(404);
});
it('denies non-members of the hunt with 404', async () => {
const { answer } = await seedAnswer();
await createUser({ email: 'stranger@test.dev' });
const client = newClient();
await client.login('stranger@test.dev', TEST_PASSWORD);
const res = await client.post(
`/api/admin/answer/${answer.id}/review`,
reviewBody
);
expect(res.status).toBe(404);
expect(await prisma.review.count()).toBe(0);
});
it('creates a review as a hunt member', async () => {
const { hunt, answer } = await seedAnswer();
const organizer = await createUser({ email: 'organizer@test.dev' });
await createHuntMember(hunt.id, organizer.id);
const client = newClient();
await client.login('organizer@test.dev', TEST_PASSWORD);
const res = await client.post(
`/api/admin/answer/${answer.id}/review`,
reviewBody
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: answer.id });
const review = await prisma.review.findUniqueOrThrow({
where: { answerId: answer.id }
});
expect(review).toMatchObject({
score: 42,
review: 'Nice shot!',
showcase: true,
internalNote: 'check exposure',
rankingValue: 7,
reviewerId: organizer.id
});
});
it('updates the existing review on a second pass', async () => {
const { hunt, answer } = await seedAnswer();
const first = await createUser({ email: 'first@test.dev' });
const second = await createUser({ email: 'second@test.dev' });
await createHuntMember(hunt.id, first.id);
await createHuntMember(hunt.id, second.id);
const firstClient = newClient();
await firstClient.login('first@test.dev', TEST_PASSWORD);
await firstClient.post(`/api/admin/answer/${answer.id}/review`, reviewBody);
const secondClient = newClient();
await secondClient.login('second@test.dev', TEST_PASSWORD);
const res = await secondClient.post(
`/api/admin/answer/${answer.id}/review`,
{ ...reviewBody, score: 100, showcase: false }
);
expect(res.status).toBe(200);
const reviews = await prisma.review.findMany({
where: { answerId: answer.id }
});
expect(reviews).toHaveLength(1);
expect(reviews[0]).toMatchObject({
score: 100,
showcase: false,
reviewerId: second.id
});
});
it('rejects invalid scores with 400', async () => {
const { hunt, answer } = await seedAnswer();
const organizer = await createUser({ email: 'organizer@test.dev' });
await createHuntMember(hunt.id, organizer.id);
const client = newClient();
await client.login('organizer@test.dev', TEST_PASSWORD);
const res = await client.post(`/api/admin/answer/${answer.id}/review`, {
...reviewBody,
score: -5
});
expect(res.status).toBe(400);
});
});
describe('hunt admin detail (GET /api/admin/hunt/[huntId])', () => {
it('denies non-members with 404', async () => {
const { hunt } = await seedAnswer();
await createUser({ email: 'stranger@test.dev' });
const client = newClient();
await client.login('stranger@test.dev', TEST_PASSWORD);
const res = await client.get(`/api/admin/hunt/${hunt.id}`);
expect(res.status).toBe(404);
});
it('responds with a SuperJSON envelope containing real Dates', async () => {
const { hunt } = await seedAnswer();
const organizer = await createUser({ email: 'organizer@test.dev' });
await createHuntMember(hunt.id, organizer.id);
const start = new Date('2026-09-01T12:00:00Z');
await prisma.hunt.update({
where: { id: hunt.id },
data: { start }
});
const client = newClient();
await client.login('organizer@test.dev', TEST_PASSWORD);
const res = await client.get(`/api/admin/hunt/${hunt.id}`);
expect(res.status).toBe(200);
const raw = await res.text();
const envelope = JSON.parse(raw);
// SuperJSON wraps the payload in { json, meta }
expect(envelope.json).toBeDefined();
expect(envelope.meta).toBeDefined();
const huntDto = superjson.parse<{ start: Date; id: number }>(raw);
expect(huntDto.id).toBe(hunt.id);
expect(huntDto.start).toBeInstanceOf(Date);
expect(huntDto.start.toISOString()).toBe(start.toISOString());
});
});
+11
View File
@@ -0,0 +1,11 @@
import { inject } from 'vitest';
export const baseUrl = inject('baseUrl');
// Must be set before the first import of ~~/lib/prisma, which reads
// DATABASE_URL at import time.
process.env.DATABASE_URL = inject('databaseUrl');
const { default: prisma } = await import('~~/lib/prisma');
export { prisma };
+145
View File
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { newClient } from '../helpers/api';
import { resetDatabase } from '../helpers/database';
import {
createAnswer,
createFile,
createHunt,
createHuntMember,
createQuest,
createReview,
createTeam,
createTeamMember,
createUser,
TEST_PASSWORD
} from '../helpers/factories';
import { prisma } from './setup';
beforeEach(resetDatabase);
/**
* Hunt with two teams and three showcase-worthy answers:
* - a public picture with score 20 (team A)
* - a private picture with score 50 (team A, must be hidden everywhere)
* - a public picture with score 30 (team B)
*/
async function seedShowcase(reveal: boolean) {
const creator = await createUser();
const hunt = await createHunt(creator.id, {
revealAnswers: reveal,
revealQuests: reveal
});
const quest = await createQuest(hunt.id);
const ownerA = await createUser();
const teamA = await createTeam(hunt.id, ownerA.id, { name: 'Team A' });
const memberA = await createUser();
await createTeamMember(teamA.id, memberA.id);
const ownerB = await createUser();
const teamB = await createTeam(hunt.id, ownerB.id, { name: 'Team B' });
const answerA = await createAnswer({
teamId: teamA.id,
questId: quest.id,
memberId: ownerA.id
});
const publicA = await createFile(ownerA.id, { answerId: answerA.id });
await prisma.questAnswer.update({
where: { id: answerA.id },
data: { pictureId: publicA.id }
});
await createReview(answerA.id, creator.id, { score: 20, showcase: true });
const answerPrivate = await createAnswer({
teamId: teamA.id,
questId: quest.id,
memberId: ownerA.id
});
const privateA = await createFile(ownerA.id, {
answerId: answerPrivate.id,
private: true
});
await prisma.questAnswer.update({
where: { id: answerPrivate.id },
data: { pictureId: privateA.id }
});
await createReview(answerPrivate.id, creator.id, {
score: 50,
showcase: true
});
const answerB = await createAnswer({
teamId: teamB.id,
questId: quest.id,
memberId: ownerB.id
});
const publicB = await createFile(ownerB.id, { answerId: answerB.id });
await prisma.questAnswer.update({
where: { id: answerB.id },
data: { pictureId: publicB.id }
});
await createReview(answerB.id, creator.id, { score: 30, showcase: true });
return { creator, hunt, quest, teamA, teamB, answerA, answerB };
}
describe('showcase (GET /api/hunt/[huntId]/showcase)', () => {
it('returns 404 for unknown hunts', async () => {
const res = await newClient().get('/api/hunt/999999/showcase');
expect(res.status).toBe(404);
});
it('blocks anonymous users before answers are revealed', async () => {
const { hunt } = await seedShowcase(false);
const res = await newClient().get(`/api/hunt/${hunt.id}/showcase`);
expect(res.status).toBe(403);
});
it('lets hunt members peek before reveal', async () => {
const { hunt } = await seedShowcase(false);
const organizer = await createUser({ email: 'organizer@test.dev' });
await createHuntMember(hunt.id, organizer.id);
const client = newClient();
await client.login('organizer@test.dev', TEST_PASSWORD);
const res = await client.get(`/api/hunt/${hunt.id}/showcase`);
expect(res.status).toBe(200);
});
it('builds leaderboard, counts and showcase answers once revealed', async () => {
const { hunt, quest, teamA, teamB, answerA, answerB } =
await seedShowcase(true);
const res = await newClient().get(`/api/hunt/${hunt.id}/showcase`);
expect(res.status).toBe(200);
const body = await res.json();
// 2 owners + 1 extra member
expect(body.userCount).toBe(3);
// all three answer pictures are counted, including the private one
expect(body.pictureCount).toBe(3);
// the private picture's score leaks into the leaderboard totals
expect(body.totalScore).toBe(20 + 50 + 30);
expect(body.leaderboard).toEqual([
expect.objectContaining({ name: 'Team A', score: 70, answers: 2 }),
expect.objectContaining({ name: 'Team B', score: 30, answers: 1 })
]);
expect(body.leaderboard[0].id).toBe(teamA.id);
expect(body.leaderboard[1].id).toBe(teamB.id);
// only the quest with showcase answers is included
expect(body.quests).toHaveLength(1);
expect(body.quests[0].id).toBe(quest.id);
// only public showcase answers cross teams, sorted by score ascending
expect(body.quests[0].answers).toHaveLength(2);
expect(body.quests[0].answers.map((a: { id: number }) => a.id)).toEqual([
answerA.id,
answerB.id
]);
for (const answer of body.quests[0].answers) {
expect([teamA.id, teamB.id]).toContain(answer.team.id);
}
});
});
+195
View File
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { newClient } from '../helpers/api';
import { resetDatabase } from '../helpers/database';
import {
createAnswer,
createHunt,
createQuest,
createTeam,
createUser,
TEST_PASSWORD
} from '../helpers/factories';
import { prisma } from './setup';
beforeEach(resetDatabase);
/** Hunt (with revealed quests), a quest and a team owned by the given user. */
async function seedPlayground(ownerEmail: string) {
const creator = await createUser();
const hunt = await createHunt(creator.id, { revealQuests: true });
const quest = await createQuest(hunt.id, { textRequired: true });
const owner = await createUser({ email: ownerEmail });
const team = await createTeam(hunt.id, owner.id);
return { hunt, quest, owner, team };
}
function form(fields: Record<string, string>) {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) {
data.append(key, value);
}
return data;
}
describe('submit answer (POST /api/quest/[questId]/submit)', () => {
it('requires a session', async () => {
const res = await newClient().post(
'/api/quest/1/submit',
form({ answer: 'hello' })
);
expect(res.status).toBe(401);
});
it('returns 404 for unknown quests', async () => {
await createUser({ email: 'owner@test.dev' });
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
const res = await client.post(
'/api/quest/999999/submit',
form({ answer: 'hello' })
);
expect(res.status).toBe(404);
});
it('returns 403 when the user has no team in the hunt', async () => {
const { quest } = await seedPlayground('owner@test.dev');
const loner = await createUser({ email: 'loner@test.dev' });
const client = newClient();
await client.login('loner@test.dev', TEST_PASSWORD);
const res = await client.post(
`/api/quest/${quest.id}/submit`,
form({ answer: 'hello' })
);
expect(res.status).toBe(403);
expect(
await prisma.questAnswer.count({ where: { memberId: loner.id } })
).toBe(0);
});
it('stores a text-only answer', async () => {
const { quest, team, owner } = await seedPlayground('owner@test.dev');
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
const res = await client.post(
`/api/quest/${quest.id}/submit`,
form({ answer: 'the answer', lang: 'en' })
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
const answer = await prisma.questAnswer.findFirst({
where: { teamId: team.id, questId: quest.id }
});
expect(answer).toMatchObject({
text: 'the answer',
lang: 'en',
final: false,
memberId: owner.id
});
});
it('exposes the stored answer with ISO dates via the quest endpoint', async () => {
const { quest } = await seedPlayground('owner@test.dev');
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
await client.post(
`/api/quest/${quest.id}/submit`,
form({ answer: 'the answer' })
);
const res = await client.get(`/api/quest/${quest.id}`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.answer.text).toBe('the answer');
expect(new Date(body.answer.createdAt).getTime()).not.toBeNaN();
expect(new Date(body.answer.updatedAt).getTime()).not.toBeNaN();
});
it('updates an existing answer when updatedAt is current', async () => {
const { quest, team } = await seedPlayground('owner@test.dev');
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
await client.post(
`/api/quest/${quest.id}/submit`,
form({ answer: 'first try' })
);
const existing = await prisma.questAnswer.findFirstOrThrow({
where: { teamId: team.id, questId: quest.id }
});
const res = await client.post(
`/api/quest/${quest.id}/submit`,
form({
answer: 'final try',
updatedAt: existing.updatedAt.toISOString(),
final: 'true'
})
);
expect(res.status).toBe(200);
const updated = await prisma.questAnswer.findFirstOrThrow({
where: { teamId: team.id, questId: quest.id }
});
expect(updated.id).toBe(existing.id);
expect(updated.text).toBe('final try');
expect(updated.final).toBe(true);
});
it('rejects updates based on stale data with 409', async () => {
const { quest } = await seedPlayground('owner@test.dev');
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
await client.post(
`/api/quest/${quest.id}/submit`,
form({ answer: 'first try' })
);
// missing updatedAt although an answer exists
expect(
(
await client.post(
`/api/quest/${quest.id}/submit`,
form({ answer: 'second try' })
)
).status
).toBe(409);
// outdated updatedAt
expect(
(
await client.post(
`/api/quest/${quest.id}/submit`,
form({
answer: 'second try',
updatedAt: new Date(Date.now() - 60_000).toISOString()
})
)
).status
).toBe(409);
});
it('rejects submissions for finalized answers with 418', async () => {
const { quest, team, owner } = await seedPlayground('owner@test.dev');
await createAnswer(
{ teamId: team.id, questId: quest.id, memberId: owner.id },
{ text: 'locked in', final: true }
);
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
const res = await client.post(
`/api/quest/${quest.id}/submit`,
form({
answer: 'too late',
updatedAt: new Date().toISOString()
})
);
expect(res.status).toBe(418);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { newClient } from '../helpers/api';
import { resetDatabase } from '../helpers/database';
import {
createHunt,
createTeam,
createTeamMember,
createUser,
TEST_PASSWORD
} from '../helpers/factories';
import { prisma } from './setup';
beforeEach(resetDatabase);
describe('create team (POST /api/hunt/[huntId]/team)', () => {
it('requires a session', async () => {
const res = await newClient().post('/api/hunt/1/team', {
name: 'The A-Team'
});
expect(res.status).toBe(401);
});
it('creates a team with a generated invite password', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id);
const owner = await createUser({ email: 'owner@test.dev' });
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
const res = await client.post(`/api/hunt/${hunt.id}/team`, {
name: 'The A-Team',
description: 'We love photos'
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.team).toMatchObject({
name: 'The A-Team',
description: 'We love photos',
ownerId: owner.id,
huntId: hunt.id
});
// short hex invite code
expect(body.team.password).toMatch(/^[0-9a-f]{6}$/);
expect(await prisma.huntTeam.count({ where: { huntId: hunt.id } })).toBe(1);
});
it('enforces one team per user per hunt', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id);
await createUser({ email: 'owner@test.dev' });
const client = newClient();
await client.login('owner@test.dev', TEST_PASSWORD);
expect(
(await client.post(`/api/hunt/${hunt.id}/team`, { name: 'First' })).status
).toBe(200);
// as owner of the first team
expect(
(await client.post(`/api/hunt/${hunt.id}/team`, { name: 'Second' }))
.status
).toBe(409);
// as member of an existing team
const team = await createTeam(hunt.id, creator.id);
const member = await createUser({ email: 'member@test.dev' });
await createTeamMember(team.id, member.id);
const memberClient = newClient();
await memberClient.login('member@test.dev', TEST_PASSWORD);
expect(
(await memberClient.post(`/api/hunt/${hunt.id}/team`, { name: 'Third' }))
.status
).toBe(409);
});
it('rejects invalid team names with 400', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id);
const client = newClient();
await client.login(creator.email, TEST_PASSWORD);
expect(
(await client.post(`/api/hunt/${hunt.id}/team`, { name: 'ab' })).status
).toBe(400);
});
});
describe('teams overview (GET /api/hunt/[huntId]/teams)', () => {
it('returns teams with owner name and member count, sorted by name', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id);
const ownerA = await createUser({ name: 'Alice' });
const ownerB = await createUser({ name: 'Bob' });
const teamA = await createTeam(hunt.id, ownerA.id, { name: 'Alpha' });
await createTeam(hunt.id, ownerB.id, { name: 'Zebra' });
const joiner = await createUser();
await createTeamMember(teamA.id, joiner.id);
const res = await newClient().get(`/api/hunt/${hunt.id}/teams`);
expect(res.status).toBe(200);
const teams = await res.json();
expect(teams).toEqual([
{
id: teamA.id,
name: 'Alpha',
description: '',
owner: { name: 'Alice' },
_count: { members: 1 }
},
expect.objectContaining({
name: 'Zebra',
owner: { name: 'Bob' },
_count: { members: 0 }
})
]);
// the invite password must not leak here
expect(JSON.stringify(teams)).not.toContain('password');
});
});
describe('join via team password (POST /api/hunt/[huntId]/join)', () => {
it('lets a second user join an existing team', async () => {
const creator = await createUser();
const hunt = await createHunt(creator.id);
const owner = await createUser();
const team = await createTeam(hunt.id, owner.id, { password: 'feedbe' });
await createUser({ email: 'joiner@test.dev' });
const client = newClient();
await client.login('joiner@test.dev', TEST_PASSWORD);
const res = await client.post(`/api/hunt/${hunt.id}/join`, {
team: team.id,
password: 'feedbe'
});
expect(res.status).toBe(200);
const teamsRes = await client.get(`/api/hunt/${hunt.id}/teams`);
const teams = await teamsRes.json();
expect(teams[0]._count.members).toBe(1);
});
});
+9
View File
@@ -0,0 +1,9 @@
export {};
declare module 'vitest' {
// Values provided by global-setup.ts, consumed via inject() in setup.ts.
export interface ProvidedContext {
baseUrl: string;
databaseUrl: string;
}
}
+33
View File
@@ -0,0 +1,33 @@
{
// Integration tests + helpers. Picked up by `nuxt typecheck` through the
// reference in the root tsconfig.json. Mirrors the options of the
// Nuxt-generated tsconfigs, minus noUncheckedIndexedAccess (tests assert
// on indexed access all the time).
"compilerOptions": {
"paths": {
"~~": [".."],
"~~/*": ["../*"],
"#shared": ["../shared"],
"#shared/*": ["../shared/*"]
},
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ESNext",
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true,
"module": "preserve",
"noEmit": true,
"types": ["node"],
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"noImplicitThis": true,
"allowSyntheticDefaultImports": true
},
"include": ["./**/*.ts", "../vitest.integration.config.ts"],
"exclude": ["../node_modules"]
}