mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Expand the seeded pool from 12 to 36 questions (migration 0020) and select 10 per session preferring questions the couple has never seen, then least recently used, with random tiebreaks, so repeat quizzes stop serving the same set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import { Pool } from 'pg';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { PostgresQuizRepository } from '../src/domains/quiz/repository.js';
|
|
|
|
const url = process.env.DATABASE_URL;
|
|
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
|
|
if (url && !local) throw new Error('Quiz integration tests only permit localhost');
|
|
|
|
describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
|
|
const admin = new Pool({ connectionString: url });
|
|
const schema = `quiz_${randomUUID().replaceAll('-', '')}`;
|
|
let pool: Pool;
|
|
let repo: PostgresQuizRepository;
|
|
|
|
const userA = '11111111-1111-4111-8111-111111111111';
|
|
const userB = '22222222-2222-4222-8222-222222222222';
|
|
const soloUser = '33333333-3333-4333-8333-333333333333';
|
|
const outsider = '44444444-4444-4444-8444-444444444444';
|
|
const couple = '55555555-5555-4555-8555-555555555555';
|
|
const soloCouple = '66666666-6666-4666-8666-666666666666';
|
|
const otherCouple = '77777777-7777-4777-8777-777777777777';
|
|
const userC = '88888888-8888-4888-8888-888888888888';
|
|
|
|
beforeAll(async () => {
|
|
await admin.query(`create schema "${schema}"`);
|
|
const isolated = new URL(url!);
|
|
isolated.searchParams.set('options', `-c search_path=${schema}`);
|
|
pool = new Pool({ connectionString: isolated.toString() });
|
|
repo = new PostgresQuizRepository(isolated.toString());
|
|
|
|
const dir = new URL('../drizzle/', import.meta.url);
|
|
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
|
|
for (const name of files) {
|
|
const sql = await readFile(new URL(name, dir), 'utf8');
|
|
for (const statement of sql.split('--> statement-breakpoint')) {
|
|
if (statement.trim()) await pool.query(statement);
|
|
}
|
|
}
|
|
|
|
await pool.query(
|
|
'insert into couples(id,invite_code) values($1,$2),($3,$4),($5,$6)',
|
|
[couple, 'QUIZAAA1', soloCouple, 'QUIZAAA2', otherCouple, 'QUIZAAA3'],
|
|
);
|
|
await pool.query(
|
|
`insert into profiles(id,couple_id) values
|
|
($1,$5),($2,$5),($3,$6),($4,$7),($8,$7)`,
|
|
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC],
|
|
);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await pool.query('delete from quiz_answers');
|
|
await pool.query('delete from quiz_sessions');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await repo.close();
|
|
await pool.end();
|
|
await admin.query(`drop schema "${schema}" cascade`);
|
|
await admin.end();
|
|
});
|
|
|
|
it('refuses to start a quiz without a couple or without a partner', async () => {
|
|
await expect(repo.startSession(randomUUID())).rejects.toMatchObject({ code: 'no_couple' });
|
|
await expect(repo.startSession(soloUser)).rejects.toMatchObject({ code: 'partner_required' });
|
|
});
|
|
|
|
it('starts a session with ten active questions and returns it idempotently', async () => {
|
|
const first = await repo.startSession(userA);
|
|
expect(first.created).toBe(true);
|
|
expect(first.session.status).toBe('active');
|
|
expect(first.session.questions).toHaveLength(10);
|
|
expect(first.session.score_percent).toBeNull();
|
|
expect(first.session.my_answers).toEqual([]);
|
|
expect(first.session.i_completed).toBe(false);
|
|
expect(first.session.partner_completed).toBe(false);
|
|
|
|
const second = await repo.startSession(userA);
|
|
expect(second.created).toBe(false);
|
|
expect(second.session.id).toBe(first.session.id);
|
|
|
|
// The partner starting also resolves to the same active session.
|
|
const partnerView = await repo.startSession(userB);
|
|
expect(partnerView.created).toBe(false);
|
|
expect(partnerView.session.id).toBe(first.session.id);
|
|
});
|
|
|
|
it('walks the full happy path: both partners answer, session completes, score is exact', async () => {
|
|
const { session } = await repo.startSession(userA);
|
|
const questionIds = session.questions.map((question) => question.id);
|
|
expect(questionIds).toHaveLength(10);
|
|
|
|
// Self-answers alternate 0/1 per question so a guess cannot accidentally match
|
|
// by coincidence. A guesses B's self-answer correctly on the first 6 questions
|
|
// and wrong on the rest; B guesses A's self-answer correctly on the first 3
|
|
// questions and wrong on the rest.
|
|
const bSelf = questionIds.map((_, index) => index % 2);
|
|
const aSelf = questionIds.map((_, index) => 1 - (index % 2));
|
|
const aGuess = bSelf.map((value, index) => (index < 6 ? value : 1 - value));
|
|
const bGuess = aSelf.map((value, index) => (index < 3 ? value : 1 - value));
|
|
|
|
const aAnswers = questionIds.map((questionId, index) => ({
|
|
question_id: questionId,
|
|
self_index: aSelf[index]!,
|
|
guess_index: aGuess[index]!,
|
|
}));
|
|
const bAnswers = questionIds.map((questionId, index) => ({
|
|
question_id: questionId,
|
|
self_index: bSelf[index]!,
|
|
guess_index: bGuess[index]!,
|
|
}));
|
|
|
|
const afterA = await repo.submitAnswers(userA, session.id, aAnswers);
|
|
expect(afterA.status).toBe('active');
|
|
expect(afterA.i_completed).toBe(true);
|
|
expect(afterA.partner_completed).toBe(false);
|
|
expect(afterA.my_answers).toHaveLength(10);
|
|
|
|
const afterB = await repo.submitAnswers(userB, session.id, bAnswers);
|
|
expect(afterB.status).toBe('completed');
|
|
expect(afterB.completed_at).not.toBeNull();
|
|
expect(afterB.i_completed).toBe(true);
|
|
expect(afterB.partner_completed).toBe(true);
|
|
|
|
// A guessed right (guess=1 matches B's self=1) on questions 0-5: 6 hits.
|
|
// B guessed right (guess=0 matches A's self=0) on questions 0-2: 3 hits.
|
|
// Total 9 hits out of 20 possible = 45%.
|
|
expect(afterB.score_percent).toBe(45);
|
|
|
|
const result = await repo.result(userA, session.id);
|
|
expect(result.score_percent).toBe(45);
|
|
expect(result.completed_at).not.toBeNull();
|
|
expect(result.questions).toHaveLength(10);
|
|
expect(result.questions[0]).toMatchObject({
|
|
my_self_index: aSelf[0],
|
|
my_guess_index: aGuess[0],
|
|
partner_self_index: bSelf[0],
|
|
partner_guess_index: bGuess[0],
|
|
i_guessed_right: true,
|
|
partner_guessed_right: true,
|
|
});
|
|
expect(result.questions[9]).toMatchObject({
|
|
i_guessed_right: false,
|
|
partner_guessed_right: false,
|
|
});
|
|
|
|
// The partner's result view mirrors the same score with roles swapped.
|
|
const partnerResult = await repo.result(userB, session.id);
|
|
expect(partnerResult.score_percent).toBe(45);
|
|
});
|
|
|
|
it('rejects an incomplete or mismatched answer set', async () => {
|
|
const { session } = await repo.startSession(userA);
|
|
const questionIds = session.questions.map((question) => question.id);
|
|
|
|
await expect(
|
|
repo.submitAnswers(userA, session.id, [
|
|
{ question_id: questionIds[0]!, self_index: 0, guess_index: 0 },
|
|
]),
|
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
|
|
|
await expect(
|
|
repo.submitAnswers(userA, session.id, [
|
|
...questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })),
|
|
{ question_id: randomUUID(), self_index: 0, guess_index: 0 },
|
|
]),
|
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
|
|
|
const options = session.questions[0]!.options;
|
|
await expect(
|
|
repo.submitAnswers(userA, session.id, questionIds.map((id) => ({
|
|
question_id: id,
|
|
self_index: options.length,
|
|
guess_index: 0,
|
|
}))),
|
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
|
});
|
|
|
|
it('isolates sessions to their own couple', async () => {
|
|
const { session } = await repo.startSession(userA);
|
|
await expect(repo.submitAnswers(outsider, session.id, [])).rejects.toMatchObject({ code: 'session_not_found' });
|
|
await expect(repo.result(outsider, session.id)).rejects.toMatchObject({ code: 'session_not_found' });
|
|
expect(await repo.currentSession(outsider)).toBeNull();
|
|
});
|
|
|
|
it('blocks the answers endpoint once the session has completed', async () => {
|
|
const { session } = await repo.startSession(userA);
|
|
const questionIds = session.questions.map((question) => question.id);
|
|
const answers = questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
|
await repo.submitAnswers(userA, session.id, answers);
|
|
const completed = await repo.submitAnswers(userB, session.id, answers);
|
|
expect(completed.status).toBe('completed');
|
|
|
|
await expect(repo.submitAnswers(userA, session.id, answers)).rejects.toMatchObject({ code: 'session_completed' });
|
|
});
|
|
|
|
it('blocks the result endpoint before the session has completed', async () => {
|
|
const { session } = await repo.startSession(userA);
|
|
await expect(repo.result(userA, session.id)).rejects.toMatchObject({ code: 'session_not_completed' });
|
|
});
|
|
|
|
it('returns the couple\'s most recent session from currentSession', async () => {
|
|
expect(await repo.currentSession(userA)).toBeNull();
|
|
const { session } = await repo.startSession(userA);
|
|
const current = await repo.currentSession(userB);
|
|
expect(current?.id).toBe(session.id);
|
|
});
|
|
|
|
it('draws the next session from never-used questions once a session completes', async () => {
|
|
const { session: first } = await repo.startSession(userA);
|
|
const firstQuestionIds = first.questions.map((question) => question.id);
|
|
expect(firstQuestionIds).toHaveLength(10);
|
|
|
|
const answers = firstQuestionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
|
await repo.submitAnswers(userA, first.id, answers);
|
|
const completed = await repo.submitAnswers(userB, first.id, answers);
|
|
expect(completed.status).toBe('completed');
|
|
|
|
const { session: second, created } = await repo.startSession(userA);
|
|
expect(created).toBe(true);
|
|
const secondQuestionIds = second.questions.map((question) => question.id);
|
|
expect(secondQuestionIds).toHaveLength(10);
|
|
|
|
// The seeded pool has 36 active questions, so the never-used-first preference
|
|
// guarantees the second session's questions are wholly disjoint from the first.
|
|
const overlap = secondQuestionIds.filter((id) => firstQuestionIds.includes(id));
|
|
expect(overlap).toEqual([]);
|
|
});
|
|
|
|
it('scopes selection to each couple independently', async () => {
|
|
const { session: coupleOneFirst } = await repo.startSession(userA);
|
|
const coupleOneFirstIds = coupleOneFirst.questions.map((question) => question.id);
|
|
const answers = coupleOneFirstIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
|
await repo.submitAnswers(userA, coupleOneFirst.id, answers);
|
|
await repo.submitAnswers(userB, coupleOneFirst.id, answers);
|
|
|
|
// A different couple with no prior sessions should still be able to draw a
|
|
// fresh set of 10 questions regardless of what the first couple has used,
|
|
// since the never-used-for-them preference is scoped per couple.
|
|
const { session: otherSession, created } = await repo.startSession(outsider);
|
|
expect(created).toBe(true);
|
|
expect(otherSession.questions).toHaveLength(10);
|
|
|
|
const partnerView = await repo.startSession(userC);
|
|
expect(partnerView.created).toBe(false);
|
|
expect(partnerView.session.id).toBe(otherSession.id);
|
|
});
|
|
});
|