Files
Sauci/apps/api/test/packs.routes.test.ts
T
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

94 lines
3.4 KiB
TypeScript

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