tubearr/src/__tests__/format-profile-api.test.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

357 lines
11 KiB
TypeScript

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<typeof schema>;
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<string, unknown>) {
return {
...opts,
headers: { 'x-api-key': apiKey, ...(opts.headers as Record<string, string> | 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);
});
});
});