| 1 | // /tracks.xml -- de hele site als muziekkanaal (Robins vraag, 10-8).
|
|---|
| 2 | //
|
|---|
| 3 | // Hij vroeg naar een "hele site, open tracks"-library voor Funkwhale. Als
|
|---|
| 4 | // AS2-object helpt dat daar niet: een Funkwhale-kanaal wijst nergens naar een
|
|---|
| 5 | // library. Maar de vorm waarin Funkwhale een kanaal WEL uitgeeft is RSS met
|
|---|
| 6 | // iTunes en een enclosure per item, en dat doet Klonkt al -- alleen droeg die
|
|---|
| 7 | // feed ook gewone posts, en een muziekkanaal met blogberichten ertussen is er
|
|---|
| 8 | // geen. Vandaar een tweede ingang op dezelfde bouwer.
|
|---|
| 9 | //
|
|---|
| 10 | // Per ITEM lezen en niet met een patroon over het hele document: dat laatste
|
|---|
| 11 | // telt een titel in de kanaalkop net zo hard mee als een titel in een item, en
|
|---|
| 12 | // dan meet de test iets anders dan hij beweert.
|
|---|
| 13 | import { test } from 'node:test';
|
|---|
| 14 | import assert from 'node:assert/strict';
|
|---|
| 15 |
|
|---|
| 16 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 17 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 18 |
|
|---|
| 19 | const dbMod = await import('../src/config/database.js');
|
|---|
| 20 | const db = dbMod.default;
|
|---|
| 21 | dbMod.initializeDatabase();
|
|---|
| 22 | const express = (await import('express')).default;
|
|---|
| 23 | const feed = (await import('../src/routes/feed.js')).default;
|
|---|
| 24 |
|
|---|
| 25 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 26 | .run('u1', 'zanger', 'zanger@t', 'x', 'god');
|
|---|
| 27 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, is_primary) VALUES (?,?,?,?,1,1)')
|
|---|
| 28 | .run('s1', 'band', 'De Band', 'u1');
|
|---|
| 29 |
|
|---|
| 30 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content, status, published_at)
|
|---|
| 31 | VALUES (?,?,?,?,?,?,?,'published',?)`)
|
|---|
| 32 | .run('p1', 's1', 'u1', 'een-blog', 'Een blogbericht', 'kort', '<p>x</p>', '2026-01-01T00:00:00Z');
|
|---|
| 33 |
|
|---|
| 34 | const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
|
|---|
| 35 | const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open, created_at) VALUES (?,?,?,?,?,?,?,?)');
|
|---|
| 36 | insM.run('m1', 's1', 'open.mp3', 'audio/open.mp3', 'audio/mpeg', 4210688);
|
|---|
| 37 | insM.run('m2', 's1', 'dicht.mp3', 'audio/dicht.mp3', 'audio/mpeg', 999);
|
|---|
| 38 | insT.run('t1', 's1', 'Het open nummer', 'De Band', 203, 'm1', 1, '2026-03-01 12:00:00');
|
|---|
| 39 | insT.run('t2', 's1', 'Achter de poort', 'De Band', 100, 'm2', 0, '2026-04-01 12:00:00');
|
|---|
| 40 |
|
|---|
| 41 | const app = express();
|
|---|
| 42 | app.use((req, res, next) => {
|
|---|
| 43 | res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
|
|---|
| 44 | res.locals.siteUrlBase = '';
|
|---|
| 45 | next();
|
|---|
| 46 | });
|
|---|
| 47 | app.use(feed);
|
|---|
| 48 | const server = app.listen(0);
|
|---|
| 49 | await new Promise((r) => server.once('listening', r));
|
|---|
| 50 | const poort = server.address().port;
|
|---|
| 51 | const haal = async (pad) => (await fetch(`http://127.0.0.1:${poort}${pad}`)).text();
|
|---|
| 52 |
|
|---|
| 53 | /** Splitsen op <item>-grenzen en dan per item op tagniveau lezen, in plaats
|
|---|
| 54 | * van een patroon over het hele document laten lopen: dat laatste telt een
|
|---|
| 55 | * titel in de kop net zo hard mee als een titel in een item. */
|
|---|
| 56 | function items(xml) {
|
|---|
| 57 | return [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => m[1]);
|
|---|
| 58 | }
|
|---|
| 59 | function tekst(blok, tag) {
|
|---|
| 60 | const m = blok.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`));
|
|---|
| 61 | return m ? m[1] : null;
|
|---|
| 62 | }
|
|---|
| 63 |
|
|---|
| 64 | const muziek = await haal('/tracks.xml');
|
|---|
| 65 | const alles = await haal('/feed.xml');
|
|---|
| 66 |
|
|---|
| 67 | test('de muziekfeed draagt ALLEEN tracks, geen blogberichten', () => {
|
|---|
| 68 | const it = items(muziek);
|
|---|
| 69 | assert.equal(it.length, 1, 'een open track, en de post hoort er niet in');
|
|---|
| 70 | assert.equal(tekst(it[0], 'title'), 'Het open nummer');
|
|---|
| 71 | assert.ok(!muziek.includes('Een blogbericht'), 'de post staat er niet in');
|
|---|
| 72 | });
|
|---|
| 73 |
|
|---|
| 74 | test('en de gewone feed draagt ze allebei nog steeds', () => {
|
|---|
| 75 | const it = items(alles);
|
|---|
| 76 | assert.equal(it.length, 2, 'de sitefeed verandert niet: post en track');
|
|---|
| 77 | assert.ok(alles.includes('Een blogbericht'));
|
|---|
| 78 | });
|
|---|
| 79 |
|
|---|
| 80 | test('een gesloten track blijft ook hier weg -- dicht is dicht', () => {
|
|---|
| 81 | assert.ok(!muziek.includes('Achter de poort'));
|
|---|
| 82 | assert.ok(!muziek.includes('dicht.mp3'));
|
|---|
| 83 | });
|
|---|
| 84 |
|
|---|
| 85 | test('elke item heeft de enclosure die een podcast-lezer zoekt', () => {
|
|---|
| 86 | const enc = muziek.match(/<enclosure[^>]*>/g) || [];
|
|---|
| 87 | assert.equal(enc.length, 1);
|
|---|
| 88 | assert.match(enc[0], /url="[^"]*\/audio\/stream\/open\.mp3"/);
|
|---|
| 89 | assert.match(enc[0], /length="4210688"/);
|
|---|
| 90 | assert.match(enc[0], /type="audio\/mpeg"/);
|
|---|
| 91 | });
|
|---|
| 92 |
|
|---|
| 93 | test('de self-link wijst naar ZICHZELF en niet naar de sitefeed', () => {
|
|---|
| 94 | // Anders stuurt een lezer die de feed opnieuw ophaalt zichzelf naar de
|
|---|
| 95 | // gemengde variant, en staan de blogberichten er alsnog in.
|
|---|
| 96 | assert.match(muziek, /<atom:link href="[^"]*\/tracks\.xml" rel="self"/);
|
|---|
| 97 | assert.ok(!/<atom:link href="[^"]*\/feed\.xml" rel="self"/.test(muziek));
|
|---|
| 98 | assert.match(alles, /<atom:link href="[^"]*\/feed\.xml" rel="self"/);
|
|---|
| 99 | });
|
|---|
| 100 |
|
|---|
| 101 | test('en hij heet anders, want twee gelijknamige feeds zijn niet te scheiden', () => {
|
|---|
| 102 | assert.ok(muziek.includes('De Band — muziek'));
|
|---|
| 103 | assert.ok(alles.includes('<title>De Band</title>'));
|
|---|
| 104 | });
|
|---|
| 105 |
|
|---|
| 106 | test('de iTunes-categorie staat er, want er is echt muziek', () => {
|
|---|
| 107 | assert.match(muziek, /<itunes:category text="Music" \/>/);
|
|---|
| 108 | assert.match(muziek, /<itunes:duration>203<\/itunes:duration>/);
|
|---|
| 109 | });
|
|---|
| 110 |
|
|---|
| 111 | test.after(() => server.close());
|
|---|