source: Klonkt/test/feed-enclosures.test.js@ 47ca083

main
Last change on this file since 47ca083 was 47ca083, checked in by Robin <roboburr@…>, 5 weeks ago

De feed draagt de muziek, niet alleen de wegwijzer ernaartoe

De actor adverteert /feed.xml als kanaal-feed sinds bdcb3a3. Maar die feed was
een blogfeed: twee items, NUL enclosures. Een podcast-app zoekt precies op
<enclosure>, dus voor hem was hij leeg hoe veel items er ook in stonden. Ik had
de wegwijzer gebouwd zonder te kijken waar hij heen wees.

Elke opengezette track is nu een eigen item met een enclosure: url, lengte in
bytes uit media.size, en het echte mime-type. Plus itunes:duration, want een
lezer die een lengte kan tonen hoort er een te krijgen.

EEN ITEM PER TRACK, en dat is geen stijlkeuze: RSS 2.0 staat maar EEN enclosure
per item toe. Een album in een item proppen zou van vijf nummers er vier laten
verdwijnen. Dezelfde tweedeling als in de outbox -- de post is het bericht, de
track is de publicatie -- en ze staan door elkaar op datum.

Geen <link> op een track-item: Klonkt heeft geen trackpagina, en een post over
vijf nummers is niet de pagina van dit ene nummer. De guid is daarom geen
permalink maar de stabiele AP-id van de track, zodat een lezer een track
herkent die hij al kende.

Welke tracks open zijn beslist siteOpenTracks in de AP-service, niet deze
route: die poortregel hoort op een plek te staan, en de test bewaakt dat een
gesloten track titel noch bestandsnaam lekt.

6 tests.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 4.3 KB
Line 
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.
7import { test } from 'node:test';
8import assert from 'node:assert/strict';
9
10process.env.DATABASE_PATH = ':memory:';
11process.env.PUBLIC_BASE_URL = 'https://test.example';
12
13const dbMod = await import('../src/config/database.js');
14const db = dbMod.default;
15dbMod.initializeDatabase();
16const express = (await import('express')).default;
17const feed = (await import('../src/routes/feed.js')).default;
18
19db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
20 .run('u1', 'zanger', 'zanger@t', 'x', 'god');
21db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, is_primary) VALUES (?,?,?,?,1,1)')
22 .run('s1', 'band', 'De Band', 'u1');
23
24db.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');
27db.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
31const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
32const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open, created_at) VALUES (?,?,?,?,?,?,?,?)');
33insM.run('m1', 's1', 'open.mp3', 'audio/open.mp3', 'audio/mpeg', 4210688);
34insM.run('m2', 's1', 'geheim.mp3', 'audio/geheim.mp3', 'audio/mpeg', 999);
35insM.run('m3', 's1', 'tweede.mp3', 'audio/tweede.mp3', 'audio/mpeg', 1024);
36insT.run('t1', 's1', 'Het open nummer', 'De Band', 203, 'm1', 1, '2026-03-01 12:00:00');
37insT.run('t2', 's1', 'Achter de poort', 'De Band', 100, 'm2', 0, '2026-04-01 12:00:00');
38insT.run('t3', 's1', 'Tweede open', 'De Band', 90, 'm3', 1, '2026-07-01 12:00:00');
39
40const app = express();
41app.use((req, res, next) => {
42 res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
43 res.locals.siteUrlBase = '';
44 next();
45});
46app.use(feed);
47const server = app.listen(0);
48await new Promise((r) => server.once('listening', r));
49const xml = await (await fetch(`http://127.0.0.1:${server.address().port}/feed.xml`)).text();
50
51test('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
58test('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
66test('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
72test('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
77test('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
81test('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
86test.after(() => server.close());
Note: See TracBrowser for help on using the repository browser.