Files
Sauci/apps/api/test/couples.routes.test.ts
jjbrunton 006761d3b0 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.
2026-08-28 14:42:11 +01:00

84 lines
3.1 KiB
TypeScript

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);
});
});