import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Hono } from 'hono'; const root = mkdtempSync(join(tmpdir(), 'ytp-admin-tools-')); process.env.DB_PATH = join(root, 'admin.db'); process.env.MEDIA_CACHE_PINNED_MAX_BYTES = '1000'; const db = await import('./db.js'); const { registerAdminToolsRoutes } = await import('./admin-tools.js'); const app = new Hono(); const backupDir = join(root, 'backups'); const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401); let tools; beforeAll(async () => { await db.initDb(); tools = registerAdminToolsRoutes(app, { db, adminAuth: auth, backupDir, backfillStep: async () => 3, pullLyrics: async () => ({ ok: true, lines: 2 }), queueWhisper: async () => ({ status: 202, body: { ok: true, job: { status: 'queued' } } }), autoStartJobs: false }); await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['QualMiss001', JSON.stringify({ title: 'Missing lyric sample', channel: 'Choir' }), 'missing lyric sample choir', 1, Date.now()] }); await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['Untimely001', JSON.stringify({ title: 'Untimed sample', channel: 'Band' }), 'untimed sample band', 1, Date.now()] }); await db.saveNote({ videoId: 'Untimely001', kind: 'lyrics', data: { lines: [{ t: null, text: 'A line' }, { t: 4, text: 'Next' }] }, source: 'user', force: true }); await db.db.execute({ sql: "INSERT INTO media_cache(video_id,status,size,gen) VALUES('CacheItem001','ready',700,1),('CacheItem002','ready',400,1)" }); await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at,url) VALUES(?,?,?,?,?,?)', args: ['IndexSong001', JSON.stringify({ title: 'Indexed song' }), 'indexed song', 1, Date.now(), 'https://www.youtube.com/watch?v=IndexSong001'] }); await db.db.execute({ sql: "INSERT INTO video_meta_sources(video_id,source,last_seen) VALUES('IndexSong001','deep',?)", args: [Date.now()] }); await db.db.execute({ sql: "INSERT INTO video_details(video_id,metadata,updated_at) VALUES('IndexSong001','{}',?)", args: [Date.now()] }); await db.db.execute({ sql: "INSERT INTO video_thumbnails(video_id,url,data,mime,size,fetched_at) VALUES('IndexSong001','https://i.ytimg.com/vi/IndexSong001/hqdefault.jpg',x'01','image/jpeg',1,?)", args: [Date.now()] }); await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,1)', args: ['OldOrphan01', JSON.stringify({ title: 'Old orphan' }), 'old orphan', 1] }); await db.db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES('job-1','complete','[]','{}','{\"queriesDone\":2,\"newVideos\":5}',1,2)" }); }); afterAll(() => rmSync(root, { recursive: true, force: true })); describe('lyrics quality queue', () => { test('requires admin and returns missing, untimed and short-lyrics reasons in pages', async () => { expect((await app.request('/api/admin/lyrics/quality')).status).toBe(401); const headers = { 'x-admin': 'yes' }; const missing = await (await app.request('/api/admin/lyrics/quality?category=missing', { headers })).json(); expect(missing.total).toBeGreaterThanOrEqual(1); expect(missing.songs.find(song => song.id === 'QualMiss001')).toMatchObject({ id: 'QualMiss001', reasons: ['missing'] }); const untimed = await (await app.request('/api/admin/lyrics/quality?category=untimed', { headers })).json(); expect(untimed.songs[0]).toMatchObject({ id: 'Untimely001', lines: 2, untimed: true }); const short = await (await app.request('/api/admin/lyrics/quality?category=short&minLines=3', { headers })).json(); expect(short.songs.find(song => song.id === 'Untimely001').reasons).toContain('short'); const page = await (await app.request('/api/admin/lyrics/quality?limit=1&offset=1', { headers })).json(); expect(page.songs).toHaveLength(1); }); test('pinning is authorized and enforces the configured pinned-byte cap', async () => { const headers = { 'x-admin': 'yes', 'content-type': 'application/json' }; expect((await app.request('/api/admin/cache/CacheItem001/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) })).status).toBe(200); const over = await app.request('/api/admin/cache/CacheItem002/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) }); expect(over.status).toBe(409); expect((await over.json()).error).toContain('byte cap'); const refused = await app.request('/api/admin/cache/CacheItem001/evict', { method: 'POST', headers, body: '{}' }); expect(refused.status).toBe(409); const health = await (await app.request('/api/admin/cache/health', { headers: { 'x-admin': 'yes' } })).json(); expect(health.pinnedBytes).toBe(700); }); test('index health reports coverage, source and deep-search data; repair actions are audited', async () => { const headers = { 'x-admin': 'yes', 'content-type': 'application/json' }; const health = await (await app.request('/api/admin/index/health', { headers })).json(); expect(health.total).toBeGreaterThanOrEqual(1); expect(health.urlCount).toBeGreaterThanOrEqual(1); expect(health.detailCount).toBe(1); expect(health.thumbnailCount).toBe(1); expect(health.sources[0].source).toBe('deep'); expect(health.lastRun.progress.queriesDone).toBe(2); const step = await (await app.request('/api/admin/index/backfill', { method: 'POST', headers, body: '{}' })).json(); expect(step.processed).toBe(3); const clean = await (await app.request('/api/admin/index/cleanup', { method: 'POST', headers, body: JSON.stringify({ days: 180 }) })).json(); expect(clean.removed).toBeGreaterThanOrEqual(1); expect((await db.db.execute({ sql: "SELECT COUNT(*) AS n FROM video_meta WHERE id='OldOrphan01'" })).rows[0].n).toBe(0); }); test('backup list verifies, previews, downloads, safely restores and exports a SQLite dump', async () => { const headers = { 'x-admin': 'yes', 'content-type': 'application/json' }; await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'Before' }] }, force: true, source: 'user' }); const created = await (await app.request('/api/admin/backups', { method: 'POST', headers, body: '{}' })).json(); expect(created.ok).toBe(true); await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'After' }] }, force: true, source: 'user' }); const list = await (await app.request('/api/admin/backups', { headers })).json(); expect(list.backups.find(item => item.date === created.date)).toMatchObject({ valid: true, notes: created.notes }); const preview = await (await app.request(`/api/admin/backups/${created.date}`, { headers })).json(); expect(preview.diff.changed).toBe(1); const download = await app.request(`/api/admin/backups/${created.date}/download`, { headers }); expect(download.status).toBe(200); const restored = await (await app.request(`/api/admin/backups/${created.date}/restore`, { method: 'POST', headers, body: JSON.stringify({ videoId: 'BackupSong01' }) })).json(); expect(restored.ok).toBe(true); expect((await db.getNotes('BackupSong01')).lyrics.data.lines[0].text).toBe('Before'); expect(existsSync(join(backupDir, 'snapshots', restored.safetySnapshot))).toBe(true); const dump = await app.request('/api/admin/backups/export/sqlite-dump', { headers: { 'x-admin': 'yes' } }); expect(dump.status).toBe(200); expect(await dump.text()).toContain('CREATE TABLE'); }); test('lyrics search is indexed on save, filters chapter state, and rebuilds under admin auth', async () => { const id = 'SearchSong01'; await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: [id, JSON.stringify({ title: 'Amazing grace sample', channel: 'Choir' }), 'amazing grace sample choir', 1, Date.now()] }); await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Amazing grace will lead me home' }] }, source: 'user', force: true }); expect((await app.request('/api/admin/lyrics/search?q=amazing')).status).toBe(401); const headers = { 'x-admin': 'yes' }; let result = await (await app.request('/api/admin/lyrics/search?q=amazing+grace&hasChapters=no', { headers })).json(); expect(result.results[0]).toMatchObject({ videoId: id, title: 'Amazing grace sample', hasChapters: false }); expect(result.results[0].snippet).toContain('\u0001'); await db.saveNote({ videoId: id, kind: 'chapters', data: { items: [{ t: 1, title: 'Verse' }] }, source: 'user', force: true }); result = await (await app.request('/api/admin/lyrics/search?q=amazing&hasChapters=yes', { headers })).json(); expect(result.results[0].videoId).toBe(id); await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Bright morning star' }] }, source: 'user', force: true }); expect((await (await app.request('/api/admin/lyrics/search?q=amazing', { headers })).json()).total).toBe(0); const rebuilt = await (await app.request('/api/admin/lyrics/search/rebuild', { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: '{}' })).json(); expect(rebuilt.ok).toBe(true); expect(rebuilt.indexed).toBeGreaterThanOrEqual(1); }); test('bulk lyrics jobs page the library, resume, clear with revision undo, export ZIP, and cancel', async () => { const headers = { 'x-admin': 'yes', 'content-type': 'application/json' }; expect((await app.request('/api/admin/lyrics/bulk/library')).status).toBe(401); const library = await (await app.request('/api/admin/lyrics/bulk/library?q=untimed', { headers })).json(); expect(library.songs[0]).toMatchObject({ id: 'Untimely001', title: 'Untimed sample' }); const create = async body => (await app.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify(body) })).json(); const pull = (await create({ action: 'lrclib', ids: ['Untimely001'] })).job; expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/pause`, { method: 'POST', headers, body: '{}' })).status).toBe(200); expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/resume`, { method: 'POST', headers, body: '{}' })).status).toBe(200); await tools.runBulkJob(); let current = await (await app.request(`/api/admin/lyrics/jobs/${pull.id}`, { headers: { 'x-admin': 'yes' } })).json(); expect(current.job).toMatchObject({ status: 'complete', state: { done: 1, results: [{ ok: true, id: 'Untimely001' }] } }); const clear = (await create({ action: 'clear', ids: ['Untimely001'], confirmClear: true })).job; await tools.runBulkJob(); expect((await db.getNotes('Untimely001')).lyrics.data.lines).toEqual([]); expect((await db.getNoteRev('Untimely001', 'lyrics', 1)).data.lines[0].text).toBe('A line'); await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['ZipSong0001', JSON.stringify({ title: 'Zip Song' }), 'zip song', 1, Date.now()] }); await db.saveNote({ videoId: 'ZipSong0001', kind: 'lyrics', data: { lines: [{ t: 1.2, text: 'Saved line' }] }, source: 'user', force: true }); const exportJob = (await create({ action: 'export', ids: ['ZipSong0001'] })).job; await tools.runBulkJob(); const archive = await app.request(`/api/admin/lyrics/jobs/${exportJob.id}/export`, { headers: { 'x-admin': 'yes' } }); expect(archive.status).toBe(200); expect([...new Uint8Array(await archive.arrayBuffer())].slice(0, 2)).toEqual([0x50, 0x4b]); const failingApp = new Hono(); const failingTools = registerAdminToolsRoutes(failingApp, { db, adminAuth: auth, backupDir: null, autoStartJobs: false }); const failingExport = (await (await failingApp.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify({ action: 'export', ids: ['ZipSong0001'] }) })).json()).job; await failingTools.runBulkJob(); const failed = await (await failingApp.request(`/api/admin/lyrics/jobs/${failingExport.id}`, { headers: { 'x-admin': 'yes' } })).json(); expect(failed.job).toMatchObject({ status: 'failed', error: 'Export storage is unavailable.' }); const cancelled = (await create({ action: 'whisper', ids: ['Untimely001'] })).job; await app.request(`/api/admin/lyrics/jobs/${cancelled.id}/cancel`, { method: 'POST', headers, body: '{}' }); current = await (await app.request(`/api/admin/lyrics/jobs/${cancelled.id}`, { headers: { 'x-admin': 'yes' } })).json(); expect(current.job.status).toBe('cancelled'); }); });