test(integration): add real-stack integration test suite with PGlite
- 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user