| 1 | // Opengezette audio onder een BETAALDE post (Robin, 24-8).
|
|---|
| 2 | //
|
|---|
| 3 | // Aanleiding: boiert.eu/introducing-this-machine — een mixtape, betaald, met
|
|---|
| 4 | // `fedi_open` audio. De vroege return in buildNote gooide ALLE bijlagen weg,
|
|---|
| 5 | // dus ook het Mixtape-object. De nummers federeerden ondertussen gewoon los als
|
|---|
| 6 | // eigen Audio-objecten met hun `context` naar die post, en in de hub viel het
|
|---|
| 7 | // bandje daardoor uiteen in vier losse nummers onder een kale teaserkaart.
|
|---|
| 8 | //
|
|---|
| 9 | // Wat de poort tegenhield was dus niet inhoud maar VORM: de volgorde en het
|
|---|
| 10 | // feit dat het een cassette is. Deze tests leggen de nieuwe grens vast:
|
|---|
| 11 | // - de tekst blijft achter de muur, altijd;
|
|---|
| 12 | // - `fedi_open` audio gaat mee, met het bandje eromheen;
|
|---|
| 13 | // - audio die NIET open staat gaat niet mee, ook niet als losse bijlage;
|
|---|
| 14 | // - en wat federeert is op de site zelf ook te horen (de poortpagina).
|
|---|
| 15 | import { test } from 'node:test';
|
|---|
| 16 | import assert from 'node:assert/strict';
|
|---|
| 17 |
|
|---|
| 18 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 19 | process.env.PUBLIC_BASE_URL = 'https://ons.test';
|
|---|
| 20 |
|
|---|
| 21 | const dbMod = await import('../src/config/database.js');
|
|---|
| 22 | const db = dbMod.default;
|
|---|
| 23 | dbMod.initializeDatabase();
|
|---|
| 24 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 25 | const { paidOpenAudioHtml } = await import('../src/routes/posts.js');
|
|---|
| 26 |
|
|---|
| 27 | const BASE = 'https://ons.test';
|
|---|
| 28 | const GEHEIM = 'DIT IS DE BETAALDE TEKST DIE NERGENS MAG STAAN';
|
|---|
| 29 |
|
|---|
| 30 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 31 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 32 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
|
|---|
| 33 | .run('s1', 'band', 'De Band', 'u1');
|
|---|
| 34 | const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
|
|---|
| 35 |
|
|---|
| 36 | // Twee open nummers in een bandje...
|
|---|
| 37 | const media = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
|
|---|
| 38 | const track = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open) VALUES (?,?,?,?,?,?,?)');
|
|---|
| 39 | media.run('m1', 's1', 'een.mp3', 'a/een.mp3', 'audio/mpeg', 100);
|
|---|
| 40 | media.run('m2', 's1', 'twee.mp3', 'a/twee.mp3', 'audio/mpeg', 100);
|
|---|
| 41 | track.run('t1', 's1', 'Kant A', 'De Band', 60, 'm1', 1);
|
|---|
| 42 | track.run('t2', 's1', 'Kant B', 'De Band', 60, 'm2', 1);
|
|---|
| 43 | db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('bandje','s1','Het Bandje','mixtape')").run();
|
|---|
| 44 | db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('bandje','t1',1)").run();
|
|---|
| 45 | db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('bandje','t2',2)").run();
|
|---|
| 46 |
|
|---|
| 47 | // ...en eentje dat dicht blijft.
|
|---|
| 48 | media.run('m9', 's1', 'dicht.mp3', 'a/dicht.mp3', 'audio/mpeg', 100);
|
|---|
| 49 | track.run('t9', 's1', 'Gesloten', 'De Band', 60, 'm9', 0);
|
|---|
| 50 |
|
|---|
| 51 | function maak({ id, slug, muziek, paid = 1 }) {
|
|---|
| 52 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content,
|
|---|
| 53 | status, published_at, paid) VALUES (?,?,?,?,?,?,?,'published',?,?)`)
|
|---|
| 54 | .run(id, 's1', 'u1', slug, 'Titel', 'Een teaser.',
|
|---|
| 55 | `<p>${GEHEIM}</p>${muziek}`, '2026-08-24T12:00:00Z', paid);
|
|---|
| 56 | return db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
|
|---|
| 57 | }
|
|---|
| 58 |
|
|---|
| 59 | const alleTekst = (o) => JSON.stringify(o);
|
|---|
| 60 | const bijlagen = (n) => (Array.isArray(n.attachment) ? n.attachment : (n.attachment ? [n.attachment] : []));
|
|---|
| 61 |
|
|---|
| 62 | test('een betaald bandje federeert zijn open nummers EN de cassette', () => {
|
|---|
| 63 | const note = AP.buildNote(BASE, site, maak({ id: 'p1', slug: 'bandje-betaald', muziek: '<p>[[playlist:bandje]]</p>' }));
|
|---|
| 64 | const bij = bijlagen(note);
|
|---|
| 65 |
|
|---|
| 66 | const audio = bij.filter((a) => a.type === 'Audio');
|
|---|
| 67 | assert.equal(audio.length, 2, 'beide open nummers horen mee te reizen');
|
|---|
| 68 | assert.deepEqual(audio.map((a) => a.name), ['Kant A', 'Kant B']);
|
|---|
| 69 | assert.ok(audio.every((a) => a.url.startsWith(`${BASE}/audio/stream/`)), 'het bestand zelf, niet een link naar de poort');
|
|---|
| 70 |
|
|---|
| 71 | const tape = bij.find((a) => a.type === 'Mixtape');
|
|---|
| 72 | assert.ok(tape, 'zonder dit object valt het bandje in de hub uiteen in losse nummers');
|
|---|
| 73 | assert.equal(tape.totalItems, 2);
|
|---|
| 74 | assert.equal(tape.orderedItems.length, 2, 'de VOLGORDE is het werk; die hoort erin te staan');
|
|---|
| 75 | });
|
|---|
| 76 |
|
|---|
| 77 | test('en de tekst blijft ondertussen gewoon achter de muur', () => {
|
|---|
| 78 | const note = AP.buildNote(BASE, site, maak({ id: 'p2', slug: 'bandje-betaald-2', muziek: '<p>[[playlist:bandje]]</p>' }));
|
|---|
| 79 | assert.ok(!alleTekst(note).includes(GEHEIM), 'de betaalde inhoud staat nergens in het object');
|
|---|
| 80 | assert.match(note.content, /Een teaser/);
|
|---|
| 81 | assert.match(note.content, /Lees de volledige post/);
|
|---|
| 82 | });
|
|---|
| 83 |
|
|---|
| 84 | test('audio die NIET is opengezet reist niet mee', () => {
|
|---|
| 85 | const note = AP.buildNote(BASE, site, maak({ id: 'p3', slug: 'dicht', muziek: '<p>[[track:t9]]</p>' }));
|
|---|
| 86 | assert.equal(bijlagen(note).length, 0, 'een gesloten nummer hoort de deur niet uit te komen');
|
|---|
| 87 | });
|
|---|
| 88 |
|
|---|
| 89 | test('een betaalde post zonder muziek verandert niet: geen bijlagen', () => {
|
|---|
| 90 | const note = AP.buildNote(BASE, site, maak({ id: 'p4', slug: 'kaal', muziek: '' }));
|
|---|
| 91 | assert.equal(bijlagen(note).length, 0);
|
|---|
| 92 | });
|
|---|
| 93 |
|
|---|
| 94 | test('een gewone (niet-betaalde) post houdt dezelfde bijlagen', () => {
|
|---|
| 95 | // De helper wordt door BEIDE takken gebruikt; deze test bewaakt dat het
|
|---|
| 96 | // uitlichten ervan de gewone tak niet stilletjes heeft leeggehaald.
|
|---|
| 97 | const note = AP.buildNote(BASE, site, maak({ id: 'p5', slug: 'open-bandje', muziek: '<p>[[playlist:bandje]]</p>', paid: 0 }));
|
|---|
| 98 | const bij = bijlagen(note);
|
|---|
| 99 | assert.equal(bij.filter((a) => a.type === 'Audio').length, 2);
|
|---|
| 100 | assert.ok(bij.some((a) => a.type === 'Mixtape'), 'het bandje hangt er nog steeds aan');
|
|---|
| 101 | assert.ok(alleTekst(note).includes(GEHEIM), 'en de inhoud van een OPEN post federeert gewoon');
|
|---|
| 102 | });
|
|---|
| 103 |
|
|---|
| 104 | // ── de poortpagina op de site zelf ───────────────────────────────────────────
|
|---|
| 105 | // Wat de hub kan afspelen hoort de site zelf ook te kunnen afspelen. Andersom
|
|---|
| 106 | // stond de muziek overal behalve op de plek die hem uitbrengt.
|
|---|
| 107 |
|
|---|
| 108 | test('de poort toont de speler van een open bandje', () => {
|
|---|
| 109 | const post = maak({ id: 'p6', slug: 'poort', muziek: '<p>[[playlist:bandje]]</p>' });
|
|---|
| 110 | const html = paidOpenAudioHtml(site, post, { session: null });
|
|---|
| 111 | assert.ok(html, 'de poort hoort hier een speler te krijgen');
|
|---|
| 112 | assert.ok(html.includes('Kant A') && html.includes('Kant B'), 'met de nummers erin');
|
|---|
| 113 | assert.ok(!html.includes(GEHEIM), 'maar NOOIT met de betaalde tekst');
|
|---|
| 114 | assert.ok(!html.includes('Een teaser'), 'en ook niet met de rest van de post');
|
|---|
| 115 | });
|
|---|
| 116 |
|
|---|
| 117 | test('de poort toont niets als de audio dicht staat', () => {
|
|---|
| 118 | const post = maak({ id: 'p7', slug: 'poort-dicht', muziek: '<p>[[track:t9]]</p>' });
|
|---|
| 119 | assert.equal(paidOpenAudioHtml(site, post, { session: null }), '');
|
|---|
| 120 | });
|
|---|
| 121 |
|
|---|
| 122 | test('half open is dicht: een gesloten nummer sluit de hele speler', () => {
|
|---|
| 123 | // Een shortcode rendert zijn hele lijst. Bij een half-open bandje zou de
|
|---|
| 124 | // speler ook het gesloten bestand krijgen, en /audio/stream laat een
|
|---|
| 125 | // gelijke-oorsprong-fetch door -- dat is een echt lek, geen theoretisch.
|
|---|
| 126 | const post = maak({ id: 'p8', slug: 'half', muziek: '<p>[[playlist:bandje]]</p><p>[[track:t9]]</p>' });
|
|---|
| 127 | assert.equal(paidOpenAudioHtml(site, post, { session: null }), '');
|
|---|
| 128 | });
|
|---|
| 129 |
|
|---|
| 130 | test('de poortpagina print de speler ook echt', async () => {
|
|---|
| 131 | const fs = await import('node:fs');
|
|---|
| 132 | const bron = fs.readFileSync('src/views/pages/paid-gate.ejs', 'utf8');
|
|---|
| 133 | assert.match(bron, /pgAudio/, 'zonder dit blijft de speler in de route steken');
|
|---|
| 134 | assert.match(bron, /<%- pgAudio %>/, 'ontsnapt hij, dan staat er HTML als tekst op het scherm');
|
|---|
| 135 | });
|
|---|