feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import { HttpRevenueCatClient, SupabaseAuthAdminClient } from '../src/domains/account-operations/clients.js';
describe('account operation provider clients', () => {
it('fails closed before account deletion when Auth Admin credentials are absent', async () => {
const request = vi.fn();
const client = new SupabaseAuthAdminClient(undefined, undefined, request);
await expect(client.deleteUser('11111111-1111-4111-8111-111111111111'))
.rejects.toMatchObject({ code: 'account_deletion_unavailable', status: 503 });
expect(request).not.toHaveBeenCalled();
});
it('uses only the server-side service role against the hosted Auth admin endpoint', async () => {
const request = vi.fn(async () => new Response(null, { status: 204 }));
const client = new SupabaseAuthAdminClient('https://auth.example.test', 'server-secret', request);
await client.deleteUser('11111111-1111-4111-8111-111111111111');
expect(request).toHaveBeenCalledWith(
'https://auth.example.test/auth/v1/admin/users/11111111-1111-4111-8111-111111111111',
expect.objectContaining({
method: 'DELETE',
headers: { apikey: 'server-secret', authorization: 'Bearer server-secret' },
}),
);
});
it.each([
[{}, false],
[{ expires_date: '2026-08-26T12:00:00.000Z' }, false],
[{ expires_date: '2026-08-28T12:00:00.000Z' }, true],
[{ expires_date: null }, true],
])('derives premium from the configured RevenueCat entitlement', async (entitlement, expected) => {
const request = vi.fn(async () => Response.json({
subscriber: { entitlements: Object.keys(entitlement).length ? { pro: entitlement } : {} },
}));
const client = new HttpRevenueCatClient(
'rc-secret',
'pro',
request,
() => new Date('2026-08-27T12:00:00.000Z'),
);
await expect(client.isEntitled('user/id')).resolves.toBe(expected);
expect(request).toHaveBeenCalledWith(
'https://api.revenuecat.com/v1/subscribers/user%2Fid',
expect.objectContaining({ headers: expect.objectContaining({ authorization: 'Bearer rc-secret' }) }),
);
});
it('does not leak provider response content when RevenueCat rejects verification', async () => {
const client = new HttpRevenueCatClient('rc-secret', 'pro', async () =>
new Response('provider secret detail', { status: 401 }));
await expect(client.isEntitled('user')).rejects.toEqual(expect.objectContaining({
code: 'subscription_verification_failed',
message: 'Failed to verify subscription',
status: 502,
}));
});
});
@@ -0,0 +1,113 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { PostgresAccountOperationsRepository } from '../src/domains/account-operations/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('Account operations integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl || !localDatabase)('PostgresAccountOperationsRepository', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `account_operations_test_${randomUUID().replaceAll('-', '')}`;
const aliceId = '11111111-1111-4111-8111-111111111111';
const bobId = '22222222-2222-4222-8222-222222222222';
const coupleId = '33333333-3333-4333-8333-333333333333';
const packId = '44444444-4444-4444-8444-444444444444';
const questionId = '55555555-5555-4555-8555-555555555555';
let pool: Pool;
let repository: PostgresAccountOperationsRepository;
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 PostgresAccountOperationsRepository(isolatedUrl.toString());
for (const migrationName of [
'0000_identity_and_feature_interests.sql',
'0001_couples.sql',
'0002_packs_catalog_progress.sql',
'0003_answers_matches.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);
}
}
});
beforeEach(async () => {
await pool.query('truncate profiles, couples, question_packs, questions cascade');
await pool.query('insert into couples (id, invite_code) values ($1, $2)', [coupleId, 'ABCD2345']);
await pool.query(
`insert into profiles (id, name, couple_id, push_token) values
($1, 'Alice', $3, null), ($2, 'Bob', $3, 'ExponentPushToken[bob]')`,
[aliceId, bobId, coupleId],
);
await pool.query('insert into question_packs (id, name) values ($1, $2)', [packId, 'Core']);
await pool.query('insert into questions (id, pack_id, text) values ($1, $2, $3)', [questionId, packId, 'Question']);
});
afterAll(async () => {
await repository.close();
await pool.end();
await adminPool.query(`DROP SCHEMA "${schema}" CASCADE`);
await adminPool.end();
});
async function addProgress(): Promise<void> {
await pool.query(
`insert into responses (id, user_id, question_id, couple_id, answer)
values ($1, $2, $3, $4, 'yes')`,
[randomUUID(), aliceId, questionId, coupleId],
);
await pool.query(
`insert into matches (id, couple_id, question_id, match_type)
values ($1, $2, $3, 'yes_yes')`,
[randomUUID(), coupleId, questionId],
);
}
it('resets only the authenticated couple progress and retains the relationship', async () => {
await addProgress();
await expect(repository.resetProgress(aliceId)).resolves.toEqual({ partnerPushToken: 'ExponentPushToken[bob]' });
expect((await pool.query('select count(*)::int as count from responses')).rows[0].count).toBe(0);
expect((await pool.query('select count(*)::int as count from matches')).rows[0].count).toBe(0);
expect((await pool.query('select count(*)::int as count from profiles where couple_id = $1', [coupleId])).rows[0].count).toBe(2);
});
it('deletes shared couple data while retaining both independent profiles', async () => {
await addProgress();
await repository.deleteRelationship(aliceId);
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
expect((await pool.query('select count(*)::int as count from profiles')).rows[0].count).toBe(2);
expect((await pool.query('select count(*)::int as count from profiles where couple_id is not null')).rows[0].count).toBe(0);
});
it('rolls back every local account change when hosted Auth deletion fails', async () => {
const deleteAuth = vi.fn(async () => { throw new Error('provider unavailable'); });
await expect(repository.deleteAccount(aliceId, deleteAuth)).rejects.toThrow('provider unavailable');
expect(deleteAuth).toHaveBeenCalledOnce();
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(1);
expect((await pool.query('select count(*)::int as count from profiles')).rows[0].count).toBe(2);
expect((await pool.query('select count(*)::int as count from profiles where couple_id = $1', [coupleId])).rows[0].count).toBe(2);
});
it('deletes the local profile and relationship only after Auth confirms', async () => {
const deleteAuth = vi.fn(async () => undefined);
await repository.deleteAccount(aliceId, deleteAuth);
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
expect((await pool.query('select id, couple_id from profiles')).rows).toEqual([{ id: bobId, couple_id: null }]);
});
it('updates premium status only for the authenticated profile', async () => {
await repository.setPremium(aliceId, true);
expect((await pool.query('select id, is_premium from profiles order by id')).rows).toEqual([
{ id: aliceId, is_premium: true },
{ id: bobId, is_premium: false },
]);
});
});
@@ -0,0 +1,60 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import { registerAccountOperationRoutes } from '../src/domains/account-operations/routes.js';
import type { AccountOperationsService } from '../src/domains/account-operations/service.js';
import { AccountOperationError } from '../src/domains/account-operations/types.js';
const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
email: 'alice@sauci.test',
name: 'Alice',
avatarUrl: null,
};
function service(): AccountOperationsService {
return {
deleteRelationship: vi.fn(async () => ({ success: true, message: 'deleted' })),
resetProgress: vi.fn(async () => ({ success: true, message: 'reset' })),
deleteAccount: vi.fn(async () => ({ success: true, message: 'deleted' })),
syncSubscription: vi.fn(async () => ({ success: true, is_premium: true })),
} as unknown as AccountOperationsService;
}
function appWith(operations: AccountOperationsService) {
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => {
c.set('identity', identity);
await next();
});
registerAccountOperationRoutes(app, operations);
return app;
}
describe('account operation routes', () => {
it('derives ownership exclusively from the authenticated identity', async () => {
const operations = service();
const app = appWith(operations);
expect((await app.request('/v1/couple/data', { method: 'DELETE' })).status).toBe(200);
expect((await app.request('/v1/couple/progress', { method: 'DELETE' })).status).toBe(200);
expect((await app.request('/v1/me', { method: 'DELETE' })).status).toBe(200);
expect((await app.request('/v1/me/subscription/sync', { method: 'POST' })).status).toBe(200);
expect(operations.deleteRelationship).toHaveBeenCalledWith(identity.id);
expect(operations.resetProgress).toHaveBeenCalledWith(identity.id);
expect(operations.deleteAccount).toHaveBeenCalledWith(identity.id);
expect(operations.syncSubscription).toHaveBeenCalledWith(identity.id);
});
it('returns a safe structured provider error', async () => {
const operations = service();
vi.mocked(operations.deleteAccount).mockRejectedValueOnce(
new AccountOperationError('auth_delete_failed', 'Failed to delete authentication record', 502),
);
const response = await appWith(operations).request('/v1/me', { method: 'DELETE' });
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: { code: 'auth_delete_failed', message: 'Failed to delete authentication record' },
});
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest';
import { SupabaseAdminAuthDirectory } from '../src/domains/admin/auth-directory.js';
describe('admin hosted-auth directory', () => {
it('returns explicit empty enrichment when server credentials are absent', async () => {
const request = vi.fn();
await expect(new SupabaseAdminAuthDirectory(undefined, undefined, request).users()).resolves.toEqual([]);
expect(request).not.toHaveBeenCalled();
});
it('reads hosted Auth metadata with server-only credentials', async () => {
const request = vi.fn(async () => Response.json({ users: [{
id: '11111111-1111-4111-8111-111111111111', email: 'admin@example.test',
last_sign_in_at: '2026-08-27T12:00:00.000Z', confirmed_at: '2026-08-26T12:00:00.000Z',
}] }));
const directory = new SupabaseAdminAuthDirectory('https://auth.example.test/auth/v1', 'server-secret', request);
await expect(directory.users()).resolves.toEqual([{
id: '11111111-1111-4111-8111-111111111111', email: 'admin@example.test',
last_sign_in_at: '2026-08-27T12:00:00.000Z', email_confirmed_at: '2026-08-26T12:00:00.000Z',
}]);
expect(request).toHaveBeenCalledWith(
'https://auth.example.test/auth/v1/admin/users?page=1&per_page=1000',
expect.objectContaining({ headers: { apikey: 'server-secret', authorization: 'Bearer server-secret' } }),
);
});
it('does not expose hosted Auth provider failures', async () => {
const directory = new SupabaseAdminAuthDirectory('https://auth.example.test', 'server-secret', async () =>
new Response('sensitive provider detail', { status: 401 }));
await expect(directory.users()).resolves.toEqual([]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import type { AuthVerifier } from '../src/auth.js';
import { AdminRequestAuth } from '../src/domains/admin/auth.js';
const hosted: AuthVerifier = { verify: vi.fn(async (token) => {
if (token !== 'hosted') throw new Error('invalid');
return { id: '11111111-1111-4111-8111-111111111111', email: 'admin@test', name: null, avatarUrl: null };
}) };
describe('AdminRequestAuth', () => {
it('accepts hosted Supabase bearer identities', async () => {
await expect(new AdminRequestAuth(hosted).verify('hosted')).resolves.toMatchObject({ id: '11111111-1111-4111-8111-111111111111' });
});
it('maps a stable service token to its explicit least-privilege admin actor', async () => {
const auth = new AdminRequestAuth(hosted, 'a'.repeat(32), '22222222-2222-4222-8222-222222222222');
await expect(auth.verify('a'.repeat(32))).resolves.toMatchObject({ id: '22222222-2222-4222-8222-222222222222' });
await expect(auth.verify('wrong')).rejects.toThrow('invalid');
});
it('rejects partial or weak service-token configuration', () => {
expect(() => new AdminRequestAuth(hosted, 'a'.repeat(32))).toThrow(/configured together/);
expect(() => new AdminRequestAuth(hosted, 'short', '22222222-2222-4222-8222-222222222222')).toThrow(/32/);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PostgresAdminRepository } from '../src/domains/admin/repository.js';
const databaseUrl = process.env.DATABASE_URL;
const isLocal = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
if (databaseUrl && !isLocal) throw new Error('Admin integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audit', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `admin_test_${randomUUID().replaceAll('-', '')}`;
const creatorUser = '11111111-1111-4111-8111-111111111111';
const superUser = '22222222-2222-4222-8222-222222222222';
const ordinaryUser = '33333333-3333-4333-8333-333333333333';
const moderatorUser = '44444444-4444-4444-8444-444444444444';
let pool: Pool; let repository: PostgresAdminRepository;
beforeAll(async () => {
await adminPool.query(`create schema "${schema}"`);
const isolated = new URL(databaseUrl!); isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() }); repository = new PostgresAdminRepository(isolated.toString());
for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql']) {
const migration = await readFile(new URL(`../drizzle/${name}`, 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,$5),($2,$6),($3,$7),($4,$8)', [creatorUser, superUser, ordinaryUser, moderatorUser, 'Creator', 'Super', 'User', 'Moderator']);
await pool.query("insert into admin_users(user_id,role,permissions) values($1,'pack_creator',array['manage_packs']),($2,'super_admin','{}'),($3,'pack_creator',array['view_chats','view_media'])", [creatorUser, superUser, moderatorUser]);
});
afterAll(async () => { await repository.close(); await pool.end(); await adminPool.query(`drop schema "${schema}" cascade`); await adminPool.end(); });
it('denies ordinary authenticated users and permission escalation', async () => {
await expect(repository.principal(ordinaryUser)).rejects.toMatchObject({ code: 'admin_access_denied' });
const creator = await repository.principal(creatorUser);
await expect(repository.query(creator, 'profiles', {})).rejects.toMatchObject({ code: 'forbidden' });
await expect(repository.query(creator, 'audit_logs', {})).rejects.toMatchObject({ code: 'forbidden' });
});
it('allows content permissions but isolates privileged resources', async () => {
const creator = await repository.principal(creatorUser);
const inserted = await repository.insert(creator, 'question_packs', [{ name: 'Creator pack' }]);
const result = await repository.query(creator, 'question_packs', { filters: [{ column: 'id', op: 'eq', value: inserted[0]!.id }] });
expect(result).toMatchObject({ count: 1, rows: [expect.objectContaining({ name: 'Creator pack' })] });
});
it('writes immutable audit evidence in the same transaction as mutations', async () => {
const creator = await repository.principal(creatorUser);
const [pack] = await repository.insert(creator, 'question_packs', [{ name: 'Audited pack' }]);
await repository.update(creator, 'question_packs', String(pack!.id), { name: 'Updated pack' });
await repository.delete(creator, 'question_packs', String(pack!.id));
const audit = await pool.query('select action,actor_user_id,old_values,new_values from audit_logs where record_id=$1 order by created_at', [pack!.id]);
expect(audit.rows.map(row => row.action)).toEqual(['INSERT', 'UPDATE', 'DELETE']);
expect(audit.rows.every(row => row.actor_user_id === creatorUser)).toBe(true);
expect(audit.rows[1]).toMatchObject({ old_values: expect.objectContaining({ name: 'Audited pack' }), new_values: expect.objectContaining({ name: 'Updated pack' }) });
});
it('preserves legacy AI config columns while redacting provider credentials from audit logs', async () => {
const principal = await repository.principal(superUser);
const [config] = await repository.insert(principal, 'ai_config', [{
openrouter_api_key: 'sk-sensitive', default_model: 'openai/gpt-4o-mini',
model_generate: 'anthropic/claude-3.5-sonnet', classifier_enabled: true,
heuristics_enabled: true, heuristic_min_text_length: 18,
}]);
expect(config).toMatchObject({
openrouter_api_key: 'sk-sensitive', model_generate: 'anthropic/claude-3.5-sonnet',
classifier_enabled: true, heuristics_enabled: true, heuristic_min_text_length: 18,
});
const audit = await pool.query("select new_values from audit_logs where table_name='ai_config' and record_id=$1", [config!.id]);
expect(audit.rows[0]?.new_values).toMatchObject({ openrouter_api_key: '[REDACTED]', default_model: 'openai/gpt-4o-mini' });
});
it('grants super admins read access without trusting client role claims', async () => {
const principal = await repository.principal(superUser);
expect((await repository.query(principal, 'profiles', {})).count).toBe(4);
expect((await repository.query(principal, 'audit_logs', {})).count).toBeGreaterThan(0);
});
it('honors validated projections and never returns generic secret-bearing fields', async () => {
await pool.query("update profiles set push_token='ExponentPushToken[secret]',public_key_jwk='{\"kty\":\"RSA\"}' where id=$1", [ordinaryUser]);
const principal = await repository.principal(superUser);
const projected = await repository.query(principal, 'profiles', { columns: ['id', 'name'], filters: [{ column: 'id', op: 'eq', value: ordinaryUser }] });
expect(projected.rows).toEqual([{ id: ordinaryUser, name: 'User' }]);
const defaultRow = (await repository.query(principal, 'profiles', { filters: [{ column: 'id', op: 'eq', value: ordinaryUser }] })).rows[0]!;
expect(defaultRow).not.toHaveProperty('push_token');
expect(defaultRow).not.toHaveProperty('public_key_jwk');
await expect(repository.query(principal, 'profiles', { columns: ['id', 'push_token'] })).rejects.toMatchObject({ code: 'restricted_column' });
});
it('supports null-safe filters and scoped feature-interest aggregates', async () => {
await pool.query("insert into feature_interests(user_id,feature) values($1,'better-chat'),($2,'better-chat')", [creatorUser, ordinaryUser]);
const principal = await repository.principal(superUser);
expect((await repository.query(principal, 'profiles', { filters: [{ column: 'email', op: 'neq', value: null }] })).count).toBe(0);
expect(await repository.featureInterestCounts(principal)).toEqual([{
feature_name: 'better-chat', opt_in_count: 2, opt_in_count_last_7_days: 2,
}]);
});
it('gifts premium atomically and audits the explicit service action', async () => {
const principal = await repository.principal(superUser);
const result = await repository.giftPremium(principal, ordinaryUser, 30, 'support grant');
expect(result.subscription_id).toEqual(expect.any(String));
expect((await pool.query('select is_premium from profiles where id=$1', [ordinaryUser])).rows[0]?.is_premium).toBe(true);
expect((await pool.query("select new_values from audit_logs where record_id=$1 and action='ACTION'", [result.subscription_id])).rows[0]?.new_values).toMatchObject({ action: 'gift_premium', target_user_id: ordinaryUser, days: 30 });
});
it('supports lifetime gifts without inventing an expiry', async () => {
const principal = await repository.principal(superUser);
const result = await repository.giftPremium(principal, ordinaryUser, null, 'lifetime support grant');
expect(result.expires_at).toBeNull();
expect((await pool.query('select expires_at from subscriptions where id=$1', [result.subscription_id])).rows[0]?.expires_at).toBeNull();
});
it('gates message moderation and media access independently from AI configuration', async () => {
const creator = await repository.principal(creatorUser);
const moderator = await repository.principal(moderatorUser);
await expect(repository.query(creator, 'messages', {})).rejects.toMatchObject({ code: 'forbidden' });
await expect(repository.query(creator, 'message_reports', {})).rejects.toMatchObject({ code: 'forbidden' });
expect((await repository.query(moderator, 'messages', {})).count).toBe(0);
expect((await repository.query(moderator, 'message_reports', {})).count).toBe(0);
await expect(repository.authorizeMedia(creator, randomUUID())).rejects.toMatchObject({ code: 'forbidden' });
await expect(repository.authorizeMedia(moderator, randomUUID())).rejects.toMatchObject({ code: 'media_not_found' });
});
it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => {
const tables = await pool.query<{ table_name: string }>(
`select table_name from information_schema.tables where table_schema=current_schema()
and table_name=any($1::text[]) order by table_name`,
[['admin_users','audit_logs','topics','pack_topics','ai_config','dare_packs','dares','master_keys','sent_dares','dare_messages']],
);
expect(tables.rows.map(row => row.table_name)).toEqual([
'admin_users','ai_config','audit_logs','dare_messages','dare_packs','dares','master_keys','pack_topics','sent_dares','topics',
]);
const fks = await pool.query<{ constraint_name: string }>(
`select constraint_name from information_schema.table_constraints
where table_schema=current_schema() and constraint_type='FOREIGN KEY'
and table_name in ('sent_dares','dare_messages')`,
);
expect(fks.rows).toHaveLength(6);
expect((await pool.query(`select column_name from information_schema.columns where table_schema=current_schema() and table_name='feedback' and column_name='admin_notes'`)).rowCount).toBe(1);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { AdminRepository } from '../src/domains/admin/repository.js';
import { registerAdminRoutes } from '../src/domains/admin/routes.js';
import { AdminError, type AdminPrincipal } from '../src/domains/admin/types.js';
import { FilesystemMediaStorage } from '../src/domains/media/storage.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: 'admin@test', name: 'Admin', avatarUrl: null };
const principal: AdminPrincipal = { adminId: '22222222-2222-4222-8222-222222222222', userId: identity.id, role: 'pack_creator', permissions: ['manage_packs'] };
function setup(denied = false) {
const repository: AdminRepository = {
principal: vi.fn(async (userId) => { if (denied) throw new AdminError('admin_access_denied', 'denied', 403); return { ...principal, userId }; }),
query: vi.fn(async () => ({ rows: [{ id: 'pack' }], count: 1 })),
insert: vi.fn(async () => [{ id: 'pack' }]), update: vi.fn(async () => ({ id: 'pack' })),
delete: vi.fn(async () => undefined), dashboard: vi.fn(async () => ({ packs: 1 })),
giftPremium: vi.fn(async () => ({ subscription_id: 'sub', expires_at: 'later' })),
featureInterestCounts: vi.fn(async () => []), users: vi.fn(async () => []), close: vi.fn(async () => undefined),
authorizeMedia: vi.fn(async () => undefined),
decryptMessage: vi.fn(async () => ({ content: 'plain' })),
decryptMedia: vi.fn(async () => ({ bytes: Buffer.from('media'), mimeType: 'image/png' })),
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerAdminRoutes(app, repository, undefined, new FilesystemMediaStorage('/tmp/admin-route-media', 'test-signing-secret', 'http://localhost:8787'));
return { app, repository };
}
describe('admin routes', () => {
it('returns role claims only after server-side admin lookup', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/admin/me');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ role: 'pack_creator', permissions: ['manage_packs'] });
expect(repository.principal).toHaveBeenCalledWith(identity.id);
});
it('denies authenticated users absent from active admin membership', async () => {
const response = await setup(true).app.request('/v1/admin/me');
expect(response.status).toBe(403);
});
it('never accepts actor identity or role from mutation bodies', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/admin/data/question_packs', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ records: [{ name: 'Pack' }], role: 'super_admin', actor_user_id: 'attacker' }),
});
expect(response.status).toBe(400);
expect(repository.insert).not.toHaveBeenCalled();
});
it('passes only validated query and mutation data to the authorized repository', async () => {
const { app, repository } = setup();
const queried = await app.request('/v1/admin/query/question_packs', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ columns: ['id', 'name'], filters: [{ column: 'is_public', op: 'eq', value: true }], limit: 20 }),
});
expect(queried.status).toBe(200);
expect(repository.query).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), 'question_packs', expect.objectContaining({ columns: ['id', 'name'], limit: 20 }));
});
it('issues short-lived admin-authorized media URLs without exposing storage paths', async () => {
const { app, repository } = setup();
const mediaId = '33333333-3333-4333-8333-333333333333';
const response = await app.request(`/v1/admin/media/${mediaId}/url`);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('private, no-store');
expect(await response.json()).toMatchObject({ url: expect.stringContaining(`/media/${mediaId}/content?`) });
expect(repository.authorizeMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), mediaId);
});
it('accepts explicit lifetime premium grants', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/admin/users/${identity.id}/gift-premium`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ days: null }),
});
expect(response.status).toBe(201);
expect(repository.giftPremium).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), identity.id, null, undefined);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll,beforeAll,describe,expect,it } from 'vitest';
import { PostgresAnswersRepository } from '../src/domains/answers/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('Answer integration tests only permit localhost');
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999';
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 PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female'])",[q,pack,concurrentQ,femaleOnlyQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
it('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});});
it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});});
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,user1_answered_today:true,user2_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
it('serializes the daily limit across different questions',async()=>{await pool.query('delete from responses where user_id=$1',[u1]);await pool.query('update app_config set daily_response_limit=1');const results=await Promise.allSettled([repo.submit(u1,{questionId:q,answer:'yes'}),repo.submit(u1,{questionId:femaleOnlyQ,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});});
});
+26
View File
@@ -0,0 +1,26 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { AnswersRepository } from '../src/domains/answers/repository.js';
import { registerAnswerRoutes } from '../src/domains/answers/routes.js';
import { AnswersError, calculateMatchType } from '../src/domains/answers/types.js';
const identity: AuthIdentity={id:'11111111-1111-4111-8111-111111111111',email:null,name:null,avatarUrl:null};
const questionId='22222222-2222-4222-8222-222222222222';
function repo():AnswersRepository{return {
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
describe('answer routes',()=>{
it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});});
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached'));expect((await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})})).status).toBe(429);});
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
});
describe('match semantics',()=>{
const q=(question_type:'swipe'|'text_answer'|'audio'|'photo'|'who_likely')=>({id:questionId,question_type});
it('preserves swipe combinations',()=>{expect(calculateMatchType(q('swipe'),'yes','yes')).toBe('yes_yes');expect(calculateMatchType(q('swipe'),'yes','maybe')).toBe('yes_maybe');expect(calculateMatchType(q('swipe'),'maybe','maybe')).toBe('maybe_maybe');expect(calculateMatchType(q('swipe'),'no','yes')).toBeNull();});
it('matches non-swipe positive answers and always matches who-likely',()=>{expect(calculateMatchType(q('text_answer'),'yes','maybe')).toBe('both_answered');expect(calculateMatchType(q('photo'),'no','yes')).toBeNull();expect(calculateMatchType(q('who_likely'),'no','no')).toBe('both_answered');});
});
+83
View File
@@ -0,0 +1,83 @@
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp } from '../src/app.js';
import { createAuthVerifier } from '../src/auth.js';
import { loadConfig } from '../src/config.js';
import { PostgresRepository } from '../src/db/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('API integration tests only permit a localhost DATABASE_URL');
}
describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `api_test_${randomUUID().replaceAll('-', '')}`;
let pool: Pool;
let repository: PostgresRepository;
let token: string;
let app: ReturnType<typeof createApp>;
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 PostgresRepository(isolatedUrl.toString());
const migration = await readFile(new URL('../drizzle/0000_identity_and_feature_interests.sql', import.meta.url), 'utf8');
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
const { publicKey, privateKey } = await generateKeyPair('ES256');
const publicJwk = await exportJWK(publicKey);
publicJwk.kid = 'integration-key';
const issuer = 'https://auth.sauci.test/auth/v1';
token = await new SignJWT({ email: 'integration@sauci.test', user_metadata: { name: 'Integration' } })
.setProtectedHeader({ alg: 'ES256', kid: 'integration-key' })
.setSubject('33333333-3333-4333-8333-333333333333')
.setIssuer(issuer)
.setAudience('authenticated')
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);
const config = loadConfig({
NODE_ENV: 'test',
DATABASE_URL: databaseUrl,
SUPABASE_AUTH_ISSUER: issuer,
AUTH_TEST_JWKS: JSON.stringify({ keys: [publicJwk] }),
});
app = createApp({ auth: createAuthVerifier(config), repository });
});
afterAll(async () => {
await repository.close();
await pool.end();
await adminPool.query(`DROP SCHEMA "${schema}" CASCADE`);
await adminPool.end();
});
it('bootstraps identity and persists an isolated interest round-trip', async () => {
const headers = { authorization: `Bearer ${token}` };
const me = await app.request('/v1/me', { headers });
expect(me.status).toBe(200);
expect(await me.json()).toMatchObject({
profile: {
id: '33333333-3333-4333-8333-333333333333',
email: 'integration@sauci.test',
name: 'Integration',
max_intensity: 5,
},
});
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
.toEqual({ feature: 'better-chat', interested: true });
expect(await (await app.request('/v1/me/feature-interests/better-chat', { method: 'DELETE', headers })).json())
.toEqual({ feature: 'better-chat', interested: false });
});
});
@@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PostgresAppDataRepository } from '../src/domains/app-data/repository.js';
const databaseUrl = process.env.DATABASE_URL;
const local = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
if (databaseUrl && !local) throw new Error('App-data integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () => {
const admin = new Pool({ connectionString: databaseUrl }); const schema = `app_data_${randomUUID().replaceAll('-', '')}`;
const alice='11111111-1111-4111-8111-111111111111', bob='22222222-2222-4222-8222-222222222222', outsider='33333333-3333-4333-8333-333333333333';
const couple='44444444-4444-4444-8444-444444444444', otherCouple='55555555-5555-4555-8555-555555555555', category='66666666-6666-4666-8666-666666666666';
const pack='77777777-7777-4777-8777-777777777777', question='88888888-8888-4888-8888-888888888888', match='99999999-9999-4999-8999-999999999999', message='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
let pool: Pool; let repo: PostgresAppDataRepository;
beforeAll(async () => {
await admin.query(`create schema "${schema}"`); const url=new URL(databaseUrl!); url.searchParams.set('options',`-c search_path=${schema}`);
pool=new Pool({connectionString:url.toString()}); repo=new PostgresAppDataRepository(url.toString());
for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0008_residual_realtime.sql']) {
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'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)',[couple,'APDATA01',otherCouple,'APDATA02']);
await pool.query('insert into profiles(id,couple_id,name,hide_nsfw) values($1,$4,$5,false),($2,$4,$6,false),($3,$7,$8,false)',[alice,bob,outsider,couple,'Alice','Bob',otherCouple,'Out']);
await pool.query("insert into categories(id,name) values($1,'Visible')",[category]);
await pool.query("insert into question_packs(id,name,category_id) values($1,'Pack',$2)",[pack,category]);
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(gen_random_uuid(),$1,$3,$4,'yes'),(gen_random_uuid(),$2,$3,$4,'yes')",[alice,bob,question,couple]);
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[match,couple,question]);
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
});
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('returns only public catalog data and couple-owned match context',async()=>{
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
await expect(repo.matchContext(outsider,match)).rejects.toMatchObject({status:404});
});
it('allows only the recipient in the owning couple to mark media viewed',async()=>{
expect((await repo.markMediaViewed(bob,message,null)).media_viewed_at).toBeTruthy();
await expect(repo.markMediaViewed(alice,message,null)).rejects.toMatchObject({status:404});
await expect(repo.markMediaViewed(outsider,message,null)).rejects.toMatchObject({status:404});
});
it('persists drawing state per couple and rejects foreign stroke owners',async()=>{
const stroke={id:'s1',userId:alice,points:[{x:.2,y:.3}],color:'#fff',width:2,timestamp:1,isEraser:false};
expect((await repo.putLiveDraw(alice,[stroke],0)).revision).toBe(1);
expect((await repo.getLiveDraw(bob)).strokes).toEqual([stroke]);
expect((await repo.getLiveDraw(outsider)).strokes).toEqual([]);
await expect(repo.putLiveDraw(alice,[{...stroke,userId:outsider}],1)).rejects.toMatchObject({status:403});
});
it('rejects one of two concurrent writes from the same base revision with current state',async()=>{
const current=await repo.getLiveDraw(alice);
const a={id:'concurrent-a',userId:alice,points:[{x:.1,y:.1}],color:'#fff',width:2,timestamp:2,isEraser:false};
const b={...a,id:'concurrent-b',userId:bob};
const results=await Promise.allSettled([
repo.putLiveDraw(alice,[...current.strokes,a],current.revision),
repo.putLiveDraw(bob,[...current.strokes,b],current.revision),
]);
expect(results.filter(result=>result.status==='fulfilled')).toHaveLength(1);
const rejected=results.find(result=>result.status==='rejected');
expect(rejected).toMatchObject({status:'rejected',reason:{status:409,code:'revision_conflict',details:{current_state:{revision:current.revision+1}}}});
});
it('atomically records nudge cooldown before reporting a missing push token',async()=>{
await expect(repo.sendNudge(alice)).resolves.toMatchObject({success:true,notification_sent:false,reason:'no_push_token'});
await expect(repo.sendNudge(alice)).rejects.toMatchObject({status:429,code:'rate_limited'});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { AppDataRepository } from '../src/domains/app-data/repository.js';
import { registerAppDataRoutes } from '../src/domains/app-data/routes.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const pack = '22222222-2222-4222-8222-222222222222';
const match = '33333333-3333-4333-8333-333333333333';
const message = '44444444-4444-4444-8444-444444444444';
function repository(): AppDataRepository {
return {
packContext: vi.fn(async () => ({ id: pack, name: 'Pack', icon: null })),
packQuestions: vi.fn(async () => []), packTeaser: vi.fn(async () => []),
matchContext: vi.fn(async () => ({ id: match, question_id: pack, couple_id: pack, match_type: 'yes_yes', created_at: 'now', response_summary: null, question: { id: pack, text: 'Q', partner_text: null }, responses: [] })),
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })),
sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })),
getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })),
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
close: vi.fn(async () => undefined),
};
}
function appWith(repo: AppDataRepository) { const app = new Hono<{ Variables: { identity: AuthIdentity } }>(); app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); }); registerAppDataRoutes(app, repo); return app; }
describe('app data routes', () => {
it('derives all ownership from the authenticated identity', async () => {
const repo = repository(); const app = appWith(repo);
expect((await app.request(`/v1/packs/${pack}/questions`)).status).toBe(200);
expect(repo.packQuestions).toHaveBeenCalledWith(identity.id, pack);
expect((await app.request(`/v1/matches/${match}/context`)).status).toBe(200);
expect(repo.matchContext).toHaveBeenCalledWith(identity.id, match);
expect((await app.request('/v1/live-draw')).status).toBe(200);
expect(repo.getLiveDraw).toHaveBeenCalledWith(identity.id);
expect((await app.request('/v1/me/nudge', { method: 'POST' })).status).toBe(200);
expect(repo.sendNudge).toHaveBeenCalledWith(identity.id);
});
it('validates IDs, media metadata, and complete drawing shape', async () => {
const repo = repository(); const app = appWith(repo);
expect((await app.request('/v1/packs/no/questions')).status).toBe(400);
expect((await app.request(`/v1/messages/${message}/media-viewed`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: '{}' })).status).toBe(400);
expect((await app.request('/v1/live-draw', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: '{"strokes":[{"id":"x"}]}' })).status).toBe(400);
expect(repo.putLiveDraw).not.toHaveBeenCalled();
});
it('accepts normalized drawing state without a client supplied couple ID', async () => {
const repo = repository(); const app = appWith(repo);
const stroke = { id: 's1', userId: identity.id, points: [{ x: 0.2, y: 0.8 }], color: '#fff', width: 2, timestamp: 1, isEraser: false };
const response = await app.request('/v1/live-draw', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ strokes: [stroke], base_revision: 0 }) });
expect(response.status).toBe(200);
expect(repo.putLiveDraw).toHaveBeenCalledWith(identity.id, [stroke], 0);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest';
import { createApp } from '../src/app.js';
import type { AuthIdentity, AuthVerifier } from '../src/auth.js';
import type {
ApiRepository,
MobileCompatibleProfile,
} from '../src/db/repository.js';
const alice: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
email: 'alice@sauci.test',
name: 'Alice',
avatarUrl: null,
};
class MemoryRepository implements ApiRepository {
profiles = new Map<string, MobileCompatibleProfile>();
interests = new Set<string>();
ready = vi.fn(async () => undefined);
close = vi.fn(async () => undefined);
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
const existing = this.profiles.get(identity.id);
if (existing) return existing;
const now = new Date().toISOString();
const profile: MobileCompatibleProfile = {
id: identity.id,
name: identity.name,
email: identity.email,
avatar_url: identity.avatarUrl,
push_token: null,
is_premium: false,
couple_id: null,
gender: null,
show_explicit_content: true,
max_intensity: 5,
public_key_jwk: null,
hide_nsfw: false,
onboarding_completed: false,
onboarding_version: 0,
created_at: now,
updated_at: now,
};
this.profiles.set(identity.id, profile);
return profile;
}
async getFeatureInterest(userId: string, feature: string) {
return { feature, interested: this.interests.has(`${userId}:${feature}`) };
}
async putFeatureInterest(userId: string, feature: string) {
this.interests.add(`${userId}:${feature}`);
return { feature, interested: true };
}
async deleteFeatureInterest(userId: string, feature: string) {
this.interests.delete(`${userId}:${feature}`);
return { feature, interested: false };
}
}
function verifier(identity: AuthIdentity = alice): AuthVerifier {
return {
verify: vi.fn(async (token: string) => {
if (token !== 'valid') throw new Error('invalid');
return identity;
}),
};
}
describe('Sauci API', () => {
it('reports liveness without touching the database and readiness with a database check', async () => {
const repository = new MemoryRepository();
const app = createApp({ auth: verifier(), repository });
expect(await (await app.request('/health/live')).json()).toEqual({ status: 'ok' });
expect(repository.ready).not.toHaveBeenCalled();
expect((await app.request('/health/ready')).status).toBe(200);
expect(repository.ready).toHaveBeenCalledOnce();
});
it('fails closed when the bearer token is missing or invalid', async () => {
const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
expect((await app.request('/v1/me')).status).toBe(401);
expect((await app.request('/v1/me', { headers: { authorization: 'Bearer invalid' } })).status).toBe(401);
});
it('idempotently bootstraps the authenticated profile', async () => {
const repository = new MemoryRepository();
const app = createApp({ auth: verifier(), repository });
const request = () => app.request('/v1/me', { headers: { authorization: 'Bearer valid' } });
const first = await (await request()).json() as { profile: MobileCompatibleProfile };
const second = await (await request()).json() as { profile: MobileCompatibleProfile };
expect(first.profile).toMatchObject({ id: alice.id, email: alice.email, name: 'Alice' });
expect(second.profile).toEqual(first.profile);
expect(repository.profiles.size).toBe(1);
});
it('derives feature-interest ownership exclusively from the token', async () => {
const repository = new MemoryRepository();
const app = createApp({ auth: verifier(), repository });
const headers = { authorization: 'Bearer valid' };
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
.toEqual({ feature: 'better-chat', interested: false });
expect(await (await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).json())
.toEqual({ feature: 'better-chat', interested: true });
expect(repository.interests.has(`${alice.id}:better-chat`)).toBe(true);
expect(await (await app.request('/v1/me/feature-interests/better-chat', { method: 'DELETE', headers })).json())
.toEqual({ feature: 'better-chat', interested: false });
});
it('rejects non-canonical feature names', async () => {
const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
const response = await app.request('/v1/me/feature-interests/Not%20Safe', {
headers: { authorization: 'Bearer valid' },
});
expect(response.status).toBe(400);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { describe, expect, it } from 'vitest';
import { createAuthVerifier } from '../src/auth.js';
import { loadConfig } from '../src/config.js';
const issuer = 'https://auth.sauci.test/auth/v1';
async function signedToken(audience = 'authenticated') {
const { publicKey, privateKey } = await generateKeyPair('ES256');
const publicJwk = await exportJWK(publicKey);
publicJwk.kid = 'test-key';
const token = await new SignJWT({
email: 'user@sauci.test',
user_metadata: { full_name: 'Test User', avatar_url: 'https://example.test/avatar.png' },
})
.setProtectedHeader({ alg: 'ES256', kid: 'test-key' })
.setSubject('22222222-2222-4222-8222-222222222222')
.setIssuer(issuer)
.setAudience(audience)
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);
return { token, jwks: JSON.stringify({ keys: [publicJwk] }) };
}
describe('Supabase Auth JWT verification', () => {
it('verifies issuer, audience, signature and maps identity claims', async () => {
const { token, jwks } = await signedToken();
const config = loadConfig({
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://localhost/sauci_test',
SUPABASE_AUTH_ISSUER: issuer,
AUTH_TEST_JWKS: jwks,
});
await expect(createAuthVerifier(config).verify(token)).resolves.toEqual({
id: '22222222-2222-4222-8222-222222222222',
email: 'user@sauci.test',
name: 'Test User',
avatarUrl: 'https://example.test/avatar.png',
});
});
it('rejects a token for another audience', async () => {
const { token, jwks } = await signedToken('another-service');
const config = loadConfig({
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://localhost/sauci_test',
SUPABASE_AUTH_ISSUER: issuer,
AUTH_TEST_JWKS: jwks,
});
await expect(createAuthVerifier(config).verify(token)).rejects.toThrow();
});
it('refuses test JWKS configuration outside the test environment', async () => {
const { jwks } = await signedToken();
expect(() => loadConfig({
NODE_ENV: 'production',
DATABASE_URL: 'postgresql://localhost/sauci',
SUPABASE_AUTH_ISSUER: issuer,
AUTH_TEST_JWKS: jwks,
})).toThrow('AUTH_TEST_JWKS is forbidden');
});
});
+133
View File
@@ -0,0 +1,133 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { PostgresBillingRepository } from '../src/domains/billing/repository.js';
import type { RevenueCatWebhook } from '../src/domains/billing/types.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('Billing integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl || !localDatabase)('PostgresBillingRepository', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `billing_test_${randomUUID().replaceAll('-', '')}`;
const aliceId = '11111111-1111-4111-8111-111111111111';
const bobId = '22222222-2222-4222-8222-222222222222';
const coupleId = '33333333-3333-4333-8333-333333333333';
const packId = '44444444-4444-4444-8444-444444444444';
let pool: Pool;
let repository: PostgresBillingRepository;
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 PostgresBillingRepository(isolatedUrl.toString());
for (const migrationName of [
'0000_identity_and_feature_interests.sql',
'0001_couples.sql',
'0002_packs_catalog_progress.sql',
'0007_billing_redemption.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);
}
}
});
beforeEach(async () => {
await pool.query('truncate profiles, couples, question_packs, subscriptions, revenuecat_webhook_events, redemption_codes, redemption_rate_limits cascade');
await pool.query('insert into couples (id, invite_code) values ($1, $2)', [coupleId, 'ABCD2345']);
await pool.query(
`insert into profiles (id, email, couple_id) values
($1, 'alice@example.test', $3), ($2, 'bob@example.test', $3)`,
[aliceId, bobId, coupleId],
);
await pool.query(
`insert into question_packs (id, name, is_premium) values ($1, 'Premium', true)`,
[packId],
);
await pool.query(
'insert into couple_packs (couple_id, pack_id, enabled) values ($1, $2, true)',
[coupleId, packId],
);
});
afterAll(async () => {
await repository.close();
await pool.end();
await adminPool.query(`DROP SCHEMA "${schema}" CASCADE`);
await adminPool.end();
});
function webhook(overrides: Partial<RevenueCatWebhook['event']> = {}): RevenueCatWebhook {
return {
api_version: '1.0',
event: {
type: 'INITIAL_PURCHASE',
id: randomUUID(),
app_user_id: aliceId,
original_app_user_id: aliceId,
product_id: 'sauci.pro.monthly',
entitlement_ids: ['Sauci Pro'],
purchased_at_ms: Date.now(),
expiration_at_ms: Date.now() + 86_400_000,
original_transaction_id: 'transaction-1',
store: 'APP_STORE',
environment: 'SANDBOX',
...overrides,
},
};
}
it('persists a webhook exactly once and grants premium', async () => {
const event = webhook({ id: 'event-once' });
await expect(repository.processRevenueCatEvent(event, 'active')).resolves.toEqual({ duplicate: false, handled: true });
await expect(repository.processRevenueCatEvent(event, 'active')).resolves.toEqual({ duplicate: true, handled: true });
expect((await pool.query('select count(*)::int as count from revenuecat_webhook_events')).rows[0].count).toBe(1);
expect((await pool.query('select count(*)::int as count from subscriptions')).rows[0].count).toBe(1);
expect((await pool.query('select is_premium from profiles where id = $1', [aliceId])).rows[0].is_premium).toBe(true);
});
it('rolls back a known event for an unknown user so a provider retry remains possible', async () => {
const event = webhook({ id: 'unknown-user', app_user_id: '99999999-9999-4999-8999-999999999999' });
await expect(repository.processRevenueCatEvent(event, 'active')).rejects.toMatchObject({ code: 'user_not_found' });
expect((await pool.query('select count(*)::int as count from revenuecat_webhook_events')).rows[0].count).toBe(0);
});
it('removes premium and disables premium packs only when neither partner remains premium', async () => {
await repository.processRevenueCatEvent(webhook({ id: 'purchase' }), 'active');
await repository.processRevenueCatEvent(webhook({ id: 'expiry', type: 'EXPIRATION' }), 'expired');
expect((await pool.query('select is_premium from profiles where id = $1', [aliceId])).rows[0].is_premium).toBe(false);
expect((await pool.query('select enabled from couple_packs where couple_id = $1', [coupleId])).rows[0].enabled).toBe(false);
});
it('serializes limited redemption uses and grants exactly one account', async () => {
await pool.query(
`insert into redemption_codes (id, code, max_uses) values ($1, 'PROMO', 1)`,
[randomUUID()],
);
const results = await Promise.all([
repository.redeem('alice@example.test', 'promo'),
repository.redeem('bob@example.test', 'PROMO'),
]);
expect(results.filter((result) => result.success)).toHaveLength(1);
expect((await pool.query('select count(*)::int as count from code_redemptions')).rows[0].count).toBe(1);
expect((await pool.query('select count(*)::int as count from profiles where is_premium')).rows[0].count).toBe(1);
expect((await pool.query('select current_uses from redemption_codes')).rows[0].current_uses).toBe(1);
});
it('enforces the database-backed redemption ceiling before code lookup', async () => {
await pool.query(
`insert into redemption_rate_limits (bucket, attempts) values (date_trunc('minute', now()), 60)`,
);
await expect(repository.redeem('alice@example.test', 'ANYTHING')).rejects.toMatchObject({
code: 'redemption_rate_limited',
status: 429,
});
expect((await pool.query('select count(*)::int as count from code_redemptions')).rows[0].count).toBe(0);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { BillingRepository } from '../src/domains/billing/repository.js';
import { registerBillingRoutes } from '../src/domains/billing/routes.js';
import { BillingService } from '../src/domains/billing/service.js';
import { BillingError, type RevenueCatWebhook } from '../src/domains/billing/types.js';
const payload: RevenueCatWebhook = {
api_version: '1.0',
event: {
type: 'INITIAL_PURCHASE',
id: 'event-1',
app_user_id: '11111111-1111-4111-8111-111111111111',
original_app_user_id: '11111111-1111-4111-8111-111111111111',
product_id: 'sauci.pro.monthly',
entitlement_ids: ['Sauci Pro'],
purchased_at_ms: 1_787_824_800_000,
expiration_at_ms: 1_790_503_200_000,
original_transaction_id: 'transaction-1',
store: 'APP_STORE',
environment: 'SANDBOX',
},
};
function repository(): BillingRepository {
return {
processRevenueCatEvent: vi.fn(async () => ({ duplicate: false, handled: true })),
redeem: vi.fn(async () => ({ success: true as const, message: 'redeemed' })),
close: vi.fn(async () => undefined),
};
}
function appWith(service: BillingService) {
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
registerBillingRoutes(app, service);
return app;
}
describe('billing routes', () => {
it('fails closed when the webhook secret is missing or incorrect', async () => {
const repo = repository();
const missing = await appWith(new BillingService(repo, undefined)).request('/webhooks/revenuecat', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload),
});
expect(missing.status).toBe(503);
const wrong = await appWith(new BillingService(repo, 'correct')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer wrong' },
body: JSON.stringify(payload),
});
expect(wrong.status).toBe(401);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
});
it('rejects malformed webhook payloads before persistence', async () => {
const repo = repository();
const response = await appWith(new BillingService(repo, 'secret')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: JSON.stringify({ event: { id: 'missing-fields' } }),
});
expect(response.status).toBe(400);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
});
it('rejects webhook authorization before attempting to parse JSON', async () => {
const repo = repository();
const response = await appWith(new BillingService(repo, 'secret')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer wrong' },
body: '{not-json',
});
expect(response.status).toBe(401);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
});
it('bounds webhook and public redemption bodies', async () => {
const repo = repository();
const app = appWith(new BillingService(repo, 'secret'));
const webhook = await app.request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: JSON.stringify({ padding: 'x'.repeat(1024 * 1024) }),
});
expect(webhook.status).toBe(413);
const redemption = await app.request('/public/v1/redemptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'alice@example.test', code: 'x'.repeat(16 * 1024) }),
});
expect(redemption.status).toBe(413);
expect(repo.processRevenueCatEvent).not.toHaveBeenCalled();
expect(repo.redeem).not.toHaveBeenCalled();
});
it('maps known events and acknowledges authenticated delivery', async () => {
const repo = repository();
const response = await appWith(new BillingService(repo, 'secret')).request('/webhooks/revenuecat', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
expect(repo.processRevenueCatEvent).toHaveBeenCalledWith(payload, 'active');
});
it('validates public redemptions and preserves product error responses', async () => {
const repo = repository();
const app = appWith(new BillingService(repo, 'secret'));
expect((await app.request('/public/v1/redemptions', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: '', code: '' }),
})).status).toBe(400);
vi.mocked(repo.redeem).mockResolvedValueOnce({ success: false, error: 'Invalid redemption code' });
const response = await app.request('/public/v1/redemptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: ' alice@example.test ', code: ' promo ' }),
});
expect(response.status).toBe(400);
expect(repo.redeem).toHaveBeenCalledWith('alice@example.test', 'promo');
});
it('returns a bounded-throttle response without leaking internals', async () => {
const repo = repository();
vi.mocked(repo.redeem).mockRejectedValueOnce(
new BillingError('redemption_rate_limited', 'Too many redemption attempts. Please try again shortly.', 429),
);
const response = await appWith(new BillingService(repo, 'secret')).request('/public/v1/redemptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'alice@example.test', code: 'PROMO' }),
});
expect(response.status).toBe(429);
await expect(response.json()).resolves.toEqual({
success: false,
error: 'Too many redemption attempts. Please try again shortly.',
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ChatError, PostgresChatRepository } from '../src/domains/chat/repository.js';
const databaseUrl = process.env.DATABASE_URL;
const isLocal = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
if (databaseUrl && !isLocal) throw new Error('Chat integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `chat_test_${randomUUID().replaceAll('-', '')}`;
const alice = '11111111-1111-4111-8111-111111111111';
const bob = '22222222-2222-4222-8222-222222222222';
const outsider = '33333333-3333-4333-8333-333333333333';
const couple = '44444444-4444-4444-8444-444444444444';
const otherCouple = '55555555-5555-4555-8555-555555555555';
const category = '66666666-6666-4666-8666-666666666666';
const pack = '77777777-7777-4777-8777-777777777777';
const question = '88888888-8888-4888-8888-888888888888';
const match = '99999999-9999-4999-8999-999999999999';
let pool: Pool;
let repository: PostgresChatRepository;
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 PostgresChatRepository(isolatedUrl.toString());
for (const migrationName of ['0000_identity_and_feature_interests.sql', '0001_couples.sql', '0002_packs_catalog_progress.sql', '0003_answers_matches.sql', '0004_chat.sql', '0005_profile_settings.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 couples (id, invite_code) values ($1, $2), ($3, $4)', [couple, 'CHATTEST', otherCouple, 'CHATOUTS']);
await pool.query('insert into profiles (id, couple_id) values ($1, $3), ($2, $3), ($4, $5)', [alice, bob, couple, outsider, otherCouple]);
await pool.query("insert into categories (id, name) values ($1, 'Chat')", [category]);
await pool.query("insert into question_packs (id, name, category_id) values ($1, 'Chat', $2)", [pack, category]);
await pool.query("insert into questions (id, pack_id, text) values ($1, $2, 'Talk')", [question, pack]);
await pool.query("insert into matches (id, couple_id, question_id, match_type) values ($1, $2, $3, 'yes_yes')", [match, couple, question]);
});
afterAll(async () => {
await repository.close(); await pool.end();
await adminPool.query(`drop schema "${schema}" cascade`); await adminPool.end();
});
it('sends, lists, counts and reads text messages for couple members only', async () => {
const sent = await repository.sendText(alice, match, 'Hello');
expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello');
expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
await expect(repository.listMessages(outsider, match)).rejects.toMatchObject({ code: 'match_not_found' });
});
it('enforces per-user and author-only deletions plus reports', async () => {
const sent = await repository.sendText(alice, match, 'Moderate me');
await repository.deleteForSelf(bob, sent.id);
expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false);
expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true);
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
await repository.report(bob, sent.id, 'harassment');
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
});
it('stores typing state with a short expiry and exposes only the partner state', async () => {
await repository.setTyping(alice, match, 4_000);
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
expect((await repository.getPartnerTyping(alice, match)).typing).toBe(false);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { ChatRepository } from '../src/domains/chat/repository.js';
import { registerChatRoutes } from '../src/domains/chat/routes.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const matchId = '22222222-2222-4222-8222-222222222222';
const messageId = '33333333-3333-4333-8333-333333333333';
const message = { id: messageId, match_id: matchId, user_id: identity.id, content: 'hello', created_at: new Date().toISOString(), read_at: null, delivered_at: null, deleted_at: null, media_path: null, media_type: null, media_expires_at: null, media_expired: false, media_viewed_at: null, version: 1, encrypted_content: null, encryption_iv: null, keys_metadata: null, moderation_status: null, flag_reason: null, category: null };
function setup() {
const repository: ChatRepository = {
listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message),
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
deleteForEveryone: vi.fn(async () => ({ ...message, deleted_at: new Date().toISOString() })),
report: vi.fn(async () => undefined), setTyping: vi.fn(async () => undefined),
getPartnerTyping: vi.fn(async () => ({ typing: true, expires_at: new Date().toISOString() })),
close: vi.fn(async () => undefined),
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerChatRoutes(app, repository);
return { app, repository };
}
describe('chat routes', () => {
it('derives sender and read ownership from the bearer identity', async () => {
const { app, repository } = setup();
const sent = await app.request(`/v1/matches/${matchId}/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: ' hello ' }) });
expect(sent.status).toBe(201);
expect(repository.sendText).toHaveBeenCalledWith(identity.id, matchId, 'hello');
await app.request(`/v1/matches/${matchId}/read`, { method: 'POST' });
expect(repository.markMatchRead).toHaveBeenCalledWith(identity.id, matchId);
});
it('rejects client-supplied ownership fields', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/matches/${matchId}/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'hello', user_id: 'attacker' }) });
expect(response.status).toBe(400);
expect(repository.sendText).not.toHaveBeenCalled();
});
it('supports unread, typing, deletion and reporting actions', async () => {
const { app, repository } = setup();
expect((await app.request('/v1/chat/unread')).status).toBe(200);
await app.request(`/v1/matches/${matchId}/typing`, { method: 'PUT' });
await app.request(`/v1/messages/${messageId}?scope=self`, { method: 'DELETE' });
await app.request(`/v1/messages/${messageId}/reports`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason: 'spam' }) });
expect(repository.setTyping).toHaveBeenCalledWith(identity.id, matchId, 4_000);
expect(repository.deleteForSelf).toHaveBeenCalledWith(identity.id, messageId);
expect(repository.report).toHaveBeenCalledWith(identity.id, messageId, 'spam');
});
});
@@ -0,0 +1,64 @@
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']) {
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 });
await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null });
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { CoupleRepository } from '../src/domains/couples/repository.js';
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
import { CoupleService } from '../src/domains/couples/service.js';
const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
email: 'alice@sauci.test',
name: 'Alice',
avatarUrl: null,
};
function setup() {
const repository: CoupleRepository = {
getState: vi.fn(async () => ({ couple: null, partner: null })),
create: vi.fn(async (_userId, coupleId, inviteCode) => ({
id: coupleId,
invite_code: inviteCode,
created_at: '2026-08-27T00:00:00.000Z',
})),
join: vi.fn(async (_userId, inviteCode) => ({
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
invite_code: inviteCode,
created_at: '2026-08-27T00:00:00.000Z',
})),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => {
c.set('identity', identity);
await next();
});
registerCoupleRoutes(app, new CoupleService(repository));
return { app, repository };
}
describe('couple routes', () => {
it('reads only the authenticated user pairing state', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/couple');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ couple: null, partner: null });
expect(repository.getState).toHaveBeenCalledWith(identity.id);
});
it('creates with an empty body and joins with a canonical invite code', async () => {
const { app, repository } = setup();
const created = await app.request('/v1/couple', { method: 'POST', body: '{}' });
expect(created.status).toBe(201);
expect(repository.create).toHaveBeenCalledWith(identity.id, expect.any(String), expect.stringMatching(/^[A-Z0-9]{8}$/));
const joined = await app.request('/v1/couple', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invite_code: 'abcd2345' }),
});
expect(joined.status).toBe(200);
expect(repository.join).toHaveBeenCalledWith(identity.id, 'ABCD2345');
});
it('rejects extra ownership fields instead of trusting the client', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/couple', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invite_code: 'ABCD2345', user_id: 'someone-else' }),
});
expect(response.status).toBe(400);
expect(repository.join).not.toHaveBeenCalled();
});
it('cancels the authenticated user pairing', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/couple', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
});
});
+64
View File
@@ -0,0 +1,64 @@
import type { Couple } from '@sauci/shared';
import { describe, expect, it, vi } from 'vitest';
import type { CoupleRepository } from '../src/domains/couples/repository.js';
import { CoupleService, generateInviteCode } from '../src/domains/couples/service.js';
import { CoupleError } from '../src/domains/couples/types.js';
const couple: Couple = {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
invite_code: 'ABCD2345',
created_at: '2026-08-27T00:00:00.000Z',
};
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
return {
getState: vi.fn(async () => ({ couple: null, partner: null })),
create: vi.fn(async () => couple),
join: vi.fn(async () => couple),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
...overrides,
};
}
describe('CoupleService', () => {
it('generates an eight-character unambiguous uppercase invite code', () => {
expect(generateInviteCode(Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7]))).toBe('ABCDEFGH');
});
it('canonicalizes join codes and never accepts a caller-supplied owner', async () => {
const repo = repository();
const service = new CoupleService(repo);
await expect(service.join('user-one', ' abcd2345 ')).resolves.toEqual({
success: true,
couple_id: couple.id,
});
expect(repo.join).toHaveBeenCalledWith('user-one', 'ABCD2345');
});
it('rejects malformed invite codes before repository access', async () => {
const repo = repository();
const service = new CoupleService(repo);
await expect(service.join('user-one', 'short')).rejects.toMatchObject({
code: 'invalid_invite_code',
status: 400,
});
expect(repo.join).not.toHaveBeenCalled();
});
it('retries an invite-code collision', async () => {
const create = vi.fn()
.mockRejectedValueOnce(new CoupleError('invite_code_collision', 'collision', 409))
.mockResolvedValueOnce(couple);
const service = new CoupleService(repository({ create }));
await expect(service.create('user-one')).resolves.toMatchObject({
success: true,
couple_id: couple.id,
});
expect(create).toHaveBeenCalledTimes(2);
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PostgresMediaRepository } from '../src/domains/media/repository.js';
import { PostgresProfileSettingsRepository } from '../src/domains/profile-settings/repository.js';
const databaseUrl=process.env.DATABASE_URL;
const local=databaseUrl ? ['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname) : false;
if(databaseUrl&&!local) throw new Error('Media integration tests only permit localhost DATABASE_URL');
describe.skipIf(!databaseUrl||!local)('media repository + PostgreSQL',()=>{
const admin=new Pool({connectionString:databaseUrl}); const schema=`media_test_${randomUUID().replaceAll('-','')}`;
const alice='11111111-1111-4111-8111-111111111111',bob='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333';
const couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',category='66666666-6666-4666-8666-666666666666',pack='77777777-7777-4777-8777-777777777777',question='88888888-8888-4888-8888-888888888888',match='99999999-9999-4999-8999-999999999999';
let pool:Pool,repository:PostgresMediaRepository;
beforeAll(async()=>{
await admin.query(`create schema "${schema}"`); const isolated=new URL(databaseUrl!); isolated.searchParams.set('options',`-c search_path=${schema}`);
pool=new Pool({connectionString:isolated.toString()}); repository=new PostgresMediaRepository(isolated.toString());
for(const migrationName of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.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 couples(id,invite_code) values($1,$2),($3,$4)',[couple,'MEDIATST',other,'MEDIAOUT']);
await pool.query('insert into profiles(id,couple_id) values($1,$3),($2,$3),($4,$5)',[alice,bob,couple,outsider,other]);
await pool.query("insert into categories(id,name) values($1,'Media')",[category]); await pool.query("insert into question_packs(id,name,category_id) values($1,'Media',$2)",[pack,category]);
await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Share')",[question,pack]); await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[match,couple,question]);
});
afterAll(async()=>{await repository.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('allows only the owner and current partner to resolve response media',async()=>{
const {media}=await repository.create(alice,'response','response/2026-01-01/random.jpg','image/jpeg',10,{questionId:question});
expect((await repository.accessible(bob,media.id)).id).toBe(media.id);
await expect(repository.accessible(outsider,media.id)).rejects.toMatchObject({code:'media_not_found'});
});
it('creates chat metadata and its message together after match authorization',async()=>{
const created=await repository.create(alice,'chat','chat/2026-01-01/random.jpg','image/jpeg',10,{matchId:match});
expect(created.message).toMatchObject({match_id:match,user_id:alice,media_path:`media:${created.media.id}`});
await expect(repository.create(outsider,'chat','chat/2026-01-01/no.jpg','image/jpeg',10,{matchId:match})).rejects.toMatchObject({code:'match_not_found'});
});
it('keeps feedback screenshots owner-private and links them to feedback',async()=>{
const {media}=await repository.create(alice,'feedback','feedback/2026-01-01/private.png','image/png',10,{});
await expect(repository.accessible(bob,media.id)).rejects.toMatchObject({code:'media_not_found'});
await expect(repository.accessible(outsider,media.id)).rejects.toMatchObject({code:'media_not_found'});
const profiles=new PostgresProfileSettingsRepository((repository as any).pool.options.connectionString);
const feedback=await profiles.submitFeedback(alice,{type:'bug',title:'Screenshot',description:'Private',screenshot_media_id:media.id});
const linked=await pool.query('select screenshot_media_id from feedback where id=$1',[feedback.id]);
expect(linked.rows[0].screenshot_media_id).toBe(media.id); await profiles.close();
});
it('queues replaced avatars and cascaded account media for retryable deletion',async()=>{
await repository.create(alice,'avatar','avatar/2026-01-01/old.jpg','image/jpeg',10,{});
await repository.create(alice,'avatar','avatar/2026-01-01/new.jpg','image/jpeg',10,{});
expect(await repository.queuedDeletions(10)).toContain('avatar/2026-01-01/old.jpg');
await pool.query('delete from profiles where id=$1',[alice]);
expect(await repository.queuedDeletions(10)).toContain('avatar/2026-01-01/new.jpg');
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { MediaJanitor } from '../src/domains/media/janitor.js';
import type { MediaRepository } from '../src/domains/media/repository.js';
import type { FilesystemMediaStorage } from '../src/domains/media/storage.js';
describe('MediaJanitor',()=>{
it('acknowledges only blobs that were removed or already absent',async()=>{
const repository={queuedDeletions:vi.fn(async()=>['old/a.jpg','old/b.jpg']),acknowledgeDeletion:vi.fn()} as unknown as MediaRepository;
const storage={remove:vi.fn(async(key:string)=>{if(key.endsWith('b.jpg')) throw new Error('disk unavailable');})} as unknown as FilesystemMediaStorage;
expect(await new MediaJanitor(repository,storage).runOnce()).toBe(1);
expect(repository.acknowledgeDeletion).toHaveBeenCalledWith('old/a.jpg');
expect(repository.acknowledgeDeletion).not.toHaveBeenCalledWith('old/b.jpg');
});
});
+29
View File
@@ -0,0 +1,29 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import { MediaError, type MediaRepository } from '../src/domains/media/repository.js';
import { registerMediaRoutes } from '../src/domains/media/routes.js';
import type { FilesystemMediaStorage } from '../src/domains/media/storage.js';
const identity: AuthIdentity={id:'11111111-1111-4111-8111-111111111111',email:'u@example.com',name:null,avatarUrl:null};
function app(repository: Partial<MediaRepository>, storage: Partial<FilesystemMediaStorage>) {
const api=new Hono<{Variables:{identity:AuthIdentity}}>(); api.use('*',async(c,next)=>{c.set('identity',identity);await next();});
registerMediaRoutes(api,repository as MediaRepository,storage as FilesystemMediaStorage); return api;
}
describe('media routes',()=>{
it('requires a supported content type before reading the body',async()=>{
const api=app({}, {validate:vi.fn(()=>{throw new Error('unsupported_media_type');})});
const response=await api.request('/v1/media/avatar',{method:'POST',headers:{'content-type':'text/html'},body:'bad'});
expect(response.status).toBe(415);
});
it('does not issue a URL when repository authorization fails',async()=>{
const signedUrl=vi.fn(); const api=app({accessible:vi.fn(async()=>{throw new MediaError('media_not_found','Media not found',404);})},{signedUrl});
const response=await api.request('/v1/media/22222222-2222-4222-8222-222222222222/url');
expect(response.status).toBe(404); expect(signedUrl).not.toHaveBeenCalled();
});
it('rejects unsigned content reads',async()=>{
const api=app({}, {verify:vi.fn(()=>false)});
const response=await api.request('/media/22222222-2222-4222-8222-222222222222/content?expires=1&signature=no');
expect(response.status).toBe(403);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { FilesystemMediaStorage } from '../src/domains/media/storage.js';
const roots: string[] = [];
async function storage() {
const root = await mkdtemp(join(tmpdir(), 'sauci-media-test-')); roots.push(root);
return new FilesystemMediaStorage(root, 'a-secure-test-secret-with-at-least-32-characters', 'https://api.test');
}
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
describe('FilesystemMediaStorage', () => {
it('stores magic-byte validated content under a server-generated path', async () => {
const subject=await storage(); const png=Uint8Array.from([137,80,78,71,13,10,26,10,1]);
const saved=await subject.put('avatar','image/png',png);
expect(saved.key).toMatch(/^avatar\/\d{4}-\d{2}-\d{2}\/[0-9a-f-]+\.png$/);
expect(await subject.read(saved.key)).toEqual(Buffer.from(png));
});
it('rejects mismatched content and oversized declared uploads', async () => {
const subject=await storage();
await expect(subject.put('avatar','image/png',Uint8Array.from([1,2,3,4]))).rejects.toThrow('media_signature_mismatch');
expect(() => subject.validate('avatar','image/png',11*1024*1024)).toThrow('upload_too_large');
expect(() => subject.validate('avatar','text/html',10)).toThrow('unsupported_media_type');
});
it('issues expiring tamper-resistant capability URLs', async () => {
const subject=await storage(); const id='b99173de-07ee-43d2-970e-27c8075f4433';
const signed=subject.signedUrl(id,60); const url=new URL(signed.url);
const expires=Number(url.searchParams.get('expires')); const signature=url.searchParams.get('signature')!;
expect(subject.verify(id,expires,signature)).toBe(true);
expect(subject.verify('c99173de-07ee-43d2-970e-27c8075f4433',expires,signature)).toBe(false);
});
});
@@ -0,0 +1,38 @@
import { randomUUID } from 'node:crypto';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { migrateDatabase, relationalParity } from '../src/migration/database.js';
import { migrateStorage, fileDigest } from '../src/migration/storage.js';
import type { MigrationCheckpoint } from '../src/migration/types.js';
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;if(databaseUrl&&!local)throw new Error('Migration integration tests only permit localhost');
describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{
const admin=new Pool({connectionString:databaseUrl});const suffix=randomUUID().replaceAll('-','');const sourceSchema=`migration_source_${suffix}`;const targetSchema=`migration_target_${suffix}`;const storageSchema=`migration_storage_${suffix}`;let source:Pool;let target:Pool;let mediaRoot:string;
const couple='11111111-1111-4111-8111-111111111111',user='22222222-2222-4222-8222-222222222222',pack='33333333-3333-4333-8333-333333333333',question='44444444-4444-4444-8444-444444444444',response='55555555-5555-4555-8555-555555555555',object='66666666-6666-4666-8666-666666666666';
const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()});
beforeAll(async()=>{
await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`);
const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()});
const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()});
for(let index=0;index<=12;index++){const files=(await import('node:fs/promises')).readdir(new URL('../drizzle',import.meta.url));const name=(await files).find(file=>file.startsWith(`${String(index).padStart(4,'0')}_`));if(!name)continue;const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await target.query(statement);}
await source.query(`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);`);
await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]);
await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]);
await source.query('alter table profiles add column avatar_url text');
await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]);
await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]);
await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]);
await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]);
await admin.query(`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`);
await admin.query(`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,[object,`${user}/avatar.png`,user,{size:4,mimetype:'image/png'}]);
mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-'));
});
afterAll(async()=>{await Promise.all([source?.end(),target?.end()]);await admin.query(`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`);await admin.end();if(mediaRoot)await rm(mediaRoot,{recursive:true,force:true});});
it('preserves IDs, precision, catalogue fields, timestamps, side-effect suppression, and FK parity',async()=>{const state=checkpoint();const result=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(result.find(row=>row.table==='responses')).toMatchObject({sourceRows:1,targetRows:1,status:'equal'});const row=await target.query('select id,created_at from responses');expect(row.rows[0].id).toBe(response);expect(new Date(row.rows[0].created_at).toISOString()).toBe('2024-01-06T00:00:00.000Z');expect((await target.query('select avg_intensity from question_packs where id=$1',[pack])).rows[0].avg_intensity).toBe('3.36');expect((await target.query('select required_props from questions where id=$1',[question])).rows[0].required_props).toEqual(['blindfold']);expect((await target.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(0);expect(await relationalParity(target)).toEqual({profiles_without_couple:0,responses_with_missing_owner:0,matches_with_missing_relation:0,messages_with_missing_relation:0,couple_mismatch_responses:0});});
it('dry-run reads and reports without changing the target',async()=>{await source.query("update profiles set name='Changed at source' where id=$1",[user]);await migrateDatabase(source,target,{dryRun:true,finalSync:false,prune:false,checkpoint:checkpoint()});expect((await target.query('select name from profiles where id=$1',[user])).rows[0].name).toBe('Alice');await source.query("update profiles set name='Alice' where id=$1",[user]);});
it('is resumable and a stopped-source prune restores exact row parity',async()=>{const state=checkpoint();state.completedTables=['couples'];await target.query(`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`);const resumed=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(resumed.find(row=>row.table==='couples')?.note).toBe('resumed from checkpoint');const final=await migrateDatabase(source,target,{dryRun:false,finalSync:true,prune:true,checkpoint:checkpoint()});expect(final.find(row=>row.table==='couples')).toMatchObject({sourceRows:1,targetRows:1,prunedRows:1,status:'equal'});});
it('copies storage atomically, rewrites legacy references, verifies checkpoints, and prunes source-deleted files',async()=>{const state=checkpoint();const first=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>new Uint8Array([1,2,3,4])});expect(first).toMatchObject({sourceFiles:1,targetFiles:1,copiedFiles:1,failedFiles:0,missingOnDisk:[]});const path=join(mediaRoot,'avatars',user,'avatar.png');expect(await fileDigest(path)).toBe('9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a');expect((await target.query('select owner_id,kind,byte_size from media_objects where id=$1',[object])).rows[0]).toMatchObject({owner_id:user,kind:'avatar',byte_size:4});expect((await target.query('select avatar_url from profiles where id=$1',[user])).rows[0].avatar_url).toBe(`media:${object}`);const resumed=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(resumed).toMatchObject({copiedFiles:0,skippedFiles:1,failedFiles:0});const stale='77777777-7777-4777-8777-777777777777';const staleKey=`avatars/${user}/stale.png`;await target.query("insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",[stale,user,couple,staleKey]);await writeFile(join(mediaRoot,staleKey),new Uint8Array([9]));const pruned=await migrateStorage(source,target,{dryRun:false,prune:true,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(pruned).toMatchObject({sourceFiles:1,targetFiles:1,prunedFiles:1,skippedFiles:1,failedFiles:0});});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { redactDatabaseUrl, validateEndpoints } from '../src/migration/safety.js';
describe('migration endpoint safety', () => {
it('requires explicit source and target URLs', () => {
expect(() => validateEndpoints(undefined, 'postgres://x:y@localhost/target')).toThrow('SOURCE_DATABASE_URL is required');
expect(() => validateEndpoints('postgres://x:y@localhost/source', undefined)).toThrow('TARGET_DATABASE_URL is required');
});
it('rejects the same physical database even when query options differ', () => {
expect(() => validateEndpoints('postgres://x:y@localhost/sauci?options=source', 'postgres://a:b@localhost/sauci?options=target')).toThrow('must be different');
});
it('allows local targets and requires an explicit remote target allowlist', () => {
expect(validateEndpoints('postgres://x:y@source.example/source', 'postgres://a:b@127.0.0.1/target').target).toContain('127.0.0.1');
expect(() => validateEndpoints('postgres://x:y@source.example/source', 'postgres://a:b@db.example/target')).toThrow('not allowlisted');
expect(validateEndpoints('postgres://x:y@source.example/source', 'postgres://a:b@db.example/target', 'db.example').target).toContain('db.example');
});
it('redacts database credentials', () => {
const redacted = redactDatabaseUrl('postgres://secret-user:secret-password@db.example/sauci');
expect(redacted).not.toContain('secret-user'); expect(redacted).not.toContain('secret-password');
});
});
@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll,beforeAll,describe,expect,it } from 'vitest';
import { PostgresOperationsRepository } from '../src/domains/operations/repository.js';
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;
if(databaseUrl&&!local)throw new Error('Operations integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
const admin=new Pool({connectionString:databaseUrl});const schema=`operations_test_${randomUUID().replaceAll('-','')}`;
const alice='11111111-1111-4111-8111-111111111111',bob='22222222-2222-4222-8222-222222222222',couple='33333333-3333-4333-8333-333333333333';
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
beforeAll(async()=>{
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql']){
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'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)',[couple,'ABCD2345']);
await pool.query(`insert into profiles(id,name,email,couple_id,push_token,onboarding_completed) values($1,'Alice','alice@test',$3,'token-a',false),($2,'Bob','bob@test',$3,'token-b',false)`,[alice,bob,couple]);
await pool.query(`insert into question_packs(id,name,is_public,scheduled_release_at) values($1,'Coming soon',false,now()-interval '1 minute')`,[pack]);
await pool.query('insert into questions(id,pack_id,text) values($1,$2,$3)',[question,pack,'Question']);
});
afterAll(async()=>{await Promise.all([repo.close(),repo2.close()]);await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('queues message delivery and classification once at the write boundary',async()=>{
const response=randomUUID(),match=randomUUID(),message=randomUUID();
await pool.query(`insert into responses(id,user_id,question_id,couple_id,answer) values($1,$2,$3,$4,'yes')`,[response,alice,question,couple]);
await pool.query(`insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')`,[match,couple,question]);
await pool.query(`insert into messages(id,match_id,user_id,content) values($1,$2,$3,'hello')`,[message,match,alice]);
const rows=await pool.query(`select kind,dedupe_key,recipient_id from operations_outbox where dedupe_key like $1 order by kind`,[`%${message}%`]);
expect(rows.rows).toEqual([{kind:'classify',dedupe_key:`classify:${message}`,recipient_id:null},{kind:'expo',dedupe_key:`message:${message}:${bob}`,recipient_id:bob}]);
});
it('loads live classifier settings from the self-hosted database',async()=>{
await pool.query("insert into ai_config(classifier_enabled,classifier_model) values(false,'test/live-model')");
await expect(repo.classifierConfig()).resolves.toMatchObject({enabled:false,model:'test/live-model'});
});
it('suppresses side effects during historical cutover imports',async()=>{
const match=(await pool.query<{id:string}>('select id from matches limit 1')).rows[0]!.id;const message=randomUUID();
const client=await pool.connect();try{await client.query('begin');await client.query("set local sauci.suppress_operations='on'");
await client.query(`insert into messages(id,match_id,user_id,content) values($1,$2,$3,'historical')`,[message,match,alice]);await client.query('commit');
}finally{client.release();}
expect((await pool.query('select count(*)::int count from operations_outbox where dedupe_key like $1',[`%${message}%`])).rows[0].count).toBe(0);
});
it('reminds an invite creator without reporting a couple as paired prematurely',async()=>{
const inviteCouple=randomUUID(),creator=randomUUID(),partner=randomUUID();
await pool.query('insert into couples(id,invite_code) values($1,$2)',[inviteCouple,'WAIT2345']);
await pool.query(`insert into profiles(id,name,email,couple_id,push_token,onboarding_completed) values($1,'Creator','creator@test',$2,'token-creator',false)`,[creator,inviteCouple]);
expect((await pool.query('select count(*)::int count from operations_outbox where dedupe_key=$1',[`discord:couple_paired:${inviteCouple}`])).rows[0].count).toBe(0);
const summary=await repo.produce(new Date('2026-08-27T18:00:00.000Z'),100);
expect(summary.unpairedReminders).toBeGreaterThanOrEqual(1);
expect((await pool.query<{body:string}>(`select payload->>'body' body from operations_outbox where dedupe_key=$1`,[`unpaired:2026-08-27:${creator}`])).rows[0]?.body).toContain('invite code is waiting');
await pool.query(`insert into profiles(id,name,email,push_token,onboarding_completed) values($1,'Partner','partner@test','token-partner',false)`,[partner]);
await pool.query('update profiles set couple_id=$1 where id=$2',[inviteCouple,partner]);
expect((await pool.query('select count(*)::int count from operations_outbox where dedupe_key=$1',[`discord:couple_paired:${inviteCouple}`])).rows[0].count).toBe(1);
});
it('atomically releases packs, celebrates milestones, and produces a single digest',async()=>{
await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,streak_celebrated_at) values($1,7,7,0) on conflict(couple_id) do update set current_streak=7,streak_celebrated_at=0`,[couple]);
const result=await repo.produce(new Date(Date.now()+10*60*1000),100);
expect(result.releasedPacks).toBe(1);expect(result.streakMilestones).toBe(1);expect(result.digests).toBe(1);
expect((await pool.query('select is_public,release_notified from question_packs where id=$1',[pack])).rows[0]).toEqual({is_public:true,release_notified:true});
const before=(await pool.query('select count(*)::int count from operations_outbox')).rows[0].count;
await repo.produce(new Date(Date.now()+10*60*1000),100);expect((await pool.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(before);
});
it('uses skip-locked claims so concurrent workers never receive the same item',async()=>{
await pool.query('insert into notification_preferences(user_id,messages_enabled) values($1,false) on conflict(user_id) do update set messages_enabled=false',[bob]);
const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0);
expect([...a,...b].find(item=>item.dedupeKey.startsWith('message:'))?.pushToken).toBeNull();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import type { OperationsRepository } from '../src/domains/operations/repository.js';
import { ExpoPushProvider, MessageClassifier } from '../src/domains/operations/providers.js';
import { OperationsRunner } from '../src/domains/operations/runner.js';
import type { OperationItem } from '../src/domains/operations/types.js';
import { loadWorkerConfig } from '../src/workers/config.js';
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
function repository(items:OperationItem[]):OperationsRepository {
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
}
describe('OperationsRunner',()=>{
it('completes successful work and schedules bounded retry for provider failures',async()=>{
const items:OperationItem[]=[
{id:'1',kind:'expo',dedupeKey:'one',recipientId:'u',pushToken:'token',payload:{title:'T',body:'B'},attempts:1},
{id:'2',kind:'discord',dedupeKey:'two',recipientId:null,pushToken:null,payload:{event:'new_user'},attempts:1},
];
const repo=repository(items);const expo={send:vi.fn(async()=>undefined)};const discord={send:vi.fn(async()=>{throw new Error('offline')})};
const classifier={classify:vi.fn(async()=>undefined)};const media={runOnce:vi.fn(async()=>2)};
const runner=new OperationsRunner(repo,expo as never,discord as never,classifier as never,media as never);
await expect(runner.runOnce(new Date(),2)).resolves.toMatchObject({claimed:2,completed:1,failed:1});
expect(repo.complete).toHaveBeenCalledWith('1');expect(repo.fail).toHaveBeenCalledWith('2','offline');expect(media.runOnce).toHaveBeenCalledWith(100);
});
it('treats Expo ticket-level errors as failures and sends a stable notification id',async()=>{
const request=vi.fn<typeof fetch>(async()=>Response.json({data:{status:'error',message:'DeviceNotRegistered'}}));
const provider=new ExpoPushProvider(request);const item:OperationItem={id:'notification-id',kind:'expo',dedupeKey:'k',recipientId:'u',pushToken:'ExponentPushToken[x]',payload:{title:'Title',body:'Body',data:{type:'message'}},attempts:1};
await expect(provider.send(item)).rejects.toThrow('expo_ticket_DeviceNotRegistered');
expect(JSON.parse(String(vi.mocked(request).mock.calls[0]?.[1]?.body))).toMatchObject({data:{type:'message',notification_id:'notification-id'}});
});
it('classifies plaintext through a mocked provider and persists only normalized outcomes',async()=>{
const repo=repository([]);vi.mocked(repo.message).mockResolvedValueOnce({id:'m1',version:1,content:'I may kill myself',encryptedContent:null,encryptionIv:null,keysMetadata:null,mediaPath:null,mediaType:null,mediaStorageKey:null,mediaMimeType:null});
vi.mocked(repo.classifierConfig).mockResolvedValueOnce({enabled:true,apiKey:'saved-key',model:'saved-model',temperature:0.2,prompt:'saved prompt',heuristicsEnabled:false,
heuristicMinTextLength:12,heuristicWhitelistMaxLength:30,heuristicSkipIfNoAlnum:true,heuristicSkipMediaWithoutText:false,
heuristicUseDefaultWhitelist:true,heuristicUseDefaultKeywords:true,heuristicWhitelist:null,heuristicKeywordTriggers:null});
const request=vi.fn<typeof fetch>(async()=>Response.json({choices:[{message:{content:JSON.stringify({status:'flagged',reason:'self harm',category:'Safety'})}}]}));
const classifier=new MessageClassifier(repo,{enabled:true,apiKey:'test-key',model:'test-model',prompt:'test prompt',mediaRoot:'/tmp'},request);
await classifier.classify({id:'job',kind:'classify',dedupeKey:'classify:m1',recipientId:null,pushToken:null,payload:{message_id:'m1'},attempts:1});
expect(request).toHaveBeenCalledWith('https://openrouter.ai/api/v1/chat/completions',expect.objectContaining({method:'POST'}));
const requestBody=JSON.parse(String(vi.mocked(request).mock.calls[0]?.[1]?.body));
expect(requestBody).toMatchObject({model:'saved-model',temperature:0.2});
expect(requestBody.messages[0]).toMatchObject({role:'system',content:'saved prompt'});
expect(repo.classify).toHaveBeenCalledWith('m1','flagged','self harm','Safety');
});
});
describe('worker configuration',()=>{
it('fails closed in production when classification has no provider key',()=>{
expect(()=>loadWorkerConfig({
NODE_ENV:'production',
DATABASE_URL:'postgresql://sauci:test@localhost:5432/sauci',
CLASSIFIER_ENABLED:'true',
})).toThrow('OPENROUTER_API_KEY is required when classification is enabled');
});
it('accepts an explicitly disabled classifier and treats empty optional secrets as absent',()=>{
expect(loadWorkerConfig({
NODE_ENV:'production',
DATABASE_URL:'postgresql://sauci:test@localhost:5432/sauci',
CLASSIFIER_ENABLED:'false',
OPENROUTER_API_KEY:'',
ADMIN_PRIVATE_KEY_JWK:'',
DISCORD_WEBHOOK_URL:'',
})).toMatchObject({
classifierEnabled:false,
openRouterApiKey:undefined,
adminPrivateKeyJwk:undefined,
discordWebhookUrl:undefined,
});
});
});
+122
View File
@@ -0,0 +1,122 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PostgresPacksRepository } from '../src/domains/packs/repository.js';
const databaseUrl = process.env.DATABASE_URL;
const isLocal = databaseUrl
? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname)
: false;
if (databaseUrl && !isLocal) throw new Error('Pack integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl || !isLocal)('packs repository + PostgreSQL', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `packs_test_${randomUUID().replaceAll('-', '')}`;
const userId = '11111111-1111-4111-8111-111111111111';
const otherUserId = '22222222-2222-4222-8222-222222222222';
const coupleId = '33333333-3333-4333-8333-333333333333';
const otherCoupleId = '44444444-4444-4444-8444-444444444444';
const categoryId = '55555555-5555-4555-8555-555555555555';
const packId = '66666666-6666-4666-8666-666666666666';
const explicitPackId = '77777777-7777-4777-8777-777777777777';
const questionId = '88888888-8888-4888-8888-888888888888';
let pool: Pool;
let repository: PostgresPacksRepository;
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 PostgresPacksRepository(isolatedUrl.toString());
for (const migrationName of [
'0000_identity_and_feature_interests.sql',
'0001_couples.sql',
'0002_packs_catalog_progress.sql',
'0011_question_pack_average_precision.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(`
create table pack_change_calls (couple_id uuid not null, user_id uuid not null);
create function queue_pack_change(p_couple_id uuid, p_user_id uuid) returns void
language sql as $$
insert into pack_change_calls (couple_id, user_id) values (p_couple_id, p_user_id)
$$;
`);
await pool.query(
'insert into couples (id, invite_code) values ($1, $2), ($3, $4)',
[coupleId, 'PACKTEST', otherCoupleId, 'PACKTST2'],
);
await pool.query(
'insert into profiles (id, couple_id, hide_nsfw) values ($1, $2, true), ($3, $4, false)',
[userId, coupleId, otherUserId, otherCoupleId],
);
await pool.query(
"insert into categories (id, name, sort_order) values ($1, 'Conversation', 1)",
[categoryId],
);
await pool.query(`
insert into question_packs (id, name, category_id, sort_order, max_intensity, avg_intensity) values
($1, 'Starter', $3, 1, 2, 3.36),
($2, 'Explicit', $3, 2, 5, null)
`, [packId, explicitPackId, categoryId]);
await pool.query('update question_packs set is_explicit = true where id = $1', [explicitPackId]);
await pool.query(
"insert into questions (id, pack_id, text) values ($1, $2, 'Question')",
[questionId, packId],
);
await pool.query(`
insert into responses (id, user_id, question_id, couple_id, answer)
values ($1, $2, $3, $4, 'yes')
`, ['99999999-9999-4999-8999-999999999999', userId, questionId, coupleId]);
await pool.query(
'insert into couple_packs (couple_id, pack_id, enabled) values ($1, $2, true)',
[otherCoupleId, packId],
);
});
afterAll(async () => {
await repository.close();
await pool.end();
await adminPool.query(`drop schema "${schema}" cascade`);
await adminPool.end();
});
it('filters explicit catalog content using server-side profile preferences', async () => {
const hidden = await repository.getCatalog(userId, false);
const visible = await repository.getCatalog(otherUserId, true);
expect(hidden.packs.map((pack) => pack.id)).toEqual([packId]);
expect(hidden.packs[0]?.avg_intensity).toBe(3.36);
expect(visible.packs.map((pack) => pack.id)).toEqual([packId, explicitPackId]);
expect(hidden.packs[0]?.questions).toEqual([{ count: 1 }]);
});
it('isolates enabled packs by the token user couple', async () => {
expect(await repository.getEnabledPacks(userId)).toEqual({ enabledPackIds: [] });
expect(await repository.getEnabledPacks(otherUserId)).toEqual({ enabledPackIds: [packId] });
await repository.setPackEnabled(userId, packId, true);
expect(await repository.getEnabledPacks(userId)).toEqual({ enabledPackIds: [packId] });
await repository.setPackEnabled(userId, packId, true);
await repository.setPackEnabled(userId, packId, false);
expect((await pool.query('select * from pack_change_calls')).rows).toEqual([
{ couple_id: coupleId, user_id: userId },
]);
});
it('counts only the token user responses', async () => {
const own = await repository.getPackProgress(userId);
const other = await repository.getPackProgress(otherUserId);
expect(own.progress.find((item) => item.packId === packId)).toMatchObject({
totalQuestions: 1, answeredQuestions: 1,
});
expect(other.progress.find((item) => item.packId === packId)).toMatchObject({
totalQuestions: 1, answeredQuestions: 0,
});
});
});
+93
View File
@@ -0,0 +1,93 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import {
PacksDomainError,
type PacksRepository,
} from '../src/domains/packs/repository.js';
import { registerPackRoutes } from '../src/domains/packs/routes.js';
const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
email: 'alice@sauci.test',
name: 'Alice',
avatarUrl: null,
};
const packId = '22222222-2222-4222-8222-222222222222';
function repository(): PacksRepository {
return {
getCatalog: vi.fn(async () => ({ categories: [], packs: [] })),
getEnabledPacks: vi.fn(async () => ({ enabledPackIds: [packId] })),
setPackEnabled: vi.fn(async () => ({ enabledPackIds: [packId] })),
getPackProgress: vi.fn(async () => ({
progress: [{ packId, totalQuestions: 4, answeredQuestions: 2 }],
})),
close: vi.fn(async () => undefined),
};
}
function appWith(repository: PacksRepository) {
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => {
c.set('identity', identity);
await next();
});
registerPackRoutes(app, repository);
return app;
}
describe('pack routes', () => {
it('derives catalog preferences and progress ownership from the authenticated identity', async () => {
const repo = repository();
const app = appWith(repo);
expect((await app.request('/v1/packs?showAllIntensities=true')).status).toBe(200);
expect(repo.getCatalog).toHaveBeenCalledWith(identity.id, true);
expect(await (await app.request('/v1/me/pack-progress')).json()).toEqual({
progress: [{ packId, totalQuestions: 4, answeredQuestions: 2 }],
});
expect(repo.getPackProgress).toHaveBeenCalledWith(identity.id);
});
it('updates the token owner couple and never accepts a user or couple ID from the client', async () => {
const repo = repository();
const app = appWith(repo);
const response = await app.request(`/v1/me/enabled-packs/${packId}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ enabled: true }),
});
expect(response.status).toBe(200);
expect(repo.setPackEnabled).toHaveBeenCalledWith(identity.id, packId, true);
});
it('rejects malformed pack IDs and request bodies', async () => {
const repo = repository();
const app = appWith(repo);
expect((await app.request('/v1/me/enabled-packs/not-a-uuid', {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: '{"enabled":true}',
})).status).toBe(400);
expect((await app.request(`/v1/me/enabled-packs/${packId}`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: '{"enabled":"yes"}',
})).status).toBe(400);
expect(repo.setPackEnabled).not.toHaveBeenCalled();
});
it('maps couple and visibility failures without leaking another couple', async () => {
const repo = repository();
vi.mocked(repo.setPackEnabled).mockRejectedValueOnce(new PacksDomainError('no_couple'));
const app = appWith(repo);
const response = await app.request(`/v1/me/enabled-packs/${packId}`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: '{"enabled":false}',
});
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
error: { code: 'no_couple', message: 'Join a couple before changing packs' },
});
});
});
@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PostgresProfileSettingsRepository } from '../src/domains/profile-settings/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('Profile settings integration tests only permit a localhost DATABASE_URL');
describe.skipIf(!databaseUrl || !localDatabase)('PostgresProfileSettingsRepository', () => {
const adminPool = new Pool({ connectionString: databaseUrl });
const schema = `profile_settings_test_${randomUUID().replaceAll('-', '')}`;
const userId = '11111111-1111-4111-8111-111111111111';
let pool: Pool;
let repository: PostgresProfileSettingsRepository;
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 PostgresProfileSettingsRepository(isolatedUrl.toString());
for (const migrationName of [
'0000_identity_and_feature_interests.sql',
'0001_couples.sql',
'0002_packs_catalog_progress.sql',
'0003_answers_matches.sql',
'0004_chat.sql',
'0005_profile_settings.sql',
'0006_media_storage.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, $2)', [userId, 'Alice']);
});
afterAll(async () => {
await repository.close();
await pool.end();
await adminPool.query(`DROP SCHEMA "${schema}" CASCADE`);
await adminPool.end();
});
it('persists profile, activity, public key, push token, and onboarding state', async () => {
await repository.updateProfile(userId, {
name: 'Alicia', gender: 'female', usage_reason: 'deeper_connection', hide_nsfw: true,
max_intensity: 2, show_explicit_content: false, onboarding_completed: true,
onboarding_version: 1, public_key_jwk: { kty: 'RSA' }, push_token: 'ExponentPushToken[test]',
});
await repository.updateLastActive(userId);
const row = (await pool.query('select * from profiles where id = $1', [userId])).rows[0];
expect(row).toMatchObject({
name: 'Alicia', gender: 'female', usage_reason: 'deeper_connection', hide_nsfw: true,
max_intensity: 2, show_explicit_content: false, onboarding_completed: true,
onboarding_version: 1, public_key_jwk: { kty: 'RSA' }, push_token: 'ExponentPushToken[test]',
});
expect(row.last_active_at).toBeInstanceOf(Date);
});
it('creates default preferences, updates one key, and leaves the others enabled', async () => {
const initial = await repository.getNotificationPreferences(userId);
expect(initial.messages_enabled).toBe(true);
const updated = await repository.updateNotificationPreference(userId, 'messages_enabled', false);
expect(updated.messages_enabled).toBe(false);
expect(updated.matches_enabled).toBe(true);
});
it('stores text feedback owned by the authenticated user', async () => {
const submitted = await repository.submitFeedback(userId, {
type: 'general', title: 'Hello', description: 'Useful feedback', device_info: { os: 'ios' },
});
const row = (await pool.query('select * from feedback where id = $1', [submitted.id])).rows[0];
expect(row).toMatchObject({ user_id: userId, type: 'general', title: 'Hello' });
const mediaColumn = await pool.query(
`select column_name from information_schema.columns
where table_schema = current_schema() and table_name = 'feedback' and column_name = 'screenshot_url'`,
);
expect(mediaColumn.rowCount).toBe(0);
});
});
@@ -0,0 +1,128 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { ProfileSettingsRepository } from '../src/domains/profile-settings/repository.js';
import { registerProfileSettingsRoutes } from '../src/domains/profile-settings/routes.js';
const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
email: 'alice@sauci.test',
name: 'Alice',
avatarUrl: null,
};
const preferences = {
user_id: identity.id,
matches_enabled: true,
messages_enabled: true,
partner_activity_enabled: true,
nudges_enabled: true,
pack_changes_enabled: true,
new_packs_enabled: true,
streak_milestones_enabled: true,
weekly_summary_enabled: true,
unpaired_reminders_enabled: true,
catchup_reminders_enabled: true,
created_at: '2026-08-27T10:00:00.000Z',
updated_at: '2026-08-27T10:00:00.000Z',
};
function repository(): ProfileSettingsRepository {
return {
updateProfile: vi.fn(async () => undefined),
updateLastActive: vi.fn(async () => undefined),
getNotificationPreferences: vi.fn(async () => preferences),
updateNotificationPreference: vi.fn(async (_userId, key, value) => ({ ...preferences, [key]: value })),
submitFeedback: vi.fn(async () => ({
id: '22222222-2222-4222-8222-222222222222',
created_at: '2026-08-27T10:00:00.000Z',
})),
close: vi.fn(async () => undefined),
};
}
function appWith(repo: ProfileSettingsRepository) {
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => {
c.set('identity', identity);
await next();
});
registerProfileSettingsRoutes(app, repo);
return app;
}
describe('profile settings routes', () => {
it('derives profile ownership from auth and accepts the onboarding fields', async () => {
const repo = repository();
const response = await appWith(repo).request('/v1/me/profile', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Alice',
gender: 'female',
usage_reason: 'deeper_connection',
onboarding_completed: true,
onboarding_version: 1,
hide_nsfw: true,
max_intensity: 2,
show_explicit_content: false,
}),
});
expect(response.status).toBe(200);
expect(repo.updateProfile).toHaveBeenCalledWith(identity.id, expect.objectContaining({ name: 'Alice' }));
});
it('supports activity, public key, push token, and preference updates for only the token owner', async () => {
const repo = repository();
const app = appWith(repo);
expect((await app.request('/v1/me/activity', { method: 'POST' })).status).toBe(204);
expect(repo.updateLastActive).toHaveBeenCalledWith(identity.id);
const profileResponse = await app.request('/v1/me/profile', {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ public_key_jwk: { kty: 'RSA', n: 'abc', e: 'AQAB' }, push_token: 'ExponentPushToken[test]' }),
});
expect(profileResponse.status).toBe(200);
expect(repo.updateProfile).toHaveBeenCalledWith(identity.id, expect.objectContaining({ push_token: 'ExponentPushToken[test]' }));
const preferenceResponse = await app.request('/v1/me/notification-preferences', {
method: 'PATCH', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: 'messages_enabled', value: false }),
});
expect(preferenceResponse.status).toBe(200);
expect(repo.updateNotificationPreference).toHaveBeenCalledWith(identity.id, 'messages_enabled', false);
});
it('accepts text-only feedback and rejects media or client-supplied ownership', async () => {
const repo = repository();
const app = appWith(repo);
const valid = await app.request('/v1/feedback', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'bug', title: 'Broken', description: 'Details', device_info: { os: 'ios' } }),
});
expect(valid.status).toBe(201);
expect(repo.submitFeedback).toHaveBeenCalledWith(identity.id, expect.objectContaining({ title: 'Broken' }));
for (const body of [
{ type: 'bug', title: 'Broken', description: 'Details', screenshot_url: 'https://example.test/image.png' },
{ type: 'bug', title: 'Broken', description: 'Details', user_id: '33333333-3333-4333-8333-333333333333' },
]) {
expect((await app.request('/v1/feedback', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
})).status).toBe(400);
}
});
it('rejects unsupported profile and preference fields', async () => {
const repo = repository();
const app = appWith(repo);
expect((await app.request('/v1/me/profile', {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ is_premium: true }),
})).status).toBe(400);
expect((await app.request('/v1/me/notification-preferences', {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'admin_enabled', value: true }),
})).status).toBe(400);
});
});