Redesign service-mode controls and add per-profile wrong-lyric-line reports
This commit is contained in:
158
server/flags.test.js
Normal file
158
server/flags.test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user