| 1 | // `fw:track` op onze Audio-objecten (shaer-3f8a, spoor B stap 1).
|
|---|
| 2 | //
|
|---|
| 3 | // Twee onafhankelijke implementaties lezen dit veld: Funkwhale (verplicht in
|
|---|
| 4 | // UploadSerializer) en Emissary 0.9.0, gemeten op bandwagon.fm 16-8. Emissary
|
|---|
| 5 | // stuurt de kleine vorm -- type, id, name, album, position -- en die nemen we
|
|---|
| 6 | // over, zonder album zolang dat bij ons geen object is (shaer-k37k).
|
|---|
| 7 | //
|
|---|
| 8 | // Wat deze test vastlegt is vooral het ID: het nummer krijgt een EIGEN
|
|---|
| 9 | // identiteit met #track, niet die van het bestand. Emissary hergebruikt daar
|
|---|
| 10 | // het object-id, en dan zijn de Audio en de Track in JSON-LD een knoop met twee
|
|---|
| 11 | // typen. Wie dat 'opruimt' naar het kortere id haalt dat verschil weg, en dan
|
|---|
| 12 | // staat er dat een mp3 een muziekstuk IS.
|
|---|
| 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://ons.test';
|
|---|
| 18 |
|
|---|
| 19 | const dbMod = await import('../src/config/database.js');
|
|---|
| 20 | const db = dbMod.default;
|
|---|
| 21 | dbMod.initializeDatabase();
|
|---|
| 22 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 23 |
|
|---|
| 24 | const BASE = 'https://ons.test';
|
|---|
| 25 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 26 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 27 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
|
|---|
| 28 | .run('s1', 'band', 'De Band', 'u1');
|
|---|
| 29 | const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
|
|---|
| 30 |
|
|---|
| 31 | db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)')
|
|---|
| 32 | .run('m1', 's1', 'een.mp3', 'a/een.mp3', 'audio/mpeg', 2480880);
|
|---|
| 33 | db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open, position) VALUES (?,?,?,?,?,?,?,?)')
|
|---|
| 34 | .run('t1', 's1', 'Het nummer', 'De Band', 103, 'm1', 1, 3);
|
|---|
| 35 | // Eentje zonder positie: die mag het veld niet verzinnen.
|
|---|
| 36 | db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)')
|
|---|
| 37 | .run('m2', 's1', 'twee.mp3', 'a/twee.mp3', 'audio/mpeg', 100);
|
|---|
| 38 | db.prepare('INSERT INTO audio_tracks (id, site_id, title, media_id, fedi_open) VALUES (?,?,?,?,?)')
|
|---|
| 39 | .run('t2', 's1', 'Zonder plek', 'm2', 1);
|
|---|
| 40 |
|
|---|
| 41 | const audio = (id) => AP.buildTrackAudio(BASE, site, AP.openTrack('s1', id), { hostPosts: null });
|
|---|
| 42 |
|
|---|
| 43 | test('elke Audio draagt een track met de kleine Emissary-vorm', () => {
|
|---|
| 44 | const a = audio('t1');
|
|---|
| 45 | assert.ok(a.track, 'geen track op het object');
|
|---|
| 46 | assert.equal(a.track.type, 'Track');
|
|---|
| 47 | assert.equal(a.track.name, 'Het nummer');
|
|---|
| 48 | assert.equal(a.track.position, 3);
|
|---|
| 49 | });
|
|---|
| 50 |
|
|---|
| 51 | test('de track heeft een EIGEN id, niet dat van het bestand', () => {
|
|---|
| 52 | const a = audio('t1');
|
|---|
| 53 | assert.equal(a.track.id, `${a.id}#track`);
|
|---|
| 54 | assert.notEqual(a.track.id, a.id, 'een bestand is geen werk');
|
|---|
| 55 | });
|
|---|
| 56 |
|
|---|
| 57 | test('een track zonder uitgave draagt GEEN album', () => {
|
|---|
| 58 | // Dit stond er in stap 1 als "nog geen album, want we hebben er geen object
|
|---|
| 59 | // voor". Sinds stap 2 hebben we dat wel -- de album-playlist -- en is de regel
|
|---|
| 60 | // scherper: geen uitgave, geen veld. De tekstkolom `album` op de track blijft
|
|---|
| 61 | // buiten de draad, want dat is een label en geen adres.
|
|---|
| 62 | db.prepare("INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES ('m9','s1','los.mp3','a/los.mp3','audio/mpeg',10)").run();
|
|---|
| 63 | db.prepare("INSERT INTO audio_tracks (id, site_id, title, album, media_id, fedi_open) VALUES ('t9','s1','Losse track','Een Albumnaam','m9',1)").run();
|
|---|
| 64 | const a = audio('t9');
|
|---|
| 65 | assert.equal(a.track.album, undefined, 'een albumNAAM is geen album');
|
|---|
| 66 | assert.equal(a.album, undefined);
|
|---|
| 67 | });
|
|---|
| 68 |
|
|---|
| 69 | test('geen verzonnen positie', () => {
|
|---|
| 70 | assert.equal(audio('t2').track.position, undefined);
|
|---|
| 71 | assert.equal(audio('t2').track.name, 'Zonder plek');
|
|---|
| 72 | });
|
|---|
| 73 |
|
|---|
| 74 | test('de termen staan in de context, anders valt het veld weg', async () => {
|
|---|
| 75 | const core = await import('../src/services/ap-core.js');
|
|---|
| 76 | const term = core.AP_CONTEXT.find((x) => x && typeof x === 'object' && x.track);
|
|---|
| 77 | assert.ok(term, 'track staat in de context');
|
|---|
| 78 | // Letterlijk de vorm uit hun contexts.py (regel 293-306), zodat een lezer die
|
|---|
| 79 | // hun context laadt op dezelfde IRI's uitkomt als een lezer die de onze leest.
|
|---|
| 80 | assert.deepEqual(term.track, { '@id': 'fw:track', '@type': '@id' });
|
|---|
| 81 | assert.equal(term.Track, 'fw:Track');
|
|---|
| 82 | });
|
|---|
| 83 |
|
|---|
| 84 | test('de track komt overal mee, niet alleen op de losse ophaal', () => {
|
|---|
| 85 | // Bibliotheek, trackcollectie en losse ophaal bouwen alle drie via
|
|---|
| 86 | // buildTrackAudio -- dit is de test die dat vasthoudt als iemand er een
|
|---|
| 87 | // eigen bouwertje naast zet.
|
|---|
| 88 | const lib = AP.buildLibrary(BASE, site, AP.siteOpenTracks('s1'));
|
|---|
| 89 | for (const a of lib.items) assert.equal(a.track.type, 'Track', `${a.name} zonder track`);
|
|---|
| 90 | const col = AP.buildTrackCollection(BASE, site, AP.siteOpenTracks('s1'));
|
|---|
| 91 | for (const a of col.orderedItems) assert.equal(a.track.type, 'Track', `${a.name} zonder track`);
|
|---|
| 92 | });
|
|---|
| 93 |
|
|---|
| 94 | // ── artist_credit (shaer-3f8a, het gat naar hun ingest) ──────────────
|
|---|
| 95 | //
|
|---|
| 96 | // TrackSerializer (regel 1569) eist `artist_credit` met min_length=1, en elke
|
|---|
| 97 | // ArtistCredit eist een Artist die zelf id, name en published nodig heeft
|
|---|
| 98 | // (MusicEntitySerializer, regel 1278). Dat leek onmogelijk zolang een artiest
|
|---|
| 99 | // bij ons tekst was -- tot de MusicBrainz-koppeling liet zien dat de ACTOR de
|
|---|
| 100 | // artiest is.
|
|---|
| 101 |
|
|---|
| 102 | test('de track draagt een artist_credit met een echte Artist', () => {
|
|---|
| 103 | const a = audio('t1');
|
|---|
| 104 | assert.ok(Array.isArray(a.track.artist_credit), 'geen lijst');
|
|---|
| 105 | assert.equal(a.track.artist_credit.length, 1);
|
|---|
| 106 | const ac = a.track.artist_credit[0];
|
|---|
| 107 | assert.equal(ac.type, 'ArtistCredit');
|
|---|
| 108 | assert.ok(ac.id && ac.published, 'ArtistCredit mist id of published');
|
|---|
| 109 | assert.equal(ac.artist.type, 'Artist');
|
|---|
| 110 | assert.equal(ac.artist.id, `${BASE}/ap/users/band`, 'de actor IS de artiest');
|
|---|
| 111 | assert.equal(ac.artist.name, 'De Band');
|
|---|
| 112 | assert.ok(ac.artist.published, 'Artist mist published');
|
|---|
| 113 | });
|
|---|
| 114 |
|
|---|
| 115 | test('de artiestnaam van de track gaat naar credit, niet naar de entiteit', () => {
|
|---|
| 116 | // De kolom is een credittekst, geen identiteit. Er een id van maken zou
|
|---|
| 117 | // dezelfde fout zijn als bij het album (shaer-756s).
|
|---|
| 118 | assert.equal(audio('t1').track.artist_credit[0].credit, 'De Band');
|
|---|
| 119 | assert.equal(audio('t2').track.artist_credit[0].credit, undefined, 'geen artiest, geen credit');
|
|---|
| 120 | });
|
|---|
| 121 |
|
|---|
| 122 | test('de track heeft published -- MusicEntitySerializer eist het', () => {
|
|---|
| 123 | assert.ok(audio('t1').track.published);
|
|---|
| 124 | });
|
|---|
| 125 |
|
|---|
| 126 | test('artist_credit is een @list in de context, anders vindt hun lezer niets', async () => {
|
|---|
| 127 | // Ze lezen dit met first_attr(FW.artist_credit, "@list"). Zonder de
|
|---|
| 128 | // container-declaratie expandeert onze array niet naar een @list en staat er
|
|---|
| 129 | // iets dat er goed uitziet en niet gevonden wordt.
|
|---|
| 130 | const core = await import('../src/services/ap-core.js');
|
|---|
| 131 | const term = core.AP_CONTEXT.find((x) => x && typeof x === 'object' && x.artist_credit);
|
|---|
| 132 | assert.deepEqual(term.artist_credit, { '@id': 'fw:artist_credit', '@type': '@id', '@container': '@list' });
|
|---|
| 133 | assert.equal(term.ArtistCredit, 'fw:ArtistCredit');
|
|---|
| 134 | assert.equal(term.Artist, 'fw:Artist');
|
|---|
| 135 | });
|
|---|
| 136 |
|
|---|
| 137 | test('musicbrainzId komt mee zodra de site gekoppeld is', () => {
|
|---|
| 138 | assert.equal(audio('t1').track.artist_credit[0].artist.musicbrainzId, undefined);
|
|---|
| 139 | db.prepare("UPDATE sites SET mb_artist_id = '5441c29d-3602-4898-b1a1-b77fa23b8e50' WHERE id = 's1'").run();
|
|---|
| 140 | const s2 = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
|
|---|
| 141 | const a = AP.buildTrackAudio(BASE, s2, AP.openTrack('s1', 't1'), { hostPosts: null });
|
|---|
| 142 | assert.equal(a.track.artist_credit[0].artist.musicbrainzId, '5441c29d-3602-4898-b1a1-b77fa23b8e50');
|
|---|
| 143 | db.prepare("UPDATE sites SET mb_artist_id = NULL WHERE id = 's1'").run();
|
|---|
| 144 | });
|
|---|
| 145 |
|
|---|
| 146 | // ── het album ingesloten (shaer-756s, stap 2) ────────────────────────
|
|---|
| 147 | //
|
|---|
| 148 | // Hun TrackSerializer heeft `album = AlbumSerializer()`: een OBJECT met name,
|
|---|
| 149 | // published en een eigen artist_credit. Een kale URI expandeert naar een knoop
|
|---|
| 150 | // met alleen een @id en valt daar af -- dat is precies waarom Emissary's tracks
|
|---|
| 151 | // bij Funkwhale ook stranden.
|
|---|
| 152 |
|
|---|
| 153 | db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at)
|
|---|
| 154 | VALUES ('de-plaat','s1','De Plaat','De Band',2024,'/media/hoes.jpg','album','2024-03-15','7c5a9b2e-1111-4222-8333-944455556666','2026-01-01T00:00:00Z')`).run();
|
|---|
| 155 | db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('de-plaat','t1',1)").run();
|
|---|
| 156 | // t2 zit in een MIXTAPE en hoort dus geen album te krijgen.
|
|---|
| 157 | db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('de-mix','s1','De Mix','playlist')").run();
|
|---|
| 158 | db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('de-mix','t2',1)").run();
|
|---|
| 159 |
|
|---|
| 160 | test('een track op een uitgave draagt het album als OBJECT', () => {
|
|---|
| 161 | const a = audio('t1');
|
|---|
| 162 | const al = a.track.album;
|
|---|
| 163 | assert.ok(al && typeof al === 'object', 'album is geen object');
|
|---|
| 164 | assert.equal(al.type, 'Album');
|
|---|
| 165 | assert.equal(al.id, `${BASE}/ap/users/band/playlists/de-plaat`, 'het id is de bestaande collectie');
|
|---|
| 166 | assert.equal(al.name, 'De Plaat');
|
|---|
| 167 | assert.ok(al.published, 'MusicEntitySerializer eist published');
|
|---|
| 168 | assert.equal(al.released, '2024-03-15');
|
|---|
| 169 | assert.equal(al.musicbrainzId, '7c5a9b2e-1111-4222-8333-944455556666');
|
|---|
| 170 | assert.ok(Array.isArray(al.artist_credit) && al.artist_credit.length >= 1, 'album zonder artist_credit');
|
|---|
| 171 | assert.equal(al.image.type, 'Image');
|
|---|
| 172 | // Ook als URI op de Audio zelf -- Funkwhale 2.0 en Emissary doen dat allebei.
|
|---|
| 173 | assert.equal(a.album, al.id);
|
|---|
| 174 | });
|
|---|
| 175 |
|
|---|
| 176 | test('een track in een MIXTAPE krijgt geen album', () => {
|
|---|
| 177 | // Een afspeellijst is geen uitgave. Zou hij hier een album krijgen, dan was
|
|---|
| 178 | // het onderscheid album/playlist decoratie.
|
|---|
| 179 | const a = audio('t2');
|
|---|
| 180 | assert.equal(a.track.album, undefined);
|
|---|
| 181 | assert.equal(a.album, undefined);
|
|---|
| 182 | });
|
|---|
| 183 |
|
|---|
| 184 | test('track en album delen dezelfde artiest', () => {
|
|---|
| 185 | // Twee keer los bouwen is hoe ze uit elkaar gaan lopen; er is een functie.
|
|---|
| 186 | const a = audio('t1');
|
|---|
| 187 | assert.deepEqual(a.track.artist_credit[0].artist, a.track.album.artist_credit[0].artist);
|
|---|
| 188 | });
|
|---|
| 189 |
|
|---|
| 190 | test('de playlist-collectie draagt de albumvelden, met type als STRING', () => {
|
|---|
| 191 | const pl = db.prepare("SELECT * FROM playlists WHERE id = 'de-plaat'").get();
|
|---|
| 192 | const col = AP.buildPlaylistCollection(BASE, site, pl, AP.playlistOpenTracks('de-plaat'));
|
|---|
| 193 | assert.equal(typeof col.type, 'string', 'een array breekt lezers die type als tekst uitpakken');
|
|---|
| 194 | assert.equal(col.type, 'OrderedCollection');
|
|---|
| 195 | assert.equal(col.released, '2024-03-15');
|
|---|
| 196 | assert.equal(col.musicbrainzId, '7c5a9b2e-1111-4222-8333-944455556666');
|
|---|
| 197 | assert.ok(col.artist_credit);
|
|---|
| 198 | });
|
|---|
| 199 |
|
|---|
| 200 | test('een mixtape-collectie krijgt GEEN albumvelden', () => {
|
|---|
| 201 | const pl = db.prepare("SELECT * FROM playlists WHERE id = 'de-mix'").get();
|
|---|
| 202 | const col = AP.buildPlaylistCollection(BASE, site, pl, AP.playlistOpenTracks('de-mix'));
|
|---|
| 203 | assert.equal(col.released, undefined);
|
|---|
| 204 | assert.equal(col.artist_credit, undefined);
|
|---|
| 205 | });
|
|---|
| 206 |
|
|---|
| 207 | test('published EN released lenen de datum van de uitgavepost', () => {
|
|---|
| 208 | // De post is het moment van uitbrengen. Geen datum is slechter dan een datum
|
|---|
| 209 | // die op het gewone geval klopt; voor een heruitgave vul je het veld in.
|
|---|
| 210 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
|
|---|
| 211 | VALUES ('p-uit','s1','u1','de-plaat-uit','De Plaat Is Er','<p>[[playlist:de-plaat]]</p>','published','2026-05-04T12:00:00Z')`).run();
|
|---|
| 212 | // De fixture zette hierboven al een release_date; die wint terecht, dus voor
|
|---|
| 213 | // DEZE test moet hij weg. (Dat de eerste versie hier omviel is het bewijs dat
|
|---|
| 214 | // de voorrang werkt.)
|
|---|
| 215 | db.prepare("UPDATE playlists SET release_date = NULL WHERE id = 'de-plaat'").run();
|
|---|
| 216 | const al = audio('t1').track.album;
|
|---|
| 217 | assert.equal(al.published, '2026-05-04T12:00:00.000Z');
|
|---|
| 218 | assert.equal(al.released, '2026-05-04', 'released is een DAG, geen tijdstip');
|
|---|
| 219 | });
|
|---|
| 220 |
|
|---|
| 221 | test('een ingevulde uitgavedatum wint van de postdatum', () => {
|
|---|
| 222 | // Dit is het geval waarvoor het veld bestaat: oud werk dat je vandaag post.
|
|---|
| 223 | db.prepare("UPDATE playlists SET release_date = '2018-09-01' WHERE id = 'de-plaat'").run();
|
|---|
| 224 | const al = audio('t1').track.album;
|
|---|
| 225 | assert.equal(al.released, '2018-09-01', 'de postdatum overschreef het ingevulde veld');
|
|---|
| 226 | db.prepare("UPDATE playlists SET release_date = NULL WHERE id = 'de-plaat'").run();
|
|---|
| 227 | });
|
|---|
| 228 |
|
|---|
| 229 | test('geen post en geen veld: dan geen released', () => {
|
|---|
| 230 | // Afwezig is afwezig -- we leiden af waar er iets af te leiden valt, en
|
|---|
| 231 | // verzinnen niets waar dat niet zo is.
|
|---|
| 232 | db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('kaal','s1','Kale Plaat','album')").run();
|
|---|
| 233 | db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('kaal','t9',1)").run();
|
|---|
| 234 | const al = audio('t9').track.album;
|
|---|
| 235 | assert.equal(al.name, 'Kale Plaat');
|
|---|
| 236 | assert.equal(al.released, undefined);
|
|---|
| 237 | });
|
|---|
| 238 |
|
|---|
| 239 | test('een GEPLANDE post: de geplande dag telt, niet wanneer de worker draaide', () => {
|
|---|
| 240 | // Robins vraag (16-8). Drie schakels moeten kloppen:
|
|---|
| 241 | //
|
|---|
| 242 | // 1. zolang de post 'scheduled' is vindt uitgavePost hem NIET (die filtert
|
|---|
| 243 | // op status='published'), dus de plaat heeft nog geen uitgavedatum --
|
|---|
| 244 | // hij is immers nog niet uit;
|
|---|
| 245 | // 2. de Scheduler zet published_at op COALESCE(published_at, publish_at,
|
|---|
| 246 | // CURRENT_TIMESTAMP), dus op de GEPLANDE tijd;
|
|---|
| 247 | // 3. wij lezen COALESCE(published_at, created_at) en komen daar dus op uit.
|
|---|
| 248 | //
|
|---|
| 249 | // Dat derde punt is waarom created_at hier nooit wint: bij een gepubliceerde
|
|---|
| 250 | // post staat published_at altijd gevuld. En punt 2 is waarom een server die
|
|---|
| 251 | // een uur plat lag geen uur te late uitgavedatum oplevert.
|
|---|
| 252 | db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('gepland','s1','Geplande Plaat','album')").run();
|
|---|
| 253 | db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('gepland','t2',1)").run();
|
|---|
| 254 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, publish_at, created_at, published_at)
|
|---|
| 255 | VALUES ('p-gepland','s1','u1','komt-nog','Komt Nog','<p>[[playlist:gepland]]</p>','scheduled',
|
|---|
| 256 | '2026-12-24T09:00:00Z','2026-06-01T08:00:00Z',NULL)`).run();
|
|---|
| 257 |
|
|---|
| 258 | // Nog gepland: geen datum, want de plaat is nog niet uit.
|
|---|
| 259 | assert.equal(audio('t2').track.album.released, undefined, 'een geplande post bracht al iets uit');
|
|---|
| 260 |
|
|---|
| 261 | // De Scheduler doet zijn werk -- en draait expres LATER dan gepland.
|
|---|
| 262 | db.prepare(`UPDATE posts SET status = 'published',
|
|---|
| 263 | published_at = COALESCE(published_at, publish_at, CURRENT_TIMESTAMP) WHERE id = 'p-gepland'`).run();
|
|---|
| 264 |
|
|---|
| 265 | const al = audio('t2').track.album;
|
|---|
| 266 | assert.equal(al.released, '2026-12-24', 'niet de geplande dag');
|
|---|
| 267 | assert.equal(al.published, '2026-12-24T09:00:00.000Z');
|
|---|
| 268 | assert.notEqual(al.released, '2026-06-01', 'de creatiedatum lekte door');
|
|---|
| 269 | });
|
|---|