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
+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);
});
});