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