// Lyric line reports: report / list / withdraw / admin triage / auto-resolve. import { describe, 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-flags-test-')); process.env.DB_PATH = join(root, 'test.db'); const dbmod = await import('./db.js'); const N = await import('./notes.js'); const VID = 'dQw4w9WgXcQ'; const PASSWORD = 'correct horse'; const PROFILES = new Set(['josh', 'maria']); let app; beforeAll(async () => { await dbmod.initDb(); const htmlPath = join(root, 'admin.html'); writeFileSync(htmlPath, 'admin'); app = new Hono(); N.registerNoteRoutes(app, { db: dbmod, getProfile: async (name) => (PROFILES.has(name) ? { data: '{}' } : null), profileNameRe: /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/, runYtdlp: async () => '{}', adminPassword: PASSWORD, backupDir: join(root, 'backups'), adminHtmlPath: htmlPath, workerToken: 'w'.repeat(32), }); // Shared lyrics everyone reports against. const saved = await dbmod.saveNote({ videoId: VID, kind: 'lyrics', baseRev: 0, source: 'user', updatedBy: 'seed', data: { offset: 0, tags: [], lines: [ { t: 1, text: 'Amazing grace', kind: 'line' }, { t: 5, text: 'how sweet the sownd', kind: 'line' }, { t: 9, text: 'that saved a wretch like me', kind: 'line' }, ] }, }); expect(saved.ok).toBe(true); }); afterAll(() => rmSync(root, { recursive: true, force: true })); const json = (method, body, headers = {}) => ({ method, headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); async function adminCookie() { const res = await app.request('/api/admin/login', json('POST', { password: PASSWORD })); return res.headers.get('set-cookie').split(';')[0]; } const report = (body) => app.request(`/api/notes/${VID}/flags`, json('POST', body)); describe('reporting a line', () => { test('needs a linked profile', async () => { expect((await report({ text: 'Amazing grace' })).status).toBe(401); expect((await report({ text: 'Amazing grace', profile: 'nobody' })).status).toBe(401); }); test('optional description: a bare report works, reason is normalised', async () => { const r = await report({ text: 'how sweet the sownd', profile: 'josh', reason: 'not-a-reason' }); expect(r.status).toBe(200); const j = await r.json(); expect(j.ok).toBe(true); expect(j.created).toBe(true); const list = await (await app.request(`/api/notes/${VID}/flags`, { headers: { 'X-Profile': 'josh' } })).json(); expect(list.flags).toEqual([{ index: 1, text: 'how sweet the sownd', count: 1, mine: true, id: j.id, reason: '', note: '' }]); }); test('reporting the same line again updates it instead of duplicating', async () => { const r = await (await report({ text: 'how sweet the sownd', profile: 'josh', reason: 'typo', note: 'should be "sound"' })).json(); expect(r.created).toBe(false); const list = await (await app.request(`/api/notes/${VID}/flags`, { headers: { 'X-Profile': 'josh' } })).json(); expect(list.flags.length).toBe(1); expect(list.flags[0]).toMatchObject({ reason: 'typo', note: 'should be "sound"', count: 1, mine: true }); }); test('a second person is counted, but never sees the first person\'s description', async () => { await report({ text: 'how sweet the sownd', profile: 'maria', note: 'private note' }); const asMaria = await (await app.request(`/api/notes/${VID}/flags`, { headers: { 'X-Profile': 'maria' } })).json(); expect(asMaria.flags[0]).toMatchObject({ count: 2, mine: true, note: 'private note' }); const anon = await (await app.request(`/api/notes/${VID}/flags`)).json(); expect(anon.flags[0]).toMatchObject({ count: 2, mine: false, id: null, note: '', reason: '' }); expect(JSON.stringify(anon)).not.toContain('josh'); }); test('a line that no longer exists is refused as already changed', async () => { const r = await report({ text: 'a line nobody sings', profile: 'josh' }); expect(r.status).toBe(409); expect((await r.json()).stale).toBe(true); expect((await report({ profile: 'josh' })).status).toBe(400); }); test('a song with no lyrics cannot be reported', async () => { const r = await app.request('/api/notes/aaaaaaaaaaa/flags', json('POST', { text: 'x', profile: 'josh' })); expect(r.status).toBe(404); }); }); describe('withdrawing', () => { test('only the reporter can take a report back', async () => { const mine = await (await app.request(`/api/notes/${VID}/flags`, { headers: { 'X-Profile': 'maria' } })).json(); const id = mine.flags[0].id; expect((await app.request(`/api/notes/${VID}/flags/${id}/withdraw`, json('POST', { profile: 'josh' }))).status).toBe(404); expect((await app.request(`/api/notes/${VID}/flags/${id}/withdraw`, json('POST', { profile: 'maria' }))).status).toBe(200); const after = await (await app.request(`/api/notes/${VID}/flags`, { headers: { 'X-Profile': 'maria' } })).json(); expect(after.flags[0]).toMatchObject({ count: 1, mine: false }); }); }); describe('admin triage', () => { test('listing needs the admin; it names reporters and shows the song', async () => { expect((await app.request('/api/admin/flags')).status).toBe(401); const cookie = await adminCookie(); const j = await (await app.request('/api/admin/flags', { headers: { cookie } })).json(); expect(j.ok).toBe(true); expect(j.open).toBe(1); expect(j.flags[0]).toMatchObject({ videoId: VID, text: 'how sweet the sownd', reporter: 'josh', reason: 'typo', status: 'open' }); }); test('resolve, reopen, delete', async () => { const cookie = await adminCookie(); const id = (await (await app.request('/api/admin/flags', { headers: { cookie } })).json()).flags[0].id; const post = (status) => app.request(`/api/admin/flags/${id}`, { ...json('POST', { status }), headers: { 'Content-Type': 'application/json', cookie } }); expect((await (await post('resolved')).json()).open).toBe(0); const resolved = await (await app.request('/api/admin/flags?status=resolved', { headers: { cookie } })).json(); expect(resolved.flags[0]).toMatchObject({ id, status: 'resolved', resolvedBy: 'admin' }); expect((await (await post('open')).json()).open).toBe(1); expect((await post('nonsense')).status).toBe(400); const del = await app.request(`/api/admin/flags/${id}`, { method: 'DELETE', headers: { cookie } }); expect((await del.json()).open).toBe(0); }); }); describe('auto-resolve when the lyrics change', () => { test('editing a reported line resolves its report; untouched lines keep theirs', async () => { await report({ text: 'how sweet the sownd', profile: 'josh' }); await report({ text: 'that saved a wretch like me', profile: 'josh' }); const cookie = await adminCookie(); const cur = (await dbmod.getNotes(VID)).lyrics; const put = await app.request(`/api/notes/${VID}/lyrics`, { method: 'PUT', headers: { 'Content-Type': 'application/json', cookie }, body: JSON.stringify({ baseRev: cur.rev, data: { offset: 0, tags: [], lines: [ { t: 1, text: 'Amazing grace', kind: 'line' }, { t: 5, text: 'how sweet the sound', kind: 'line' }, // fixed { t: 9, text: 'that saved a wretch like me', kind: 'line' }, // untouched ] } }), }); expect(put.status).toBe(200); await new Promise((r) => setTimeout(r, 100)); // resolution runs right after the response const open = await (await app.request(`/api/notes/${VID}/flags`, { headers: { 'X-Profile': 'josh' } })).json(); expect(open.flags.map((f) => f.text)).toEqual(['that saved a wretch like me']); const resolved = await (await app.request('/api/admin/flags?status=resolved', { headers: { cookie } })).json(); expect(resolved.flags.some((f) => f.text === 'how sweet the sownd' && f.resolvedBy === 'auto (line changed)')).toBe(true); }); });