Files
Sauci/apps/api/test/couples.repository.integration.test.ts
T
jjbruntonandClaude Fable 5 9e6e4612f5 fix(db): journal 0021_dare_proof and renumber solo sealed answers migration to 0022
The dare proof migration file landed on staging without a journal entry, so the
drizzle migrator would never apply it. Register it in the journal and move the
solo sealed answers migration to 0022 to keep filenames and journal order aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:52:58 +01:00

134 lines
6.4 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PostgresCoupleRepository } from '../src/domains/couples/repository.js';
const databaseUrl = process.env.DATABASE_URL;
const localDatabase = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
if (databaseUrl && !localDatabase) {
throw new Error('Couples integration tests only permit a localhost DATABASE_URL');
}
describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `couples_test_${randomUUID().replaceAll('-', '')}`;
let pool: Pool;
let repository: PostgresCoupleRepository;
const alice = '11111111-1111-4111-8111-111111111111';
const bob = '22222222-2222-4222-8222-222222222222';
const charlie = '33333333-3333-4333-8333-333333333333';
beforeAll(async () => {
await adminPool.query(`CREATE SCHEMA "${schema}"`);
const isolatedUrl = new URL(databaseUrl!);
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolatedUrl.toString() });
repository = new PostgresCoupleRepository(isolatedUrl.toString());
for (const migrationName of [
'0000_identity_and_feature_interests.sql',
'0001_couples.sql',
'0002_packs_catalog_progress.sql',
'0003_answers_matches.sql',
'0022_solo_sealed_answers.sql',
]) {
const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8');
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
}
await pool.query(
`insert into profiles (id, name) values ($1, 'Alice'), ($2, 'Bob'), ($3, 'Charlie')`,
[alice, bob, charlie],
);
});
afterAll(async () => {
await repository.close();
await pool.end();
await adminPool.query(`DROP SCHEMA "${schema}" CASCADE`);
await adminPool.end();
});
it('creates, joins, reads the partner, enforces capacity, and cancels both members atomically', async () => {
const coupleId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
await repository.create(alice, coupleId, 'ABCD2345');
await repository.join(bob, 'ABCD2345');
await expect(repository.getState(alice)).resolves.toMatchObject({
couple: { id: coupleId, invite_code: 'ABCD2345' },
partner: { id: bob, name: 'Bob', couple_id: coupleId },
});
await expect(repository.join(charlie, 'ABCD2345')).rejects.toMatchObject({ code: 'couple_full' });
await repository.cancel(alice);
await expect(repository.getState(alice)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
});
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const question = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
await pool.query(`insert into profiles(id, name) values ($1, 'Solo')`, [solo]);
await pool.query(`insert into question_packs(id, name) values ($1, 'Pack')`, [pack]);
await pool.query(`insert into questions(id, pack_id, text) values ($1, $2, 'Question')`, [question, pack]);
await pool.query(
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')`,
[randomUUID(), solo, question],
);
await expect(repository.getState(solo)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
});
it('claims both members\' sealed answers and computes matches when the second member joins', async () => {
const coupleId = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
const dana = '44444444-4444-4444-8444-444444444444';
const erin = '55555555-5555-4555-8555-555555555555';
const pack = '66666666-6666-4666-8666-666666666666';
const yesYesQuestion = '77777777-7777-4777-8777-777777777777';
const yesNoQuestion = '88888888-8888-4888-8888-888888888888';
const soloOnlyQuestion = '99999999-9999-4999-8999-999999999999';
await pool.query(`insert into profiles(id, name) values ($1, 'Dana'), ($2, 'Erin')`, [dana, erin]);
await pool.query(`insert into question_packs(id, name) values ($1, 'Claim pack')`, [pack]);
await pool.query(
`insert into questions(id, pack_id, text) values ($1, $2, 'Yes yes'), ($3, $2, 'Yes no'), ($4, $2, 'Solo only')`,
[yesYesQuestion, pack, yesNoQuestion, soloOnlyQuestion],
);
// Both answer the same question before pairing (sealed, couple_id null). Dana
// and Erin agree on one question (yes/yes), disagree on another (yes/no), and
// Dana alone answers a third, which has no match to compute.
const insertResponse = (userId: string, questionId: string, answer: 'yes' | 'no' | 'maybe') =>
pool.query(
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, $4)`,
[randomUUID(), userId, questionId, answer],
);
await insertResponse(dana, yesYesQuestion, 'yes');
await insertResponse(erin, yesYesQuestion, 'yes');
await insertResponse(dana, yesNoQuestion, 'yes');
await insertResponse(erin, yesNoQuestion, 'no');
await insertResponse(dana, soloOnlyQuestion, 'maybe');
await repository.create(dana, coupleId, 'CLAIM123');
await repository.join(erin, 'CLAIM123');
const claimed = await pool.query<{ couple_id: string | null; count: number }>(
'select couple_id, count(*)::int count from responses where user_id in ($1,$2) group by couple_id',
[dana, erin],
);
expect(claimed.rows).toEqual([{ couple_id: coupleId, count: 5 }]);
// Yes/yes produces a match; yes/no produces none; the solo-only answer has no
// partner response yet, so only one match is computed from the claim.
const matches = await pool.query<{ question_id: string; match_type: string }>(
'select question_id, match_type from matches where couple_id=$1 order by question_id',
[coupleId],
);
expect(matches.rows).toEqual([{ question_id: yesYesQuestion, match_type: 'yes_yes' }]);
});
});