Files
Sauci/apps/api/test/app.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

123 lines
4.6 KiB
TypeScript

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