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)
357 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|