| 1 | // De RSS-feed draagt de opengezette tracks als <enclosure> (shaer-0nh).
|
|---|
| 2 | //
|
|---|
| 3 | // Aanleiding: de actor adverteert /feed.xml als kanaal-feed, maar die feed was
|
|---|
| 4 | // een blogfeed -- titels en tekst, nul enclosures. Een podcast-app zoekt
|
|---|
| 5 | // precies daarop, dus voor hem was de feed leeg hoe veel items er ook in
|
|---|
| 6 | // stonden. De wegwijzer stond er, de inhoud niet.
|
|---|
| 7 | import { test } from 'node:test';
|
|---|
| 8 | import assert from 'node:assert/strict';
|
|---|
| 9 |
|
|---|
| 10 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 11 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 12 |
|
|---|
| 13 | const dbMod = await import('../src/config/database.js');
|
|---|
| 14 | const db = dbMod.default;
|
|---|
| 15 | dbMod.initializeDatabase();
|
|---|
| 16 | const express = (await import('express')).default;
|
|---|
| 17 | const feed = (await import('../src/routes/feed.js')).default;
|
|---|
| 18 |
|
|---|
| 19 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 20 | .run('u1', 'zanger', 'zanger@t', 'x', 'god');
|
|---|
| 21 | db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, is_primary) VALUES (?,?,?,?,1,1)')
|
|---|
| 22 | .run('s1', 'band', 'De Band', 'u1');
|
|---|
| 23 |
|
|---|
| 24 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content, status, published_at)
|
|---|
| 25 | VALUES (?,?,?,?,?,?,?,'published',?)`)
|
|---|
| 26 | .run('p1', 's1', 'u1', 'oud-bericht', 'Oud bericht', 'kort', '<p>x</p>', '2026-01-01T00:00:00Z');
|
|---|
| 27 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content, status, published_at)
|
|---|
| 28 | VALUES (?,?,?,?,?,?,?,'published',?)`)
|
|---|
| 29 | .run('p2', 's1', 'u1', 'nieuw-bericht', 'Nieuw bericht', 'kort', '<p>y</p>', '2026-06-01T00:00:00Z');
|
|---|
| 30 |
|
|---|
| 31 | const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
|
|---|
| 32 | const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open, created_at) VALUES (?,?,?,?,?,?,?,?)');
|
|---|
| 33 | insM.run('m1', 's1', 'open.mp3', 'audio/open.mp3', 'audio/mpeg', 4210688);
|
|---|
| 34 | insM.run('m2', 's1', 'geheim.mp3', 'audio/geheim.mp3', 'audio/mpeg', 999);
|
|---|
| 35 | insM.run('m3', 's1', 'tweede.mp3', 'audio/tweede.mp3', 'audio/mpeg', 1024);
|
|---|
| 36 | insT.run('t1', 's1', 'Het open nummer', 'De Band', 203, 'm1', 1, '2026-03-01 12:00:00');
|
|---|
| 37 | insT.run('t2', 's1', 'Achter de poort', 'De Band', 100, 'm2', 0, '2026-04-01 12:00:00');
|
|---|
| 38 | insT.run('t3', 's1', 'Tweede open', 'De Band', 90, 'm3', 1, '2026-07-01 12:00:00');
|
|---|
| 39 |
|
|---|
| 40 | const app = express();
|
|---|
| 41 | app.use((req, res, next) => {
|
|---|
| 42 | res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
|
|---|
| 43 | res.locals.siteUrlBase = '';
|
|---|
| 44 | next();
|
|---|
| 45 | });
|
|---|
| 46 | app.use(feed);
|
|---|
| 47 | const server = app.listen(0);
|
|---|
| 48 | await new Promise((r) => server.once('listening', r));
|
|---|
| 49 | const xml = await (await fetch(`http://127.0.0.1:${server.address().port}/feed.xml`)).text();
|
|---|
| 50 |
|
|---|
| 51 | test('elke open track levert precies een enclosure', () => {
|
|---|
| 52 | const enclosures = xml.match(/<enclosure[^>]*>/g) || [];
|
|---|
| 53 | assert.equal(enclosures.length, 2, 'twee open tracks, twee enclosures');
|
|---|
| 54 | const items = xml.match(/<item>/g) || [];
|
|---|
| 55 | assert.equal(items.length, 4, 'twee posts en twee tracks, elk een eigen item');
|
|---|
| 56 | });
|
|---|
| 57 |
|
|---|
| 58 | test('de enclosure draagt url, lengte in bytes en het echte mime-type', () => {
|
|---|
| 59 | const e = (xml.match(/<enclosure[^>]*open\.mp3[^>]*>/) || [])[0];
|
|---|
| 60 | assert.ok(e, 'de open track heeft een enclosure');
|
|---|
| 61 | assert.match(e, /url="[^"]*\/audio\/stream\/open\.mp3"/);
|
|---|
| 62 | assert.match(e, /length="4210688"/, 'de bytes komen uit media.size');
|
|---|
| 63 | assert.match(e, /type="audio\/mpeg"/);
|
|---|
| 64 | });
|
|---|
| 65 |
|
|---|
| 66 | test('de duur staat erbij, in seconden', () => {
|
|---|
| 67 | assert.match(xml, /<itunes:duration>203<\/itunes:duration>/);
|
|---|
| 68 | assert.match(xml, /xmlns:itunes="http:\/\/www\.itunes\.com\/dtds\/podcast-1\.0\.dtd"/,
|
|---|
| 69 | 'de namespace is gedeclareerd, anders is het geen geldige XML voor een lezer');
|
|---|
| 70 | });
|
|---|
| 71 |
|
|---|
| 72 | test('een gesloten track staat er niet in, en lekt titel noch bestandsnaam', () => {
|
|---|
| 73 | assert.ok(!xml.includes('Achter de poort'));
|
|---|
| 74 | assert.ok(!xml.includes('geheim.mp3'));
|
|---|
| 75 | });
|
|---|
| 76 |
|
|---|
| 77 | test('de guid van een track is geen permalink maar zijn AP-id', () => {
|
|---|
| 78 | assert.match(xml, /<guid isPermaLink="false">[^<]*\/ap\/users\/band\/tracks\/t1<\/guid>/);
|
|---|
| 79 | });
|
|---|
| 80 |
|
|---|
| 81 | test('posts en tracks staan door elkaar op datum, nieuwste eerst', () => {
|
|---|
| 82 | const titels = [...xml.matchAll(/<title>([^<]*)<\/title>/g)].map((m) => m[1]).slice(1); // [0] is de kanaaltitel
|
|---|
| 83 | assert.deepEqual(titels, ['Tweede open', 'Nieuw bericht', 'Het open nummer', 'Oud bericht']);
|
|---|
| 84 | });
|
|---|
| 85 |
|
|---|
| 86 | test.after(() => server.close());
|
|---|