159 lines
8.0 KiB
JavaScript
159 lines
8.0 KiB
JavaScript
// 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, '<!doctype html><title>admin</title>');
|
|
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);
|
|
});
|
|
});
|