mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(quiz): couples quiz with shareable watermarked results card
Replaces the notify-me quiz stub with a full loop: quiz sessions per couple, answer-and-guess flow, dual-direction scoring, and a score-only share card (no question or answer content) captured via ViewShot with Sauci branding. API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12 seeded questions), new quiz domain with session start / current / answers / result routes, couple isolation, route + integration + scoring unit tests. Mobile: src/features/quiz feature (intro, per-question self+guess flow, waiting-for-partner nudge, results with breakdown), quizStore with sign-out clearing, QuizShareModal following SharePreviewModal, discovery QuizTile, analytics events, and tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30aa7bc386
commit
6523ddeb1c
40 files changed
+2973
-237
No files matched your search
@@ -0,0 +1,65 @@
|
||||
-- Couples quiz: a shareable "how well do you know each other?" loop. Each partner
|
||||
-- answers every question twice, once about themselves and once guessing their
|
||||
-- partner, and the session only scores once both partners have a complete set.
|
||||
CREATE TABLE IF NOT EXISTS "quiz_questions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"prompt_self" text NOT NULL UNIQUE,
|
||||
"prompt_guess" text NOT NULL,
|
||||
"options" text[] NOT NULL CHECK (array_length("options", 1) BETWEEN 2 AND 4),
|
||||
"sort_order" integer NOT NULL DEFAULT 0,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "quiz_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"status" text NOT NULL DEFAULT 'active' CHECK ("status" IN ('active', 'completed')),
|
||||
"question_ids" uuid[] NOT NULL,
|
||||
"score_percent" integer,
|
||||
"completed_at" timestamptz,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "quiz_sessions_one_active_per_couple_idx"
|
||||
ON "quiz_sessions" ("couple_id") WHERE "status" = 'active';
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "quiz_sessions_couple_id_created_at_idx"
|
||||
ON "quiz_sessions" ("couple_id", "created_at" DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "quiz_answers" (
|
||||
"session_id" uuid NOT NULL REFERENCES "quiz_sessions"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL,
|
||||
"question_id" uuid NOT NULL,
|
||||
"self_index" integer NOT NULL CHECK ("self_index" >= 0),
|
||||
"guess_index" integer NOT NULL CHECK ("guess_index" >= 0),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("session_id", "user_id", "question_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
|
||||
('What is your idea of a perfect date night?', 'What is your partner''s idea of a perfect date night?',
|
||||
ARRAY['Cosy night in', 'Fancy dinner out', 'Live music or a show', 'Something active or outdoorsy'], 10),
|
||||
('What comfort food do you crave the most?', 'What comfort food does your partner crave the most?',
|
||||
ARRAY['Pizza', 'Ice cream', 'A home-cooked classic', 'Salty snacks'], 20),
|
||||
('How do you most like to receive love?', 'How does your partner most like to receive love?',
|
||||
ARRAY['Words of affirmation', 'Quality time', 'Physical touch', 'Thoughtful gifts'], 30),
|
||||
('What is your biggest pet peeve at home?', 'What is your partner''s biggest pet peeve at home?',
|
||||
ARRAY['Dishes left in the sink', 'Clothes on the floor', 'Being interrupted', 'Loud noises while resting'], 40),
|
||||
('If you could go anywhere on a dream holiday, where would it be?', 'If your partner could go anywhere on a dream holiday, where would it be?',
|
||||
ARRAY['A beach escape', 'A big city adventure', 'A mountain retreat', 'A road trip somewhere new'], 50),
|
||||
('What movie genre do you reach for most?', 'What movie genre does your partner reach for most?',
|
||||
ARRAY['Comedy', 'Romance', 'Action or thriller', 'Documentary'], 60),
|
||||
('Are you more of a morning person or a night person?', 'Is your partner more of a morning person or a night person?',
|
||||
ARRAY['Morning person', 'Night person', 'A bit of both', 'Neither, honestly'], 70),
|
||||
('What hidden talent are you secretly proud of?', 'What hidden talent is your partner secretly proud of?',
|
||||
ARRAY['Cooking or baking', 'Singing or dancing', 'A sport or game', 'Art or a craft'], 80),
|
||||
('What helps you relieve stress the most?', 'What helps your partner relieve stress the most?',
|
||||
ARRAY['A quiet walk', 'Talking it out', 'Exercise', 'Watching something and switching off'], 90),
|
||||
('If you splurged on one thing, what would it be?', 'If your partner splurged on one thing, what would it be?',
|
||||
ARRAY['New clothes', 'A gadget', 'A trip', 'A nice meal out'], 100),
|
||||
('What is your go-to karaoke song vibe?', 'What is your partner''s go-to karaoke song vibe?',
|
||||
ARRAY['A power ballad', 'An upbeat pop anthem', 'An old classic', 'I would never do karaoke'], 110),
|
||||
('If you had one superpower, which would you pick?', 'If your partner had one superpower, which would they pick?',
|
||||
ARRAY['Flight', 'Invisibility', 'Reading minds', 'Time travel'], 120)
|
||||
ON CONFLICT ("prompt_self") DO NOTHING;
|
||||
@@ -134,6 +134,13 @@
|
||||
"when": 1788081480000,
|
||||
"tag": "0018_cutover_source_parity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1788168000000,
|
||||
"tag": "0019_quiz",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type { ChatRepository } from './domains/chat/repository.js';
|
||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||
import type { DaresRepository } from './domains/dares/repository.js';
|
||||
import { registerDareRoutes } from './domains/dares/routes.js';
|
||||
import type { QuizRepository } from './domains/quiz/repository.js';
|
||||
import { registerQuizRoutes } from './domains/quiz/routes.js';
|
||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||
import type { CoupleService } from './domains/couples/service.js';
|
||||
import type { PacksRepository } from './domains/packs/repository.js';
|
||||
@@ -39,6 +41,7 @@ export interface AppDependencies {
|
||||
answersRepository?: AnswersRepository;
|
||||
chatRepository?: ChatRepository;
|
||||
daresRepository?: DaresRepository;
|
||||
quizRepository?: QuizRepository;
|
||||
profileSettingsRepository?: ProfileSettingsRepository;
|
||||
accountOperationsService?: AccountOperationsService;
|
||||
mediaRepository?: MediaRepository;
|
||||
@@ -126,6 +129,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||
if (deps.quizRepository) registerQuizRoutes(app, deps.quizRepository);
|
||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from '../domains/packs/schema.js';
|
||||
export * from '../domains/answers/schema.js';
|
||||
export * from '../domains/chat/schema.js';
|
||||
export * from '../domains/app-data/schema.js';
|
||||
export * from '../domains/quiz/schema.js';
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { computeQuizScore, type QuizAnswerPair } from './service.js';
|
||||
import {
|
||||
QuizError,
|
||||
type QuizAnswerInput,
|
||||
type QuizQuestionSummary,
|
||||
type QuizResultPayload,
|
||||
type QuizSessionPayload,
|
||||
type QuizSessionStatus,
|
||||
} from './types.js';
|
||||
|
||||
interface QuizQuestionRow extends QueryResultRow {
|
||||
id: string;
|
||||
prompt_self: string;
|
||||
prompt_guess: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface QuizSessionRow extends QueryResultRow {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
status: QuizSessionStatus;
|
||||
question_ids: string[];
|
||||
score_percent: number | null;
|
||||
completed_at: Date | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface QuizAnswerRow extends QueryResultRow {
|
||||
user_id: string;
|
||||
question_id: string;
|
||||
self_index: number;
|
||||
guess_index: number;
|
||||
}
|
||||
|
||||
export interface QuizRepository {
|
||||
startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }>;
|
||||
currentSession(userId: string): Promise<QuizSessionPayload | null>;
|
||||
submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload>;
|
||||
result(userId: string, sessionId: string): Promise<QuizResultPayload>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const QUESTIONS_PER_SESSION = 10;
|
||||
|
||||
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await work(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function orderByIds<T extends { id: string }>(rows: T[], ids: string[]): T[] {
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
return ids.map((id) => byId.get(id)).filter((row): row is T => row !== undefined);
|
||||
}
|
||||
|
||||
export class PostgresQuizRepository implements QuizRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
private async coupleOf(client: Pool | PoolClient, userId: string): Promise<string | null> {
|
||||
const result = await client.query<{ couple_id: string | null }>(
|
||||
'select couple_id from profiles where id = $1',
|
||||
[userId],
|
||||
);
|
||||
return result.rows[0]?.couple_id ?? null;
|
||||
}
|
||||
|
||||
private async partnerOf(client: Pool | PoolClient, coupleId: string, userId: string): Promise<string | null> {
|
||||
const result = await client.query<{ id: string }>(
|
||||
'select id from profiles where couple_id = $1 and id <> $2 order by id asc limit 1',
|
||||
[coupleId, userId],
|
||||
);
|
||||
return result.rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
private async questionsFor(client: Pool | PoolClient, questionIds: string[]): Promise<QuizQuestionRow[]> {
|
||||
const result = await client.query<QuizQuestionRow>(
|
||||
'select id, prompt_self, prompt_guess, options from quiz_questions where id = any($1::uuid[])',
|
||||
[questionIds],
|
||||
);
|
||||
return orderByIds(result.rows, questionIds);
|
||||
}
|
||||
|
||||
private async buildPayload(
|
||||
client: Pool | PoolClient,
|
||||
session: QuizSessionRow,
|
||||
userId: string,
|
||||
): Promise<QuizSessionPayload> {
|
||||
const questions = await this.questionsFor(client, session.question_ids);
|
||||
const [myAnswers, counts] = await Promise.all([
|
||||
client.query<QuizAnswerRow>(
|
||||
'select question_id, self_index, guess_index from quiz_answers where session_id = $1 and user_id = $2',
|
||||
[session.id, userId],
|
||||
),
|
||||
client.query<{ user_id: string; count: string }>(
|
||||
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||
[session.id],
|
||||
),
|
||||
]);
|
||||
|
||||
const coupleId = session.couple_id;
|
||||
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||
const questionCount = session.question_ids.length;
|
||||
|
||||
const answersByQuestion = new Map(myAnswers.rows.map((row) => [row.question_id, row]));
|
||||
const orderedAnswers: QuizAnswerInput[] = session.question_ids
|
||||
.map((questionId) => answersByQuestion.get(questionId))
|
||||
.filter((row): row is QuizAnswerRow => row !== undefined)
|
||||
.map((row) => ({ question_id: row.question_id, self_index: row.self_index, guess_index: row.guess_index }));
|
||||
|
||||
const questionSummaries: QuizQuestionSummary[] = questions.map((row) => ({
|
||||
id: row.id,
|
||||
prompt_self: row.prompt_self,
|
||||
prompt_guess: row.prompt_guess,
|
||||
options: row.options,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
status: session.status,
|
||||
created_at: session.created_at.toISOString(),
|
||||
completed_at: session.completed_at?.toISOString() ?? null,
|
||||
score_percent: session.score_percent,
|
||||
questions: questionSummaries,
|
||||
my_answers: orderedAnswers,
|
||||
partner_completed: partnerId ? (answerCounts.get(partnerId) ?? 0) === questionCount : false,
|
||||
i_completed: (answerCounts.get(userId) ?? 0) === questionCount,
|
||||
};
|
||||
}
|
||||
|
||||
async startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const coupleId = await this.coupleOf(client, userId);
|
||||
if (!coupleId) throw new QuizError('no_couple', 'Join a couple before starting a quiz', 409);
|
||||
|
||||
const members = await client.query<{ count: string }>(
|
||||
'select count(*)::text as count from profiles where couple_id = $1',
|
||||
[coupleId],
|
||||
);
|
||||
if (Number(members.rows[0]?.count ?? 0) < 2) {
|
||||
throw new QuizError('partner_required', 'Both partners must be paired to start a quiz', 409);
|
||||
}
|
||||
|
||||
const existing = await client.query<QuizSessionRow>(
|
||||
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||
[coupleId],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
|
||||
}
|
||||
|
||||
const questions = await client.query<{ id: string }>(
|
||||
'select id from quiz_questions where is_active = true order by sort_order, id limit $1',
|
||||
[QUESTIONS_PER_SESSION],
|
||||
);
|
||||
const questionIds = questions.rows.map((row) => row.id);
|
||||
|
||||
// The partial unique index (one active session per couple) is the source of
|
||||
// truth for the race between two simultaneous starts; ON CONFLICT DO NOTHING
|
||||
// lets the loser fall through to reading the winner's row instead of erroring.
|
||||
const inserted = await client.query<QuizSessionRow>(
|
||||
`insert into quiz_sessions (couple_id, question_ids)
|
||||
values ($1, $2::uuid[])
|
||||
on conflict (couple_id) where status = 'active' do nothing
|
||||
returning *`,
|
||||
[coupleId, questionIds],
|
||||
);
|
||||
|
||||
let sessionRow = inserted.rows[0];
|
||||
let created = true;
|
||||
if (!sessionRow) {
|
||||
created = false;
|
||||
const raced = await client.query<QuizSessionRow>(
|
||||
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||
[coupleId],
|
||||
);
|
||||
sessionRow = raced.rows[0]!;
|
||||
}
|
||||
|
||||
return { session: await this.buildPayload(client, sessionRow, userId), created };
|
||||
});
|
||||
}
|
||||
|
||||
async currentSession(userId: string): Promise<QuizSessionPayload | null> {
|
||||
const coupleId = await this.coupleOf(this.pool, userId);
|
||||
if (!coupleId) return null;
|
||||
const result = await this.pool.query<QuizSessionRow>(
|
||||
'select * from quiz_sessions where couple_id = $1 order by created_at desc limit 1',
|
||||
[coupleId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) return null;
|
||||
return this.buildPayload(this.pool, row, userId);
|
||||
}
|
||||
|
||||
async submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const sessionResult = await client.query<QuizSessionRow>(
|
||||
'select * from quiz_sessions where id = $1 for update',
|
||||
[sessionId],
|
||||
);
|
||||
const session = sessionResult.rows[0];
|
||||
const coupleId = await this.coupleOf(client, userId);
|
||||
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||
}
|
||||
if (session.status !== 'active') {
|
||||
throw new QuizError('session_completed', 'This quiz session has already finished', 409);
|
||||
}
|
||||
|
||||
const questionIds = new Set(session.question_ids);
|
||||
const answeredIds = new Set(answers.map((answer) => answer.question_id));
|
||||
if (answeredIds.size !== answers.length || answeredIds.size !== questionIds.size) {
|
||||
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||
}
|
||||
for (const id of answeredIds) {
|
||||
if (!questionIds.has(id)) {
|
||||
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const questions = await this.questionsFor(client, session.question_ids);
|
||||
const optionsByQuestion = new Map(questions.map((question) => [question.id, question.options]));
|
||||
for (const answer of answers) {
|
||||
const options = optionsByQuestion.get(answer.question_id);
|
||||
if (!options) {
|
||||
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||
}
|
||||
if (
|
||||
answer.self_index < 0 || answer.self_index >= options.length ||
|
||||
answer.guess_index < 0 || answer.guess_index >= options.length
|
||||
) {
|
||||
throw new QuizError('invalid_answers', 'Answer indexes must be within the question options', 400);
|
||||
}
|
||||
}
|
||||
|
||||
for (const answer of answers) {
|
||||
await client.query(
|
||||
`insert into quiz_answers (session_id, user_id, question_id, self_index, guess_index)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (session_id, user_id, question_id)
|
||||
do update set self_index = excluded.self_index, guess_index = excluded.guess_index`,
|
||||
[sessionId, userId, answer.question_id, answer.self_index, answer.guess_index],
|
||||
);
|
||||
}
|
||||
|
||||
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||
let updatedSession = session;
|
||||
if (partnerId) {
|
||||
const counts = await client.query<{ user_id: string; count: string }>(
|
||||
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||
[sessionId],
|
||||
);
|
||||
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||
const questionCount = session.question_ids.length;
|
||||
const bothComplete =
|
||||
(answerCounts.get(userId) ?? 0) === questionCount && (answerCounts.get(partnerId) ?? 0) === questionCount;
|
||||
|
||||
if (bothComplete) {
|
||||
const allAnswers = await client.query<QuizAnswerRow>(
|
||||
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||
[sessionId],
|
||||
);
|
||||
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||
for (const row of allAnswers.rows) {
|
||||
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||
}
|
||||
const mine = byUser.get(userId)!;
|
||||
const theirs = byUser.get(partnerId)!;
|
||||
const pairs: QuizAnswerPair[] = session.question_ids.map((questionId) => ({
|
||||
questionId,
|
||||
aSelfIndex: mine.get(questionId)!.self_index,
|
||||
aGuessIndex: mine.get(questionId)!.guess_index,
|
||||
bSelfIndex: theirs.get(questionId)!.self_index,
|
||||
bGuessIndex: theirs.get(questionId)!.guess_index,
|
||||
}));
|
||||
const { scorePercent } = computeQuizScore(pairs);
|
||||
|
||||
const completed = await client.query<QuizSessionRow>(
|
||||
`update quiz_sessions set status = 'completed', score_percent = $2, completed_at = now()
|
||||
where id = $1
|
||||
returning *`,
|
||||
[sessionId, scorePercent],
|
||||
);
|
||||
updatedSession = completed.rows[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildPayload(client, updatedSession, userId);
|
||||
});
|
||||
}
|
||||
|
||||
async result(userId: string, sessionId: string): Promise<QuizResultPayload> {
|
||||
const sessionResult = await this.pool.query<QuizSessionRow>(
|
||||
'select * from quiz_sessions where id = $1',
|
||||
[sessionId],
|
||||
);
|
||||
const session = sessionResult.rows[0];
|
||||
const coupleId = await this.coupleOf(this.pool, userId);
|
||||
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||
}
|
||||
if (session.status !== 'completed') {
|
||||
throw new QuizError('session_not_completed', 'This quiz session has not finished yet', 409);
|
||||
}
|
||||
|
||||
const partnerId = await this.partnerOf(this.pool, coupleId, userId);
|
||||
if (!partnerId) throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||
|
||||
const [questions, allAnswers] = await Promise.all([
|
||||
this.questionsFor(this.pool, session.question_ids),
|
||||
this.pool.query<QuizAnswerRow>(
|
||||
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||
[sessionId],
|
||||
),
|
||||
]);
|
||||
|
||||
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||
for (const row of allAnswers.rows) {
|
||||
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||
}
|
||||
const mine = byUser.get(userId) ?? new Map<string, QuizAnswerRow>();
|
||||
const theirs = byUser.get(partnerId) ?? new Map<string, QuizAnswerRow>();
|
||||
|
||||
const questionsPayload = questions.map((question) => {
|
||||
const my = mine.get(question.id);
|
||||
const partner = theirs.get(question.id);
|
||||
const mySelf = my?.self_index ?? 0;
|
||||
const myGuess = my?.guess_index ?? 0;
|
||||
const partnerSelf = partner?.self_index ?? 0;
|
||||
const partnerGuess = partner?.guess_index ?? 0;
|
||||
return {
|
||||
question_id: question.id,
|
||||
prompt_self: question.prompt_self,
|
||||
prompt_guess: question.prompt_guess,
|
||||
options: question.options,
|
||||
my_self_index: mySelf,
|
||||
my_guess_index: myGuess,
|
||||
partner_self_index: partnerSelf,
|
||||
partner_guess_index: partnerGuess,
|
||||
i_guessed_right: myGuess === partnerSelf,
|
||||
partner_guessed_right: partnerGuess === mySelf,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
score_percent: session.score_percent ?? 0,
|
||||
completed_at: session.completed_at?.toISOString() ?? null,
|
||||
questions: questionsPayload,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { QuizError, type QuizAnswerInput } from './types.js';
|
||||
import type { QuizRepository } from './repository.js';
|
||||
|
||||
type QuizApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
const id = z.string().uuid();
|
||||
|
||||
const answerEntry = z
|
||||
.object({
|
||||
question_id: id,
|
||||
self_index: z.number().int().min(0),
|
||||
guess_index: z.number().int().min(0),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const submitBody = z.object({ answers: z.array(answerEntry).min(1) }).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function result<T>(operation: () => Promise<T>) {
|
||||
try {
|
||||
return { ok: true as const, value: await operation() };
|
||||
} catch (cause) {
|
||||
if (cause instanceof QuizError) return { ok: false as const, cause };
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerQuizRoutes(app: QuizApp, repository: QuizRepository): void {
|
||||
app.post('/v1/quiz/sessions', async (c) => {
|
||||
const response = await result(() => repository.startSession(c.get('identity').id));
|
||||
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
return c.json({ session: response.value.session }, response.value.created ? 201 : 200);
|
||||
});
|
||||
|
||||
app.get('/v1/quiz/sessions/current', async (c) => {
|
||||
const response = await result(() => repository.currentSession(c.get('identity').id));
|
||||
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
return c.json({ session: response.value });
|
||||
});
|
||||
|
||||
app.post('/v1/quiz/sessions/:sessionId/answers', async (c) => {
|
||||
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!body.success) return c.json(error('invalid_answers', 'Provide the full set of question answers'), 400);
|
||||
const answers: QuizAnswerInput[] = body.data.answers;
|
||||
const response = await result(() => repository.submitAnswers(c.get('identity').id, sessionId.data, answers));
|
||||
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
return c.json({ session: response.value });
|
||||
});
|
||||
|
||||
app.get('/v1/quiz/sessions/:sessionId/result', async (c) => {
|
||||
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||
const response = await result(() => repository.result(c.get('identity').id, sessionId.data));
|
||||
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
return c.json(response.value);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { couples } from '../couples/schema.js';
|
||||
|
||||
/**
|
||||
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
|
||||
* same question so a session can ask each partner about themselves and about the
|
||||
* other without a second row.
|
||||
*/
|
||||
export const quizQuestions = pgTable('quiz_questions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
promptSelf: text('prompt_self').notNull().unique(),
|
||||
promptGuess: text('prompt_guess').notNull(),
|
||||
options: text('options').array().notNull(),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
|
||||
]);
|
||||
|
||||
export const quizSessions = pgTable('quiz_sessions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull().default('active'),
|
||||
questionIds: uuid('question_ids').array().notNull(),
|
||||
scorePercent: integer('score_percent'),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
|
||||
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
|
||||
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
|
||||
]);
|
||||
|
||||
export const quizAnswers = pgTable('quiz_answers', {
|
||||
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
|
||||
userId: uuid('user_id').notNull(),
|
||||
questionId: uuid('question_id').notNull(),
|
||||
selfIndex: integer('self_index').notNull(),
|
||||
guessIndex: integer('guess_index').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
|
||||
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
|
||||
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
|
||||
]);
|
||||
|
||||
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
|
||||
export type QuizSessionRow = typeof quizSessions.$inferSelect;
|
||||
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* One partner's guess of the other's self-answer, for a single question. Scoring
|
||||
* always runs both directions: A guessing B and B guessing A each count separately,
|
||||
* so a couple who only ever gets half the guesses right lands at 50%, not 100%.
|
||||
*/
|
||||
export interface QuizAnswerPair {
|
||||
questionId: string;
|
||||
aSelfIndex: number;
|
||||
aGuessIndex: number;
|
||||
bSelfIndex: number;
|
||||
bGuessIndex: number;
|
||||
}
|
||||
|
||||
export interface QuizScoreQuestionResult extends QuizAnswerPair {
|
||||
aGuessedRight: boolean;
|
||||
bGuessedRight: boolean;
|
||||
}
|
||||
|
||||
export interface QuizScoreResult {
|
||||
scorePercent: number;
|
||||
perQuestion: QuizScoreQuestionResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure scoring function for a completed quiz session. Each question contributes
|
||||
* up to two hits (partner A's guess about B, and B's guess about A), so the
|
||||
* denominator is twice the question count.
|
||||
*/
|
||||
export function computeQuizScore(pairs: QuizAnswerPair[]): QuizScoreResult {
|
||||
const perQuestion = pairs.map((pair) => ({
|
||||
...pair,
|
||||
aGuessedRight: pair.aGuessIndex === pair.bSelfIndex,
|
||||
bGuessedRight: pair.bGuessIndex === pair.aSelfIndex,
|
||||
}));
|
||||
const hits = perQuestion.reduce(
|
||||
(sum, question) => sum + (question.aGuessedRight ? 1 : 0) + (question.bGuessedRight ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const scorePercent = pairs.length === 0 ? 0 : Math.round((100 * hits) / (2 * pairs.length));
|
||||
return { scorePercent, perQuestion };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export type QuizSessionStatus = 'active' | 'completed';
|
||||
|
||||
export type QuizErrorCode =
|
||||
| 'no_couple'
|
||||
| 'partner_required'
|
||||
| 'session_not_found'
|
||||
| 'session_completed'
|
||||
| 'invalid_answers'
|
||||
| 'session_not_completed';
|
||||
|
||||
export class QuizError extends Error {
|
||||
constructor(
|
||||
readonly code: QuizErrorCode,
|
||||
message: string,
|
||||
readonly status: 400 | 404 | 409,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QuizError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuizAnswerInput {
|
||||
question_id: string;
|
||||
self_index: number;
|
||||
guess_index: number;
|
||||
}
|
||||
|
||||
export interface QuizQuestionSummary {
|
||||
id: string;
|
||||
prompt_self: string;
|
||||
prompt_guess: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface QuizSessionPayload {
|
||||
id: string;
|
||||
status: QuizSessionStatus;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
score_percent: number | null;
|
||||
questions: QuizQuestionSummary[];
|
||||
my_answers: QuizAnswerInput[];
|
||||
partner_completed: boolean;
|
||||
i_completed: boolean;
|
||||
}
|
||||
|
||||
export interface QuizResultQuestion {
|
||||
question_id: string;
|
||||
prompt_self: string;
|
||||
prompt_guess: string;
|
||||
options: string[];
|
||||
my_self_index: number;
|
||||
my_guess_index: number;
|
||||
partner_self_index: number;
|
||||
partner_guess_index: number;
|
||||
i_guessed_right: boolean;
|
||||
partner_guessed_right: boolean;
|
||||
}
|
||||
|
||||
export interface QuizResultPayload {
|
||||
score_percent: number;
|
||||
completed_at: string | null;
|
||||
questions: QuizResultQuestion[];
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||
import { PostgresQuizRepository } from './domains/quiz/repository.js';
|
||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||
import { CoupleService } from './domains/couples/service.js';
|
||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||
@@ -38,6 +39,7 @@ const packsRepository = new PostgresPacksRepository(pool);
|
||||
const answersRepository = new PostgresAnswersRepository(pool);
|
||||
const chatRepository = new PostgresChatRepository(pool);
|
||||
const daresRepository = new PostgresDaresRepository(pool);
|
||||
const quizRepository = new PostgresQuizRepository(pool);
|
||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
||||
const accountOperationsService = new AccountOperationsService(
|
||||
@@ -68,6 +70,7 @@ const app = createApp({
|
||||
answersRepository,
|
||||
chatRepository,
|
||||
daresRepository,
|
||||
quizRepository,
|
||||
profileSettingsRepository,
|
||||
accountOperationsService,
|
||||
mediaRepository,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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';
|
||||
|
||||
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)`,
|
||||
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple],
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthIdentity } from '../src/auth.js';
|
||||
import type { QuizRepository } from '../src/domains/quiz/repository.js';
|
||||
import { registerQuizRoutes } from '../src/domains/quiz/routes.js';
|
||||
import { QuizError, type QuizSessionPayload } from '../src/domains/quiz/types.js';
|
||||
|
||||
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
|
||||
const sessionId = '22222222-2222-4222-8222-222222222222';
|
||||
const questionId = '33333333-3333-4333-8333-333333333333';
|
||||
|
||||
const session: QuizSessionPayload = {
|
||||
id: sessionId,
|
||||
status: 'active',
|
||||
created_at: '2026-08-27T00:00:00.000Z',
|
||||
completed_at: null,
|
||||
score_percent: null,
|
||||
questions: [
|
||||
{ id: questionId, prompt_self: 'Self prompt', prompt_guess: 'Guess prompt', options: ['A', 'B'] },
|
||||
],
|
||||
my_answers: [],
|
||||
partner_completed: false,
|
||||
i_completed: false,
|
||||
};
|
||||
|
||||
function setup(overrides: Partial<QuizRepository> = {}) {
|
||||
const repository: QuizRepository = {
|
||||
startSession: vi.fn(async () => ({ session, created: true })),
|
||||
currentSession: vi.fn(async () => session),
|
||||
submitAnswers: vi.fn(async () => session),
|
||||
result: vi.fn(async () => ({
|
||||
score_percent: 100,
|
||||
completed_at: '2026-08-27T00:00:00.000Z',
|
||||
questions: [],
|
||||
})),
|
||||
close: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||
registerQuizRoutes(app, repository);
|
||||
return { app, repository };
|
||||
}
|
||||
|
||||
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
|
||||
return app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('quiz routes', () => {
|
||||
it('starts a session and returns 201 when a new one is created', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await post(app, '/v1/quiz/sessions');
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.json()).toEqual({ session });
|
||||
expect(repository.startSession).toHaveBeenCalledWith(identity.id);
|
||||
});
|
||||
|
||||
it('returns 200 when starting resolves to an existing active session', async () => {
|
||||
const { app } = setup({ startSession: vi.fn(async () => ({ session, created: false })) });
|
||||
const response = await post(app, '/v1/quiz/sessions');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('maps no_couple and partner_required to 409', async () => {
|
||||
const { app } = setup({
|
||||
startSession: vi.fn(async () => { throw new QuizError('no_couple', 'Join a couple first', 409); }),
|
||||
});
|
||||
const response = await post(app, '/v1/quiz/sessions');
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toMatchObject({ error: { code: 'no_couple' } });
|
||||
});
|
||||
|
||||
it('returns the current session, including null when none exists', async () => {
|
||||
const { app } = setup({ currentSession: vi.fn(async () => null) });
|
||||
const response = await app.request('/v1/quiz/sessions/current');
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ session: null });
|
||||
});
|
||||
|
||||
it('rejects a malformed session id on the answers route', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await post(app, '/v1/quiz/sessions/not-a-uuid/answers', {
|
||||
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(repository.submitAnswers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an empty or malformed answer body', async () => {
|
||||
const { app, repository } = setup();
|
||||
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers: [] })).status).toBe(400);
|
||||
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {})).status).toBe(400);
|
||||
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
|
||||
answers: [{ question_id: questionId, self_index: 0, guess_index: 1, extra: true }],
|
||||
})).status).toBe(400);
|
||||
expect(repository.submitAnswers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits valid answers and surfaces the updated session', async () => {
|
||||
const { app, repository } = setup();
|
||||
const answers = [{ question_id: questionId, self_index: 0, guess_index: 1 }];
|
||||
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers });
|
||||
expect(response.status).toBe(200);
|
||||
expect(repository.submitAnswers).toHaveBeenCalledWith(identity.id, sessionId, answers);
|
||||
});
|
||||
|
||||
it('maps session errors to their status codes', async () => {
|
||||
const { app } = setup({
|
||||
submitAnswers: vi.fn(async () => { throw new QuizError('session_completed', 'Already finished', 409); }),
|
||||
});
|
||||
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
|
||||
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toMatchObject({ error: { code: 'session_completed' } });
|
||||
});
|
||||
|
||||
it('returns the result payload for a completed session', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ score_percent: 100 });
|
||||
expect(repository.result).toHaveBeenCalledWith(identity.id, sessionId);
|
||||
});
|
||||
|
||||
it('maps session_not_completed to 409 on the result route', async () => {
|
||||
const { app } = setup({
|
||||
result: vi.fn(async () => { throw new QuizError('session_not_completed', 'Not finished yet', 409); }),
|
||||
});
|
||||
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
|
||||
it('rejects a malformed session id on the result route', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await app.request('/v1/quiz/sessions/not-a-uuid/result');
|
||||
expect(response.status).toBe(404);
|
||||
expect(repository.result).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeQuizScore, type QuizAnswerPair } from '../src/domains/quiz/service.js';
|
||||
|
||||
function pair(overrides: Partial<QuizAnswerPair> = {}): QuizAnswerPair {
|
||||
return {
|
||||
questionId: 'q1',
|
||||
aSelfIndex: 0,
|
||||
aGuessIndex: 0,
|
||||
bSelfIndex: 0,
|
||||
bGuessIndex: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeQuizScore', () => {
|
||||
it('scores 100% when every guess matches the partner self-answer in both directions', () => {
|
||||
const pairs: QuizAnswerPair[] = [
|
||||
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 1 }),
|
||||
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 3, bGuessIndex: 0 }),
|
||||
];
|
||||
const { scorePercent } = computeQuizScore(pairs);
|
||||
expect(scorePercent).toBe(100);
|
||||
});
|
||||
|
||||
it('scores 0% when nobody guesses their partner correctly', () => {
|
||||
const pairs: QuizAnswerPair[] = [
|
||||
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 2, bGuessIndex: 3 }),
|
||||
];
|
||||
expect(computeQuizScore(pairs).scorePercent).toBe(0);
|
||||
});
|
||||
|
||||
it('counts each direction independently, so a half-right couple lands at 50%', () => {
|
||||
// A guesses B correctly, but B guesses A incorrectly: one hit out of two possible.
|
||||
const pairs: QuizAnswerPair[] = [
|
||||
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 0 }),
|
||||
];
|
||||
const result = computeQuizScore(pairs);
|
||||
expect(result.scorePercent).toBe(50);
|
||||
expect(result.perQuestion[0]).toMatchObject({ aGuessedRight: true, bGuessedRight: false });
|
||||
});
|
||||
|
||||
it('rounds to the nearest whole percent', () => {
|
||||
// 1 hit out of 6 possible (3 questions * 2 directions) = 16.67%, rounds to 17.
|
||||
const pairs: QuizAnswerPair[] = [
|
||||
// aGuess (0) matches bSelf (0): one hit. bGuess (3) does not match aSelf (1).
|
||||
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||
pair({ questionId: 'q3', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||
];
|
||||
expect(computeQuizScore(pairs).scorePercent).toBe(17);
|
||||
});
|
||||
|
||||
it('returns 0% for an empty question set instead of dividing by zero', () => {
|
||||
expect(computeQuizScore([]).scorePercent).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user