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,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);
|
||||
}
|
||||
@@ -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;`
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user