import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { type FastifyInstance } from 'fastify'; import { initDatabaseAsync, closeDatabase } from '../db/index'; import { runMigrations } from '../db/migrate'; import { buildServer } from '../server/index'; import { systemConfig } from '../db/schema/index'; import { eq } from 'drizzle-orm'; import { type LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; /** * Integration tests for format profile CRUD API endpoints. * Uses Fastify inject — no real HTTP ports. */ describe('Format Profile API', () => { let server: FastifyInstance; let db: LibSQLDatabase; let apiKey: string; let tmpDir: string; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-fp-api-')); const dbPath = join(tmpDir, 'test.db'); db = await initDatabaseAsync(dbPath); await runMigrations(dbPath); server = await buildServer({ db }); await server.ready(); // Read API key from database (generated by auth plugin) const rows = await db .select() .from(systemConfig) .where(eq(systemConfig.key, 'api_key')) .limit(1); apiKey = rows[0]?.value ?? ''; expect(apiKey).toBeTruthy(); }); afterAll(async () => { await server.close(); closeDatabase(); try { if (tmpDir && existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } catch { // Temp dir cleanup is best-effort on Windows } }); // ── Helpers ── function authed(opts: Record) { return { ...opts, headers: { 'x-api-key': apiKey, ...(opts.headers as Record | undefined) }, }; } // ── Auth gating ── describe('Authentication', () => { it('returns 401 when no API key is provided', async () => { const res = await server.inject({ method: 'GET', url: '/api/v1/format-profile', }); expect(res.statusCode).toBe(401); }); }); // ── CRUD lifecycle ── describe('CRUD lifecycle', () => { let profileId: number; it('POST creates a format profile', async () => { const res = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: 'HD Video', videoResolution: '1080p', audioCodec: 'aac', audioBitrate: '192k', containerFormat: 'mp4', isDefault: false, }, }) ); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.name).toBe('HD Video'); expect(body.videoResolution).toBe('1080p'); expect(body.audioCodec).toBe('aac'); expect(body.audioBitrate).toBe('192k'); expect(body.containerFormat).toBe('mp4'); expect(body.isDefault).toBe(false); expect(body.id).toBeDefined(); profileId = body.id; }); it('GET / lists all profiles', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/format-profile' }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); expect(body.length).toBeGreaterThanOrEqual(1); expect(body.some((p: { id: number }) => p.id === profileId)).toBe(true); }); it('GET /:id returns a single profile', async () => { const res = await server.inject( authed({ method: 'GET', url: `/api/v1/format-profile/${profileId}` }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.id).toBe(profileId); expect(body.name).toBe('HD Video'); }); it('PUT /:id updates profile fields', async () => { const res = await server.inject( authed({ method: 'PUT', url: `/api/v1/format-profile/${profileId}`, payload: { name: 'Full HD', videoResolution: '1080p' }, }) ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.id).toBe(profileId); expect(body.name).toBe('Full HD'); }); it('DELETE /:id removes the profile', async () => { const res = await server.inject( authed({ method: 'DELETE', url: `/api/v1/format-profile/${profileId}` }) ); expect(res.statusCode).toBe(204); // Verify it's gone const getRes = await server.inject( authed({ method: 'GET', url: `/api/v1/format-profile/${profileId}` }) ); expect(getRes.statusCode).toBe(404); }); }); // ── 404 handling ── describe('Not found handling', () => { it('GET /:id returns 404 for non-existent profile', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/format-profile/99999' }) ); expect(res.statusCode).toBe(404); expect(res.json().error).toBe('Not Found'); }); it('PUT /:id returns 404 for non-existent profile', async () => { const res = await server.inject( authed({ method: 'PUT', url: '/api/v1/format-profile/99999', payload: { name: 'Nope' }, }) ); expect(res.statusCode).toBe(404); }); it('DELETE /:id returns 404 for non-existent profile', async () => { const res = await server.inject( authed({ method: 'DELETE', url: '/api/v1/format-profile/99999' }) ); expect(res.statusCode).toBe(404); }); }); // ── Validation errors ── describe('Validation', () => { it('POST rejects body missing required name', async () => { const res = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { videoResolution: '720p' }, }) ); expect(res.statusCode).toBe(400); }); it('POST rejects body with empty name', async () => { const res = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: '' }, }) ); expect(res.statusCode).toBe(400); }); it('GET /:id returns 400 for non-numeric ID', async () => { const res = await server.inject( authed({ method: 'GET', url: '/api/v1/format-profile/abc' }) ); expect(res.statusCode).toBe(400); }); }); // ── Default profile management ── describe('Default profile', () => { it('setting isDefault on one profile clears it from others', async () => { // Create first profile as default const res1 = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: 'Default A', isDefault: true }, }) ); expect(res1.statusCode).toBe(201); const profileA = res1.json(); expect(profileA.isDefault).toBe(true); // Create second profile as default — should clear the first const res2 = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: 'Default B', isDefault: true }, }) ); expect(res2.statusCode).toBe(201); const profileB = res2.json(); expect(profileB.isDefault).toBe(true); // Verify first profile is no longer default const resA = await server.inject( authed({ method: 'GET', url: `/api/v1/format-profile/${profileA.id}` }) ); expect(resA.json().isDefault).toBe(false); // Clean up — profileA is not default so it's deletable. // profileB is default and protected — leave it (shared test DB, no conflict). await server.inject( authed({ method: 'DELETE', url: `/api/v1/format-profile/${profileA.id}` }) ); }); }); // ── Default profile protection ── describe('Default profile protection', () => { it('DELETE default profile returns 403', async () => { const createRes = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: 'Protected Default', isDefault: true }, }) ); expect(createRes.statusCode).toBe(201); const profile = createRes.json(); const deleteRes = await server.inject( authed({ method: 'DELETE', url: `/api/v1/format-profile/${profile.id}` }) ); expect(deleteRes.statusCode).toBe(403); expect(deleteRes.json().message).toBe('Cannot delete the default format profile'); // Profile remains in DB (default, protected) — no cleanup needed for test isolation }); it('DELETE non-default profile still works', async () => { const createRes = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: 'Deletable Profile', isDefault: false }, }) ); expect(createRes.statusCode).toBe(201); const profile = createRes.json(); const deleteRes = await server.inject( authed({ method: 'DELETE', url: `/api/v1/format-profile/${profile.id}` }) ); expect(deleteRes.statusCode).toBe(204); }); it('PUT default profile with isDefault: false returns 400', async () => { const createRes = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: 'Default No Unset', isDefault: true }, }) ); expect(createRes.statusCode).toBe(201); const profile = createRes.json(); const putRes = await server.inject( authed({ method: 'PUT', url: `/api/v1/format-profile/${profile.id}`, payload: { isDefault: false }, }) ); expect(putRes.statusCode).toBe(400); expect(putRes.json().message).toBe('Cannot unset isDefault on the default format profile'); // Clean up — force unset via direct DB or just leave (fresh DB per suite) // We can't unset via API (that's what we're testing), so just leave it }); it('PUT default profile with other fields works', async () => { const createRes = await server.inject( authed({ method: 'POST', url: '/api/v1/format-profile', payload: { name: 'Renameable Default', isDefault: true }, }) ); expect(createRes.statusCode).toBe(201); const profile = createRes.json(); const putRes = await server.inject( authed({ method: 'PUT', url: `/api/v1/format-profile/${profile.id}`, payload: { name: 'Renamed Default', videoResolution: '1080p' }, }) ); expect(putRes.statusCode).toBe(200); const updated = putRes.json(); expect(updated.name).toBe('Renamed Default'); expect(updated.videoResolution).toBe('1080p'); expect(updated.isDefault).toBe(true); }); }); });