source: Klonkt/test/ap-fw-track.test.js@ 9026de5

main
Last change on this file since 9026de5 was 9026de5, checked in by Robin <roboburr@…>, 3 weeks ago

Album: published en released lenen de datum van de uitgavepost

Robins voorstel, en mijn eerste antwoord was te voorzichtig. Ik wilde
released leeg laten omdat een plaat uit 2018 die je vandaag post dan
zou beweren vandaag uit te komen. Dat gebeurt ook -- maar bij de meeste
Klonkt-sites IS de post het uitbrengen, en GEEN datum is slechter dan
een datum die op het gewone geval klopt. Het handmatige veld is precies
het gereedschap voor de uitzondering, en dat wint altijd.

year vult nog steeds niets aan, en dat is geen inconsequentie: een
jaartal is geen dag, terwijl de postdatum een gebeurtenis is die
werkelijk heeft plaatsgevonden. Verzinnen versus afleiden.

GEPLANDE POSTS, Robins tweede vraag. Het pad klopt op alle drie de
schakels: zolang de post 'scheduled' is vindt uitgavePost hem niet (die
filtert op status='published') en heeft de plaat dus nog geen
uitgavedatum -- hij is immers nog niet uit. De Scheduler zet daarna
published_at op COALESCE(published_at, publish_at, CURRENT_TIMESTAMP),
oftewel op de GEPLANDE tijd, en wij lezen COALESCE(published_at,
created_at). Een server die een uur plat lag levert dus geen uur te late
uitgavedatum op, en created_at wint nooit bij een gepubliceerde post.
Er staat nu een test die precies dat afloopt, met een worker die expres
te laat draait.

uitgavePost wordt in buildPlaylistCollection nog EEN keer aangeroepen en
twee keer gebruikt: het Album leent er zijn datums en titel van,
leenVanPost zijn tekst en tags. Twee losse aanroepen zouden uiteen kunnen
lopen, en dan staat er weer iets anders op het ingesloten object dan op
zijn eigen URI -- de fout van een uur geleden.

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

  • Property mode set to 100644
File size: 14.5 KB
Line 
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.
13import { test } from 'node:test';
14import assert from 'node:assert/strict';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://ons.test';
18
19const dbMod = await import('../src/config/database.js');
20const db = dbMod.default;
21dbMod.initializeDatabase();
22const AP = await import('../src/services/ActivityPubService.js');
23
24const BASE = 'https://ons.test';
25db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
26 .run('u1', 'u1', 'u1@t', 'x', 'god');
27db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
28 .run('s1', 'band', 'De Band', 'u1');
29const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
30
31db.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);
33db.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.
36db.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);
38db.prepare('INSERT INTO audio_tracks (id, site_id, title, media_id, fedi_open) VALUES (?,?,?,?,?)')
39 .run('t2', 's1', 'Zonder plek', 'm2', 1);
40
41const audio = (id) => AP.buildTrackAudio(BASE, site, AP.openTrack('s1', id), { hostPosts: null });
42
43test('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
51test('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
57test('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
69test('geen verzonnen positie', () => {
70 assert.equal(audio('t2').track.position, undefined);
71 assert.equal(audio('t2').track.name, 'Zonder plek');
72});
73
74test('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
84test('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
102test('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
115test('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
122test('de track heeft published -- MusicEntitySerializer eist het', () => {
123 assert.ok(audio('t1').track.published);
124});
125
126test('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
137test('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
153db.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();
155db.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.
157db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('de-mix','s1','De Mix','playlist')").run();
158db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('de-mix','t2',1)").run();
159
160test('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
176test('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
184test('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
190test('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
200test('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
207test('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
221test('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
229test('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
239test('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});
Note: See TracBrowser for help on using the repository browser.