Files
ytplayer/server/flags.test.js

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