import { test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-transcriptions-'));
process.env.DB_PATH = join(root, 'test.db');
const db = await import('./db.js');
const { registerNoteRoutes } = await import('./notes.js');
const VID = '0gfX0dFLaBc';
const TOKEN = 'worker-test-token-'.repeat(3);
const worker = { Authorization: 'Bearer ' + TOKEN };
const json = (body = {}, headers = {}) => ({ method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
let app, cookie, job, lease;
beforeAll(async () => {
await db.initDb(); await db.upsertMedia(VID, { status: 'ready', duration: 240 });
await db.saveNote({ videoId: VID, kind: 'lyrics', baseRev: 0, source: 'user', updatedBy: 'fixture', data: { lines: [{ t: 1, text: 'Original saved lyrics', kind: 'line' }], tags: [], offset: 0 } });
writeFileSync(join(root, 'admin.html'), '
admin');
app = new Hono();
registerNoteRoutes(app, { db, getProfile: async () => null, profileNameRe: /^[\w-]{3,40}$/, runYtdlp: async () => '{}', adminPassword: 'test-password', workerToken: TOKEN, backupDir: join(root, 'backups'), adminHtmlPath: join(root, 'admin.html') });
const login = await app.request('/api/admin/login', json({ password: 'test-password' }));
cookie = login.headers.get('set-cookie').split(';')[0];
});
afterAll(() => { db.db.close(); rmSync(root, { recursive: true, force: true }); });
const admin = () => ({ Cookie: cookie });
const draft = { lines: [{ t: 2, text: 'Because You are God', kind: 'line' }, { t: 6, text: 'You can do anything', kind: 'line' }], tags: ['wrong source'], offset: 0 };
test('admin and worker authentication are enforced separately', async () => {
expect((await app.request('/api/admin/transcriptions/' + VID, json())).status).toBe(401);
expect((await app.request('/api/admin/transcriptions/' + VID)).status).toBe(401);
expect((await app.request('/api/lyrics-worker/claim', json({}, admin()))).status).toBe(401);
});
test('two button requests deduplicate and preserve published lyrics', async () => {
const first = await app.request('/api/admin/transcriptions/' + VID, json({}, admin()));
expect(first.status).toBe(202); job = (await first.json()).job;
const second = await (await app.request('/api/admin/transcriptions/' + VID, json({}, admin()))).json();
expect(second.job.id).toBe(job.id); expect(job.baseRev).toBe(1);
expect((await db.getNotes(VID)).lyrics.data.lines[0].text).toBe('Original saved lyrics');
await db.initDb();
expect((await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json()).job.id).toBe(job.id);
});
test('claiming is atomic and does not expose leases to the admin UI', async () => {
const responses = await Promise.all([app.request('/api/lyrics-worker/claim', json({}, worker)), app.request('/api/lyrics-worker/claim', json({}, worker))]);
const jobs = await Promise.all(responses.map(r => r.json()));
expect(jobs.filter(r => r.job)).toHaveLength(1); lease = jobs.find(r => r.job).job.lease;
expect(jobs.find(r => r.job).job.audioPath).toBe('/api/media/' + VID + '?a=1');
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.lease).toBeUndefined(); expect(view.workerOnline).toBe(true);
});
test('heartbeat advances stages and invalid leases cannot finish jobs', async () => {
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease: 'wrong', status: 'complete', result: draft }, worker))).status).toBe(409);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, stage: 'transcribing' }, worker))).status).toBe(200);
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.stage).toBe('transcribing');
});
test('completion returns a sanitized review draft without overwriting saved lyrics', async () => {
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'complete', result: draft }, worker))).status).toBe(200);
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.status).toBe('complete'); expect(view.job.result.lines).toHaveLength(2);
expect(view.job.result.tags).toEqual(['auto-transcribed (whisper)']);
expect((await db.getNotes(VID)).lyrics.rev).toBe(1);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'failed' }, worker))).status).toBe(409);
});
test('expired jobs are reclaimed with a new lease; stale results are rejected', async () => {
const queued = await (await app.request('/api/admin/transcriptions/' + VID, json({}, admin()))).json();
expect(queued.job.id).not.toBe(job.id); job = queued.job;
const first = (await (await app.request('/api/lyrics-worker/claim', json({}, worker))).json()).job;
await db.db.execute({ sql: 'UPDATE lyric_transcriptions SET lease_until=0 WHERE id=?', args: [job.id] });
const reclaimed = (await (await app.request('/api/lyrics-worker/claim', json({}, worker))).json()).job;
expect(reclaimed.lease).not.toBe(first.lease);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease: first.lease, status: 'complete', result: draft }, worker))).status).toBe(409);
lease = reclaimed.lease;
});
test('empty transcripts and oversized drafts are rejected, and failures retain saved lyrics', async () => {
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'complete', result: { lines: [] } }, worker))).status).toBe(400);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, result: 'x'.repeat(400001) }, worker))).status).toBe(413);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'failed', error: 'No sung vocals were detected.' }, worker))).status).toBe(200);
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.error).toBe('No sung vocals were detected.'); expect((await db.getNotes(VID)).lyrics.rev).toBe(1);
});
test('uncached songs, invalid IDs, and overlong audio give usable errors', async () => {
expect((await app.request('/api/admin/transcriptions/nope', json({}, admin()))).status).toBe(400);
expect((await app.request('/api/admin/transcriptions/aaaaaaaaaaa', json({}, admin()))).status).toBe(409);
await db.upsertMedia('bbbbbbbbbbb', { status: 'ready', duration: 4000 });
expect((await app.request('/api/admin/transcriptions/bbbbbbbbbbb', json({}, admin()))).status).toBe(422);
});
test('uploads use the upload media endpoint and repeated worker crashes fail visibly', async () => {
const id = 'upl_0123456789ab';
await db.createUpload({ id, kind: 'audio', title: 'Fixture upload', duration: 30, ext: 'm4a', mime: 'audio/mp4', size: 100 });
await app.request('/api/admin/transcriptions/' + id, json({}, admin()));
const claim = (await (await app.request('/api/lyrics-worker/claim', json({}, worker))).json()).job;
expect(claim.audioPath).toBe('/api/uploads/' + id);
await db.db.execute({ sql: 'UPDATE lyric_transcriptions SET lease_until=0,attempts=3 WHERE id=?', args: [claim.id] });
await app.request('/api/lyrics-worker/claim', json({}, worker));
const view = await (await app.request('/api/admin/transcriptions/' + id, { headers: admin() })).json();
expect(view.job.status).toBe('failed');
});