source: Klonkt/test/fep1580-migration.test.js@ 47f1183

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

Een track zonder bestand is niet hetzelfde als een kapotte track

Robin, over het ene nummer dat niet meekwam: "ah, dat was een no audio 'audio'
voor alleen spotify en youtube links". Dat is een bestaand Klonkt-type, geen
ongelukje: geen gehost bestand, wel externe links, en buildNote maakt er een
embed-kaart van (trackEmbedLinks). Mijn regel "geen bestand, geen track" was te
grof en gooide hem weg.

"Geen bestand" en "niets om te tonen" zijn niet hetzelfde. De regel blijft
staan waar hij hoort: een track zonder bestand EN zonder links wordt nog steeds
geweigerd, want die kan niets.

Het archiefformaat heeft nu drie staten in plaats van twee: included, missing,
en linkOnly. Een link-only track telt dus ook niet meer als "ontbrekend", want
er ontbreekt niets.

EN EEN TWEEDE FOUT DIE HIERONDER LAG. De pull viste de audio-URL uit de
url-lijst met element NUL. buildTrackAudio zet daar een text/html-link naar de
post voor het bestand: wie blind de eerste pakt downloadt een HTML-pagina en
schrijft die weg als mp3. Nu wordt de link met mediaType audio/* gezocht, met
/audio/stream/ als terugval. Dat het bij Robin niet misging is geluk: zijn
tracks hadden geen host-post in die lijst.

Changed files:
src/services/ArchiveExportService.js

  • shaer:availability kent linkOnly; die telt niet als audioMissing

src/services/ArchiveImportService.js

  • link-only tracks komen mee, met media_id NULL zoals op de bron
  • de schrijftak kan een track zonder bestand aan

src/services/MigrationService.js

  • de pull vist de audio-link uit de lijst in plaats van element nul
  • een track met alleen externe links wordt gewoon aangemaakt

test/archive-audio.test.js

  • link-only reist mee; zonder bestand en zonder links blijft geweigerd

test/fep1580-migration.test.js

  • hetzelfde voor de ophaalknop, plus: de audio-link wordt gevist

remarks: Robins Youngstown-nummer komt hiermee alsnog over. Suite 1009 groen;
tegenproef op beide, en de eerste poging daarvan kwam niet aan (te slordige
sabotage), dus opnieuw gedaan met twee rode tests als bewijs.

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

  • Property mode set to 100644
File size: 43.0 KB
RevLine 
[fbfd7a1]1// FEP-1580: je objecten verhuizen mee met een Move.
2//
3// FEP-7628 verhuist je VOLGERS en zegt zelf dat de inhoud een ander probleem is.
4// Dit is dat probleem. Twee rollen, en ze horen bij elkaar: de BRON geeft de
5// nieuwe instantie zijn eigen kijkrechten, de DOELkant haalt op en publiceert
6// een vertaaltabel zodat derden hun verwijzingen kunnen bijwerken.
7//
8// Het hele vertrouwen hangt aan één ding: `moved_to` staat er alleen als
9// moveAccount() een terugverwijzing in alsoKnownAs zag. Dat is tweezijdig
10// bewijs, en daarom is er geen tweede mechanisme (geen token, geen code) nodig.
11// Deze tests leggen precies vast hoever die sleutel reikt, want een sleutel die
12// te ver reikt geeft je hele geschiedenis aan de verkeerde.
13import { test, beforeEach } from 'node:test';
14import assert from 'node:assert/strict';
[6c4ff7e]15import path from 'node:path';
[fbfd7a1]16
17process.env.DATABASE_PATH = ':memory:';
18process.env.PUBLIC_BASE_URL = 'https://nieuw.example';
19
20const dbMod = await import('../src/config/database.js');
21const db = dbMod.default;
22{
23 const stil = console.log;
24 console.log = () => {};
25 try { dbMod.initializeDatabase(); } finally { console.log = stil; }
26}
27const AP = await import('../src/services/ActivityPubService.js');
28const Mig = await import('../src/services/MigrationService.js');
29
30const BRON = 'https://oud.example/ap/users/robo';
31const IK = 'https://nieuw.example/ap/users/ik';
32
33function site({ aliases = [], movedTo = null } = {}) {
34 db.prepare('INSERT OR IGNORE INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
35 .run('u1', 'u1', 'u1@test', 'x', 'god');
36 db.prepare('INSERT OR IGNORE INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
37 .run('s1', 'ik', 'Mijn site', 'u1');
38 db.prepare('UPDATE sites SET ap_aliases = ?, moved_to = ? WHERE slug = ?')
39 .run(JSON.stringify(aliases), movedTo, 'ik');
40 return db.prepare('SELECT * FROM sites WHERE slug = ?').get('ik');
41}
42
43async function stil(fn) {
44 const w = console.warn; const l = console.log;
45 console.warn = () => {}; console.log = () => {};
46 try { return await fn(); } finally { console.warn = w; console.log = l; }
47}
48
49beforeEach(() => {
[6c4ff7e]50 for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers', 'audio_tracks', 'media']) {
[fbfd7a1]51 try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet in deze build */ }
52 }
53});
54
55// ── BRONKANT: hoever reikt de sleutel ─────────────────────────────
56
57test('isMoveTarget geldt alleen voor precies de actor waar we heen gingen', () => {
58 site({ movedTo: BRON });
59 assert.equal(AP.isMoveTarget('ik', BRON), true);
60 assert.equal(AP.isMoveTarget('ik', BRON + '2'), false, 'een prefix is geen match');
61 assert.equal(AP.isMoveTarget('ik', 'https://oud.example/ap/users/iemand'), false, 'zelfde host is niet genoeg');
62 assert.equal(AP.isMoveTarget('ik', ''), false);
63 assert.equal(AP.isMoveTarget('ik', null), false);
64});
65
66test('zonder verhuizing opent de sleutel niets', () => {
67 site({ movedTo: null });
68 assert.equal(AP.isMoveTarget('ik', BRON), false);
69 assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'public');
70});
71
72test('de doelinstantie krijgt de fan-only geschiedenis te zien', () => {
73 // Dit is de kern van de bronkant. Zonder deze tak haalt de nieuwe Klonkt
74 // alleen je publieke berichten op en blijft de rest achter op een domein
75 // dat je gaat opzeggen.
76 site({ movedTo: BRON });
77 assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'friend');
78 const post = { fan_only: 1 };
79 assert.equal(AP.mayReadNote({ slug: 'ik' }, post, BRON), true);
80 assert.equal(AP.mayReadNote({ slug: 'ik' }, post, 'https://elders.example/users/x'), false,
81 'een vreemde blijft buiten, ook tijdens een verhuizing');
82});
83
84test('een geblokkeerde actor wint van de verhuizing', () => {
85 // De volgorde in outboxAudience is niet toevallig: een block is een gesloten
86 // deur, en die gaat niet open omdat er toevallig een verhuizing loopt. Zou
87 // iemand ooit moved_to naar een geblokkeerd account zetten, dan hoort de
88 // blokkade te winnen en niet andersom.
89 site({ movedTo: BRON });
90 db.prepare("INSERT INTO ap_blocks (slug, target, kind) VALUES (?, ?, 'actor')").run('ik', BRON);
91 assert.equal(AP.outboxAudience('ik', { verifiedActor: BRON }), 'blocked');
92});
93
94test('direct-berichten gaan nooit over de lijn, ook niet bij een verhuizing', () => {
95 site({ movedTo: BRON });
96 const dm = { ap_visibility: 'direct' };
97 assert.equal(AP.mayReadNote({ slug: 'ik' }, dm, BRON), false,
98 'een DM is aan iemand gericht; die migreer je niet via een GET');
99});
100
101test('de outbox adresseert een fan-only post NIET als publiek', () => {
102 // Gevonden tijdens de end-to-end test van deze feature, maar het is een
103 // bestaande fout die er los van staat: outboxSlice haalde fan_only en
104 // ap_visibility niet op, dus buildNote zag post.fan_only === undefined en
105 // zette to: as:Public op ALLES. De post werd wel alleen aan vrienden
106 // geserveerd, maar met een publiek etiket erop, en dan mag een volger hem
107 // publiek boosten.
108 //
109 // Deze test hangt hier omdat de migratie erop leunt: de doelkant beslist aan
110 // de hand van dit adres of een bericht in de PUBLIEKE vertaaltabel mag.
111 const s = site();
112 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, fan_only, published_at)
113 VALUES ('pf', 's1', 'u1', 'fans', 'Fans', '<p>x</p>', 'published', 1, '2024-01-01T00:00:00Z')`).run();
114 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
115 VALUES ('pp', 's1', 'u1', 'open', 'Open', '<p>y</p>', 'published', '2024-01-02T00:00:00Z')`).run();
116
117 const { posts } = AP.outboxSlice('s1', { fanOnly: true, page: 1 });
118 const rij = (id) => posts.find((p) => p.id === id);
119 assert.ok(rij('pf'), 'de fan-only post hoort in de vrienden-outbox te zitten');
120 assert.equal(rij('pf').fan_only, 1, 'en zijn zichtbaarheid moet MEE uit de database komen');
121
122 const fan = AP.buildNote('https://nieuw.example', s, rij('pf'));
123 assert.equal(AP.noteVisibility(fan), 'followers',
124 'een fan-only post die as:Public heet mag een volger publiek boosten');
125 const open = AP.buildNote('https://nieuw.example', s, rij('pp'));
126 assert.equal(AP.noteVisibility(open), 'public', 'en een gewone post blijft gewoon publiek');
127});
128
129// ── DOELKANT: de collecties ───────────────────────────────────────
130
131test('de actor adverteert migration en moves, ook als er niets verhuisd is', () => {
132 // De FEP wijst hier apart op: zonder deze twee is "een verhuizing zonder
133 // objecten" niet te onderscheiden van "een server die dit niet kent".
134 const doc = AP.buildActor('https://nieuw.example', site());
135 assert.equal(doc.migration, `${IK}/migration`);
136 assert.equal(doc.moves, `${IK}/moves`);
137});
138
139test('een lege migration-collectie is geldig en zegt dat hij klaar is', () => {
140 const coll = Mig.buildMigration('https://nieuw.example', site());
141 assert.equal(coll.type, 'OrderedCollection');
142 assert.equal(coll.totalItems, 0);
143 assert.equal(coll.migrationComplete, true, 'niets te doen is ook klaar; anders blijven derden pollen');
144 assert.equal(coll.moves, `${IK}/moves`, 'de spec eist de verwijzing naar de moves-collectie');
145});
146
147test('de vertaaltabel mapt oud naar nieuw, nieuwste kopie eerst', () => {
148 const s = site();
149 Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a`, sourceActor: BRON });
150 Mig.recordMigrated('ik', { origin: `${BRON}/notes/2`, target: `${IK}/notes/b`, sourceActor: BRON });
151 const coll = Mig.buildMigration('https://nieuw.example', s);
152 assert.equal(coll.totalItems, 2);
153 const items = coll.orderedItems || coll.items;
154 assert.equal(items[0].origin, `${BRON}/notes/2`, 'omgekeerd chronologisch op aanmaakmoment HIER');
155 assert.equal(items[0].type, 'Move');
156 assert.equal(items[0].target, `${IK}/notes/b`);
157});
158
159test('niet-publieke items staan niet in de publieke vertaaltabel', () => {
160 // Een lijst met de URIs van je fan-only posts is een lek, ook zonder inhoud:
161 // hij verraadt hoeveel er zijn en wanneer ze kwamen.
162 const s = site();
163 Mig.recordMigrated('ik', { origin: `${BRON}/notes/pub`, target: `${IK}/notes/a`, isPublic: true });
164 Mig.recordMigrated('ik', { origin: `${BRON}/notes/geheim`, target: `${IK}/notes/b`, isPublic: false });
165 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 1);
166 assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 2);
167 const publiek = JSON.stringify(Mig.buildMigration('https://nieuw.example', s));
168 assert.ok(!publiek.includes('geheim'), 'de URI zelf mag er ook niet in staan');
169});
170
171test('dezelfde origin twee keer levert een rij, geen dubbele', () => {
172 const s = site();
173 Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a` });
174 Mig.recordMigrated('ik', { origin: `${BRON}/notes/1`, target: `${IK}/notes/a` });
175 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 1,
176 'een tweede ingest-ronde mag de tabel niet verdubbelen');
177});
178
179test('de moves-collectie bewaart het bron-actordocument erbij', () => {
180 // De spec: sluit de bron-actor in, zodat een lezer de herkomst kan nakijken
181 // ook als de bron onbereikbaar is. Dat is precies het geval waarvoor dit
182 // bestaat, dus een verwijzing naar de bron zou hier niets waard zijn.
183 const s = site();
184 const actorDoc = { id: BRON, type: 'Person', publicKey: { id: `${BRON}#main-key`, owner: BRON, publicKeyPem: 'x' } };
185 Mig.recordMove('ik', { moveId: `${BRON}#move`, sourceActor: BRON, targetActor: IK, activity: { type: 'Move' }, actorDoc });
186 const coll = Mig.buildMoves('https://nieuw.example', s);
187 assert.equal(coll.totalItems, 1);
188 assert.equal(coll.orderedItems[0].origin, BRON);
189 assert.equal(coll.orderedItems[0].target, IK);
190 assert.equal(coll.orderedItems[0].actor.publicKey.publicKeyPem, 'x', 'ingesloten, niet als verwijzing');
191});
192
193test('er staat GEEN handtekening onder de moves-collectie', () => {
194 // Bewuste keuze zolang shaer-j1v0 (FEP-8b32) open staat. Een leeg of nep
195 // proof-veld is erger dan geen veld: een derde die het controleert wordt dan
196 // misleid. Deze test valt om zodra we 8b32 bouwen, en dat is de bedoeling.
197 const s = site();
198 Mig.recordMove('ik', { moveId: `${BRON}#move`, sourceActor: BRON, targetActor: IK, activity: {} });
199 const coll = Mig.buildMoves('https://nieuw.example', s);
200 assert.equal(coll.proof, undefined,
201 'liever eerlijk niets dan een proof-veld dat niets bewijst (zie shaer-j1v0)');
202});
203
204// ── DOELKANT: de ingest ───────────────────────────────────────────
205
206// Een nep-bron, zodat dit zonder netwerk draait.
207function bronnetje({ movedTo = IK, items = [], blocked = null } = {}) {
208 const actor = {
209 id: BRON, type: 'Person', movedTo,
210 outbox: `${BRON}/outbox`,
211 ...(blocked ? { blocked: `${BRON}/blocked` } : {}),
212 };
213 const kaart = new Map([
214 [BRON, actor],
215 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: items }],
216 ...(blocked ? [[`${BRON}/blocked`, { type: 'OrderedCollection', orderedItems: blocked }]] : []),
217 ]);
218 // noteVisibility erbij: zonder deze telt alles als niet-publiek (fail-closed),
219 // en dan zou de test op de publieke vertaaltabel groen zijn om de verkeerde reden.
220 return {
221 getJson: async (_slug, url) => kaart.get(url) || null,
222 noteId: (b, id) => `${b}/ap/notes/${id}`,
223 noteVisibility: AP.noteVisibility,
224 };
225}
226
227function note(id, extra = {}) {
228 return {
229 type: 'Create',
230 object: {
231 id, type: 'Note', attributedTo: BRON, content: '<p>hallo</p>',
232 published: '2023-05-04T10:00:00Z', to: ['https://www.w3.org/ns/activitystreams#Public'],
233 ...extra,
234 },
235 };
236}
237
238test('de ingest weigert als de bron niet naar ONS wijst', async () => {
239 const s = site({ aliases: [BRON] });
240 const r = await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({ movedTo: 'https://iemandanders.example/users/x' }) }));
241 assert.equal(r.error, 'not_moved_here',
242 'anders kon je de geschiedenis opeisen van iedereen die toevallig verhuisde');
243});
244
245test('de ingest weigert zonder onze eigen terugverwijzing', async () => {
246 // Eén kant is een bewering, twee kanten is een afspraak. Dit is de aanval:
247 // iemand laat je een bron-adres invullen, die bron roept movedTo naar ons,
248 // en zonder deze controle trekken we andermans geschiedenis binnen als de
249 // onze. De bron wordt hier EXPLICIET meegegeven, want anders strandt hij al
250 // eerder op no_source en test je niets.
251 const s = site({ aliases: ['https://ietsanders.example/users/ik'] });
252 const r = await stil(() => Mig.ingestFromSource(s, { sourceUri: BRON, deps: bronnetje({}) }));
253 assert.equal(r.error, 'no_backreference');
254 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 0, 'en er is niets binnengekomen');
255});
256
257test('zonder alias en zonder opgegeven bron valt er niets op te halen', async () => {
258 const s = site({ aliases: [] });
259 const r = await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({}) }));
260 assert.equal(r.error, 'no_source');
261});
262
263test('de ingest haalt de berichten op en houdt de oorspronkelijke datum', async () => {
264 const s = site({ aliases: [BRON] });
265 const deps = bronnetje({ items: [note(`${BRON}/notes/1`), note(`${BRON}/notes/2`)] });
266 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
267 assert.equal(r.posts, 2);
268 const rijen = db.prepare('SELECT slug, published_at, origin_server FROM posts WHERE site_id = ? ORDER BY slug').all('s1');
269 assert.equal(rijen.length, 2);
270 assert.equal(rijen[0].published_at, '2023-05-04T10:00:00Z',
271 'een verhuisd bericht is niet vandaag geschreven; de spec eist behoud van de timestamps');
272 assert.equal(rijen[0].origin_server, 'migrated');
273 // en de vertaaltabel is gevuld, want dat is het punt van de hele operatie
274 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 2);
275});
276
277test('de ingest zet de vlag open tijdens en dicht na afloop', async () => {
278 const s = site({ aliases: [BRON] });
279 await stil(() => Mig.ingestFromSource(s, { deps: bronnetje({ items: [note(`${BRON}/notes/1`)] }) }));
280 assert.equal(Mig.migrationComplete('ik'), true, 'pas na afloop mag een derde stoppen met kijken');
281});
282
283test('een tweede ronde slaat over wat er al is', async () => {
284 const s = site({ aliases: [BRON] });
285 const deps = bronnetje({ items: [note(`${BRON}/notes/1`)] });
286 await stil(() => Mig.ingestFromSource(s, { deps }));
287 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
288 assert.equal(r.posts, 0);
289 assert.equal(r.overgeslagen, 1);
290 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 1, 'geen dubbele berichten');
291});
292
293test('blokkades komen eerst binnen', async () => {
294 // De spec zet dit expliciet vooraan: blokkades bepalen wie de rest te zien
295 // krijgt. Andersom importeer je je hele geschiedenis zichtbaar voor precies
296 // degene die je buiten wilde houden.
297 const s = site({ aliases: [BRON] });
298 const deps = bronnetje({ items: [note(`${BRON}/notes/1`)], blocked: ['https://elders.example/users/pest'] });
299 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
300 assert.equal(r.blocks, 1);
301 const b = db.prepare("SELECT target FROM ap_blocks WHERE slug = 'ik' AND kind = 'actor'").all();
302 assert.deepEqual(b.map((x) => x.target), ['https://elders.example/users/pest']);
303});
304
305test('de ingest neemt geen antwoorden en niets van een ander mee', async () => {
306 const s = site({ aliases: [BRON] });
307 const deps = bronnetje({
308 items: [
309 note(`${BRON}/notes/1`),
310 note(`${BRON}/notes/2`, { inReplyTo: 'https://elders.example/notes/9' }),
311 note(`${BRON}/notes/3`, { attributedTo: 'https://elders.example/users/ander' }),
312 ],
313 });
314 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
315 assert.equal(r.posts, 1, 'alleen je eigen toplevel-berichten verhuizen mee');
316});
317
[6c4ff7e]318test('de muziekbibliotheek komt mee, ook wat niet fedi_open is', async () => {
319 // Losse nummers staan NIET in de outbox: die hangen aan de tracks-collectie
320 // waar de actor via AS2 `streams` naar wijst. Zonder deze tak verhuist een
321 // muzieksite zijn berichten en laat hij zijn bibliotheek achter, en dat is
322 // precies wat er op soundfabrics.nl gebeurde.
323 const s = site({ aliases: [BRON] });
324 const AUDIO = `${BRON}/audio/x.mp3`;
325 const bytes = Buffer.from('ID3-nep-geluid');
326 const kaart = new Map([
327 [BRON, {
328 id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`,
329 streams: [`${BRON}/tracks`, `${BRON}/playlists`],
330 }],
331 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
332 [`${BRON}/tracks`, {
333 type: 'OrderedCollection',
334 orderedItems: [{
335 id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Gesloten nummer', artist: 'Robo',
336 url: [{ type: 'Link', href: AUDIO, mediaType: 'audio/mpeg' }],
337 }],
338 }],
339 ]);
340 const geschreven = new Map();
341 let getekend = false;
342 const r = await stil(() => Mig.ingestFromSource(s, {
343 deps: {
344 getJson: async (_slug, url) => kaart.get(url) || null,
345 noteId: (b, id) => `${b}/ap/notes/${id}`,
346 noteVisibility: AP.noteVisibility,
347 audioRoot: '/nep/audio',
348 // De handtekening is hier geen detail: de audio-route van de bron weigert
349 // een kale fetch, want die kan niet zien dat wij de doel-actor zijn.
350 signHeaders: () => { getekend = true; return { Signature: 'nep' }; },
351 safeFetch: async (url, opts) => {
352 assert.equal(url, AUDIO);
353 assert.ok(opts.headers && opts.headers.Signature, 'de bytes worden ONDERTEKEND opgehaald');
354 return { ok: true, arrayBuffer: async () => bytes, headers: { get: () => 'audio/mpeg' } };
355 },
356 fs: { mkdirSync() {}, writeFileSync: (p, b) => geschreven.set(p, b) },
357 path,
358 },
359 }));
360 assert.equal(r.tracksBinnen, 1);
361 assert.equal(r.tracksMislukt, 0);
362 assert.ok(getekend);
363 assert.equal(geschreven.size, 1, 'het bestand wordt echt weggeschreven');
364 const [pad] = [...geschreven.keys()];
365 assert.ok(pad.startsWith('/nep/audio/'), 'audio hoort in AUDIO_ROOT, niet in de mediamap');
366 assert.ok(!pad.slice('/nep/audio/'.length).includes('/'), 'en er direct in, want de speler zoekt op bestandsnaam');
367 const t = db.prepare('SELECT title, artist FROM audio_tracks').get();
368 assert.equal(t.title, 'Gesloten nummer');
369});
370
[f704237]371test('een link-only nummer komt ook via de ophaalknop mee', async () => {
372 // Dezelfde regel als bij de zip. Over AP heeft zo'n track geen audio-link in
373 // zijn url-lijst, alleen de externe. De vorige versie viste blind de eerste
374 // url en strandde daarop.
375 const s = site({ aliases: [BRON] });
376 const kaart = new Map([
377 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
378 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 0, first: `${BRON}/outbox?page=1` }],
379 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [] }],
380 [`${BRON}/tracks`, { type: 'OrderedCollection', orderedItems: [{
381 id: `${BRON}/tracks/lo`, type: 'Audio', name: 'Alleen links', summary: 'Youngstown',
382 url: [
383 { type: 'Link', href: 'https://oud.example/eenpost', mediaType: 'text/html' },
384 { type: 'Link', href: 'https://open.spotify.com/track/abc' },
385 { type: 'Link', href: 'https://www.youtube.com/watch?v=xyz' },
386 ],
387 }] }],
388 ]);
389 const gehaald = [];
390 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
391 getJson: async (_s, url) => kaart.get(url) || null,
392 noteId: (b, id) => `${b}/ap/notes/${id}`,
393 noteVisibility: AP.noteVisibility,
394 audioRoot: '/nep/audio', mediaRoot: '/nep/media',
395 signHeaders: () => ({ Signature: 'nep' }),
396 safeFetch: async (u) => { gehaald.push(u); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }; },
397 fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
398 path,
399 } }));
400 assert.equal(r.tracksBinnen, 1);
401 assert.equal(r.tracksMislukt, 0);
402 assert.equal(r.tracksLinks, 1);
403 assert.deepEqual(gehaald, [], 'er valt niets te downloaden, en dat hoort ook niet geprobeerd');
404 const t = db.prepare("SELECT title, artist, media_id, link_spotify, link_youtube FROM audio_tracks WHERE id = 'lo'").get();
405 assert.ok(t, 'de track staat er');
406 assert.equal(t.media_id, null);
407 assert.equal(t.artist, 'Youngstown', 'de artiest komt uit summary');
408 assert.equal(t.link_spotify, 'https://open.spotify.com/track/abc');
409 assert.equal(t.link_youtube, 'https://www.youtube.com/watch?v=xyz');
410});
411
412test('de audio-link wordt uit de lijst GEVIST, niet blind de eerste gepakt', async () => {
413 // buildTrackAudio zet een text/html-link naar de post VOOR het bestand. Wie
414 // element nul pakt downloadt een HTML-pagina en noemt dat een mp3.
415 const s = site({ aliases: [BRON] });
416 const MP3 = 'https://oud.example/audio/stream/x.mp3';
417 const kaart = new Map([
418 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
419 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 0, first: `${BRON}/outbox?page=1` }],
420 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [] }],
421 [`${BRON}/tracks`, { type: 'OrderedCollection', orderedItems: [{
422 id: `${BRON}/tracks/m1`, type: 'Audio', name: 'Met bestand',
423 url: [
424 { type: 'Link', href: 'https://oud.example/depost', mediaType: 'text/html' },
425 { type: 'Link', href: MP3, mediaType: 'audio/mpeg' },
426 ],
427 }] }],
428 ]);
429 const gehaald = [];
430 await stil(() => Mig.ingestFromSource(s, { deps: {
431 getJson: async (_s, url) => kaart.get(url) || null,
432 noteId: (b, id) => `${b}/ap/notes/${id}`,
433 noteVisibility: AP.noteVisibility,
434 audioRoot: '/nep/audio', mediaRoot: '/nep/media',
435 signHeaders: () => ({ Signature: 'nep' }),
436 safeFetch: async (u) => { gehaald.push(u); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }; },
437 fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
438 path,
439 } }));
440 assert.deepEqual(gehaald, [MP3], 'het bestand, niet de postpagina');
441});
442
[6c4ff7e]443test('een nummer waarvan de bytes niet komen levert GEEN track op', async () => {
444 // Dezelfde regel als bij de zip. Een nummer dat in de lijst staat en 404't is
445 // erger dan een nummer dat ontbreekt.
446 const s = site({ aliases: [BRON] });
447 const kaart = new Map([
448 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
449 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
450 [`${BRON}/tracks`, {
451 type: 'OrderedCollection',
452 orderedItems: [{ id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Weg', url: `${BRON}/audio/weg.mp3` }],
453 }],
454 ]);
455 const r = await stil(() => Mig.ingestFromSource(s, {
456 deps: {
457 getJson: async (_slug, url) => kaart.get(url) || null,
458 noteId: (b, id) => `${b}/ap/notes/${id}`,
459 noteVisibility: AP.noteVisibility,
460 audioRoot: '/nep/audio',
461 signHeaders: () => ({ Signature: 'nep' }),
462 safeFetch: async () => ({ ok: false, status: 403 }),
463 fs: { mkdirSync() {}, writeFileSync() { throw new Error('mag niet gebeuren'); } },
464 path,
465 },
466 }));
467 assert.equal(r.tracksBinnen, 0);
468 assert.equal(r.tracksMislukt, 1);
469 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0);
470});
471
[eb071a7]472test('een tweede ronde VULT AAN en slaat niet over', async () => {
473 // De val die Robin bijna in liep: de eerste versie van de ingest onthield per
474 // bron-URI "al gehad" en passeerde die daarna altijd. Kwam er later iets bij
475 // (hoezen, duur, playlists), dan kon je opnieuw drukken zoveel je wilde en
476 // gebeurde er niets. Opruimen hielp ook niet, want ap_migration hield de
477 // blokkade in stand. Dus: aanvullen, niet overslaan.
478 const s = site({ aliases: [BRON] });
479 const AUDIO = `${BRON}/audio/x.mp3`;
480 const HOES = `${BRON}/media/hoes.png`;
481 const maakKaart = (metHoes) => new Map([
482 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
483 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
484 [`${BRON}/tracks`, {
485 type: 'OrderedCollection',
486 orderedItems: [{
487 id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Nummer', summary: 'Robo', duration: 'PT212S',
488 url: [{ type: 'Link', href: AUDIO, mediaType: 'audio/mpeg' }],
489 ...(metHoes ? { icon: { type: 'Image', url: HOES } } : {}),
490 }],
491 }],
492 ]);
493 const gehaald = [];
494 const deps = (metHoes) => ({
495 getJson: async (_slug, url) => maakKaart(metHoes).get(url) || null,
496 noteId: (b, id) => `${b}/ap/notes/${id}`,
497 noteVisibility: AP.noteVisibility,
498 audioRoot: '/nep/audio', mediaRoot: '/nep/media',
499 signHeaders: () => ({ Signature: 'nep' }),
500 safeFetch: async (url) => {
501 gehaald.push(url);
502 return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } };
503 },
504 fs: { mkdirSync() {}, writeFileSync() {} },
505 path,
506 });
507
508 // Ronde 1: zonder hoes, zoals de eerste uitrol.
509 const r1 = await stil(() => Mig.ingestFromSource(s, { deps: deps(false) }));
510 assert.equal(r1.tracksBinnen, 1);
511 const na1 = db.prepare('SELECT id, cover_url, duration FROM audio_tracks').get();
512 assert.equal(na1.cover_url, null);
513 assert.equal(na1.duration, 212, 'PT212S hoort 212 seconden te worden');
514
515 // Ronde 2: nu MET hoes. Hij moet aanvullen, niet passeren en niet verdubbelen.
516 const voor = gehaald.length;
517 const r2 = await stil(() => Mig.ingestFromSource(s, { deps: deps(true) }));
518 assert.equal(r2.tracksBinnen, 0, 'er komt niets nieuws bij');
519 assert.equal(r2.tracksBijgewerkt, 1, 'maar het bestaande nummer wordt wel aangevuld');
520 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 1, 'geen dubbele');
521 const na2 = db.prepare('SELECT id, cover_url FROM audio_tracks').get();
522 assert.equal(na2.id, na1.id, 'dezelfde rij, niet een nieuwe');
523 assert.ok(na2.cover_url, 'en de hoes is er nu wel');
524 assert.ok(!gehaald.slice(voor).includes(AUDIO), 'het geluidsbestand wordt NIET opnieuw gedownload');
525
526 // Ronde 3: alles compleet, dus er valt niets meer aan te vullen.
527 const r3 = await stil(() => Mig.ingestFromSource(s, { deps: deps(true) }));
528 assert.equal(r3.tracksBijgewerkt, 0);
529 assert.equal(r3.overgeslagenTracks, 1);
530});
531
[f894be5]532test('de outbox-keten wordt gevolgd, ook als de kale collectie zelf items draagt', async () => {
533 // Robins 18 van 35. Klonkts kale outbox draagt first EN een kopie van
534 // pagina 1 (Pleroma eiste ooit een first, sindsdien staan ze er allebei).
535 // De ingest zag items, sloeg first over, vond daarna geen next (dat veld
536 // bestaat alleen op echte pagina's) en dacht klaar te zijn. Zonder een
537 // waarschuwing, en dat was het ergste deel.
538 const s = site({ aliases: [BRON] });
539 const maak = (i) => note(`${BRON}/notes/n${i}`);
540 const p1 = Array.from({ length: 20 }, (_, i) => maak(i + 1));
541 const p2 = Array.from({ length: 8 }, (_, i) => maak(i + 21));
542 const kaart = new Map([
543 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
544 // de valstrik: totalItems, first, EN de eerste twintig inline
545 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 28, first: `${BRON}/outbox?page=1`, orderedItems: p1 }],
546 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', next: `${BRON}/outbox?page=2`, orderedItems: p1 }],
547 [`${BRON}/outbox?page=2`, { type: 'OrderedCollectionPage', orderedItems: p2 }],
548 ]);
549 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
550 getJson: async (_s, url) => kaart.get(url) || null,
551 noteId: (b, id) => `${b}/ap/notes/${id}`,
552 noteVisibility: AP.noteVisibility,
553 } }));
554 assert.equal(r.posts, 28, 'alle pagina\'s, niet alleen de kopie op de kale collectie');
555});
556
557test('een niet opgehaalde pagina wordt GEMELD, niet verzwegen', async () => {
558 const s = site({ aliases: [BRON] });
559 const p1 = Array.from({ length: 20 }, (_, i) => note(`${BRON}/notes/n${i + 1}`));
560 const kaart = new Map([
561 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
562 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 28, first: `${BRON}/outbox?page=1` }],
563 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', next: `${BRON}/outbox?page=2`, orderedItems: p1 }],
564 // pagina 2 antwoordt niet (rate limit, netwerkstoring, wat dan ook)
565 ]);
566 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
567 getJson: async (_s, url) => kaart.get(url) || null,
568 noteId: (b, id) => `${b}/ap/notes/${id}`,
569 noteVisibility: AP.noteVisibility,
570 } }));
571 assert.equal(r.posts, 20);
572 assert.ok(r.waarschuwingen.some((w) => /28 items en er zijn er 20/.test(w)),
573 'stil minder ophalen dan de bron meldt is hoe 18 van 35 wekenlang op klaar had gestaan');
574});
575
576test('een plaatje in de tekst wordt gedownload en de verwijzing wordt relatief', async () => {
577 // Robins hotlinks. En de valkuil die de eerste versie half liet werken: de
578 // regex stond als STRING in een template literal, \s werd een kale s, en
579 // elke URL met een s erin (post-images!) knapte af. Vandaar de assert op de
580 // VOLLEDIGE url, letter voor letter.
581 const s = site({ aliases: [BRON] });
582 const IMG = `${BRON.replace('/ap/users/robo', '')}/media/post-images/plaatje-strak.png`;
583 const kaart = new Map([
584 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
585 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 1, first: `${BRON}/outbox?page=1` }],
586 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [
587 note(`${BRON}/notes/pi`, { content: `<p>kijk</p><a href="${IMG}"><img src="${IMG}"></a>` }),
588 ] }],
589 ]);
590 const opgehaald = [];
591 const geschreven = [];
592 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
593 getJson: async (_s, url) => kaart.get(url) || null,
594 noteId: (b, id) => `${b}/ap/notes/${id}`,
595 noteVisibility: AP.noteVisibility,
596 mediaRoot: '/nep/media', audioRoot: '/nep/audio',
597 signHeaders: () => ({ Signature: 'nep' }),
598 safeFetch: async (url) => { opgehaald.push(url); return { ok: true, arrayBuffer: async () => Buffer.from('png'), headers: { get: () => 'image/png' } }; },
599 fs: { mkdirSync() {}, writeFileSync: (pad) => geschreven.push(pad), statSync() { throw new Error('ENOENT'); } },
600 path,
601 } }));
602 assert.equal(r.posts, 1);
603 assert.deepEqual(opgehaald, [IMG], 'de VOLLEDIGE url, niet een afgekapt stuk');
604 assert.deepEqual(geschreven, ['/nep/media/post-images/plaatje-strak.png'], 'op het pad van de bron');
605 const c = db.prepare("SELECT content FROM posts WHERE site_id = 's1'").get().content;
606 assert.ok(c.includes('src="/media/post-images/plaatje-strak.png"'), `relatief herschreven, kreeg: ${c}`);
607 assert.ok(!c.includes('oud.example'), 'en er hotlinkt niets meer naar de bron');
608});
609
[9f9b45f]610test('de omslag komt ook mee als hij in `image` zit in plaats van in attachment', async () => {
611 // Robins 9 covers van de 36. Klonkt onderdrukt de beeldbijlage met opzet
612 // zodra een post een speler of embed heeft (noImages in buildNote), en zet
613 // de omslag dan in `image` zodat Mastodon zijn spelerkaart toont. 18 van de
614 // 20 berichten op pagina 1 hadden daardoor geen enkele bijlage, en toch een
615 // cover. Mijn ingest keek alleen naar attachment.
616 const s = site({ aliases: [BRON] });
617 const OMSLAG = 'https://oud.example/media/post-images/omslag.png';
618 const HOES = 'https://oud.example/media/audio-covers/hoes.webp';
619 const kaart = new Map([
620 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
621 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 2, first: `${BRON}/outbox?page=1` }],
622 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [
623 // een speler-post: GEEN attachment, omslag in image
624 note(`${BRON}/notes/speler`, { content: '<p>muziek</p>', image: { type: 'Image', url: OMSLAG } }),
625 // een audio-bijlage draagt zijn eigen hoes in icon
626 note(`${BRON}/notes/audio`, { content: '<p>track</p>', attachment: [
627 { type: 'Audio', mediaType: 'audio/mpeg', url: 'https://oud.example/audio/stream/x.mp3',
628 icon: { type: 'Image', mediaType: 'image/webp', url: HOES } },
629 ] }),
630 ] }],
631 ]);
632 const opgehaald = [];
633 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
634 getJson: async (_s, url) => kaart.get(url) || null,
635 noteId: (b, id) => `${b}/ap/notes/${id}`,
636 noteVisibility: AP.noteVisibility,
637 mediaRoot: '/nep/media', audioRoot: '/nep/audio',
638 signHeaders: () => ({ Signature: 'nep' }),
639 safeFetch: async (url) => { opgehaald.push(url); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'image/png' } }; },
640 fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
641 path,
642 } }));
643 assert.equal(r.posts, 2);
644 assert.ok(opgehaald.includes(OMSLAG), 'de omslag uit `image` hoort opgehaald te worden');
645 assert.ok(opgehaald.includes(HOES), 'en de hoes uit de icon van een Audio-bijlage ook');
646 const covers = db.prepare('SELECT cover_image_url c FROM posts WHERE site_id = ?').all('s1').map((x) => x.c);
647 assert.equal(covers.filter(Boolean).length, 2, 'beide berichten krijgen hun omslag');
648 assert.ok(covers.includes('/media/post-images/omslag.png'), 'op het pad van de bron');
649});
650
[861c228]651test('links naar de bronpost buigen om naar hier, in dezelfde ronde', async () => {
652 // De gebakken "luister op"-links: buildNote zet
653 // https://oud/<slug>#track-<id> en ?fc=2 in de content. Die bleven naar de
654 // oude site wijzen, en dat is een tijdbom: zodra dat domein weg is staan er
655 // dode links in je eigen berichten. Slugs en track-ids blijven behouden, dus
656 // de fragmenten kloppen vanzelf zodra de host eraf is.
657 const s = site({ aliases: [BRON] });
658 const OUD = 'https://oud.example';
659 const kaart = new Map([
660 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
661 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 2, first: `${BRON}/outbox?page=1` }],
662 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [
663 note(`${BRON}/notes/tiktik`, { url: `${OUD}/tiktik`, content:
664 `<p>🎵 <a href="${OUD}/tiktik#track-t1">TikTik</a> — <a href="${OUD}/tiktik?fc=2">luister</a></p>` }),
665 // een link naar iets dat we NIET hebben blijft met rust
666 note(`${BRON}/notes/ander`, { url: `${OUD}/ander`, content:
667 `<p>zie <a href="${OUD}/bestaat-hier-niet">daar</a></p>` }),
668 ] }],
669 ]);
670 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
671 getJson: async (_s, url) => kaart.get(url) || null,
672 noteId: (b, id) => `${b}/ap/notes/${id}`,
673 noteVisibility: AP.noteVisibility,
674 } }));
675 assert.equal(r.posts, 2);
676 const tik = db.prepare("SELECT content FROM posts WHERE slug = 'tiktik'").get().content;
677 assert.ok(tik.includes('href="/tiktik#track-t1"'), `fragment blijft heel, kreeg: ${tik}`);
678 assert.ok(tik.includes('href="/tiktik?fc=2"'), 'en de querystring ook');
679 assert.ok(!tik.includes('oud.example'), 'er wijst niets meer naar de oude site');
680 const ander = db.prepare("SELECT content FROM posts WHERE slug = 'ander'").get().content;
681 assert.ok(ander.includes(`${OUD}/bestaat-hier-niet`),
682 'een link naar iets dat hier niet bestaat blijft staan: beter de oude site dan een 404 op de nieuwe');
683 assert.equal(r.linksBijgetrokken, 1);
684});
685
[dc802b4]686test('de doelinstantie krijgt de RAUWE inhoud, met de shorthand er nog in', () => {
687 // Robins laatste klacht: de post kwam aan met "🎵 TikTik — listen on
688 // soundfabrics" en zonder speler. De bron BAKT de shorthand namelijk om voor
689 // de fediverse: [[track:]] wordt gestript en er komt achteraan een blok met
690 // hooguit VIER titels. Onherstelbaar dus: een album van tien nummers is weg.
691 //
692 // Voor een verhuizing hoort dat bakken niet te gebeuren. De doelinstantie is
693 // zelf een Klonkt en maakt van [[track:]] gewoon weer een speler.
694 const s = site();
695 db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('m1','s1','a.mp3','audio/mpeg',1,'/x/a.mp3')").run();
696 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('t1','s1','Nummer A','m1')").run();
697 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
698 VALUES ('pa','s1','u1','plaat','Plaat','<p>hoor dit</p>[[track:t1]]','published','2026-01-01T10:00:00Z')`).run();
699 const post = db.prepare("SELECT * FROM posts WHERE id = 'pa'").get();
700
701 const gebakken = AP.buildNote('https://oud.example', s, post);
702 assert.ok(!gebakken.content.includes('[[track:t1]]'), 'de gewone federatie bakt hem om');
703 assert.match(gebakken.content, /listen on/, 'en plakt er een tekstlink aan');
704
705 const rauw = AP.buildNote('https://oud.example', s, post, { rauweInhoud: true });
706 assert.ok(rauw.content.includes('[[track:t1]]'), 'voor een verhuizing blijft de shorthand staan');
707 assert.ok(!/listen on/.test(rauw.content), 'en er wordt geen tekstlink aangeplakt');
708});
709
[919b82d]710test('een bericht dat je zelf hebt verwijderd komt bij een tweede ronde terug', async () => {
711 // Robin: "ik kan handmatig deze keer de posts verwijderen en opnieuw ophalen."
712 // Met de eerste opzet kon dat niet: de mapping in ap_migration zei "al gehad"
713 // en dan werd alles overgeslagen, hoe leeg je site ook was. Dezelfde val als
714 // bij de nummers, en juist deze zou hij als eerste tegenkomen.
715 const s = site({ aliases: [BRON] });
716 const deps = bronnetje({ items: [note(`${BRON}/notes/1`), note(`${BRON}/notes/2`)] });
717
718 const r1 = await stil(() => Mig.ingestFromSource(s, { deps }));
719 assert.equal(r1.posts, 2);
720
721 // Eentje weg, de mapping blijft staan.
722 const weg = db.prepare('SELECT id FROM posts LIMIT 1').get().id;
723 db.prepare('DELETE FROM posts WHERE id = ?').run(weg);
724 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 1);
725
726 const r2 = await stil(() => Mig.ingestFromSource(s, { deps }));
727 assert.equal(r2.posts, 1, 'het verwijderde bericht hoort terug te komen');
728 assert.equal(r2.opnieuw, 1, 'en het verslag zegt dat het opnieuw is opgehaald');
729 assert.equal(r2.overgeslagen, 1, 'terwijl het bericht dat er nog stond met rust blijft');
730 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 2, 'geen dubbele');
731});
732
[19430fa]733test('de [[track:]]-verwijzing in een bericht blijft naar een bestaand nummer wijzen', async () => {
[919b82d]734 // Wat Robin op TikTik zag: het bericht toonde de shorthand zelf in plaats van
735 // een speler. Zijn posts kwamen uit de ZIP (die bewaart posts.content
736 // letterlijk, inclusief [[track:<oud id>]]) en zijn nummers uit de PULL (die
737 // gaf ze een nieuw id). De tekst wees dus naar een nummer dat hier niet
738 // bestaat, en dan valt hij terug op de kale code.
739 const s = site({ aliases: [BRON] });
740 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
741 VALUES ('pz','s1','u1','tiktik','TikTik','<p>[[track:t-oud]]</p>','published','2026-08-13T10:00:00Z')`).run();
742
743 const kaart = new Map([
744 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
745 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
746 [`${BRON}/tracks`, {
747 type: 'OrderedCollection',
748 orderedItems: [{
749 id: `${BRON}/tracks/t-oud`, type: 'Audio', name: 'Nummer',
750 url: [{ type: 'Link', href: `${BRON}/audio/x.mp3`, mediaType: 'audio/mpeg' }],
751 }],
752 }],
753 ]);
754 await stil(() => Mig.ingestFromSource(s, {
755 deps: {
756 getJson: async (_slug, url) => kaart.get(url) || null,
757 noteId: (b, id) => `${b}/ap/notes/${id}`,
758 noteVisibility: AP.noteVisibility,
759 audioRoot: '/nep/audio', mediaRoot: '/nep/media',
760 signHeaders: () => ({ Signature: 'nep' }),
761 safeFetch: async () => ({ ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }),
762 fs: { mkdirSync() {}, writeFileSync() {} },
763 path,
764 },
765 }));
766
[19430fa]767 // DE EIS, ongeacht hoe: na een verhuizing wijst de shorthand naar een nummer
768 // dat hier bestaat. Sinds "altijd behouden" (14-8) klopt dat meestal vanzelf,
769 // want het id verandert niet meer. Botst het id wel, dan trekt de ingest de
770 // tekst bij. Deze test toetst de UITKOMST en niet de route ernaartoe.
[919b82d]771 const inhoud = db.prepare("SELECT content FROM posts WHERE id = 'pz'").get().content;
772 const m = /\[\[track:([^\]]+)\]\]/.exec(inhoud);
[19430fa]773 assert.ok(m, 'de shorthand blijft staan');
[919b82d]774 const bestaat = db.prepare('SELECT 1 FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], 's1');
[19430fa]775 assert.ok(bestaat, `[[track:${m[1]}]] hoort bij een nummer dat er echt is`);
776 assert.equal(m[1], 't-oud', 'en omdat het id behouden blijft, hoefde er niets herschreven');
777});
778
779test('botst het track-id wel, dan wordt de tekst bijgetrokken', async () => {
780 // Het vangnet. "Altijd behouden" kan niet als er hier al iets anders met dat
781 // id staat; dan krijgt het nummer een ander id en moet de shorthand mee.
782 const s2 = site({ aliases: [BRON] });
783 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
784 VALUES ('pb','s1','u1','botsing','Botsing','<p>[[track:t-bots]]</p>','published','2026-08-13T10:00:00Z')`).run();
785 // Een nummer dat hier AL bestaat onder datzelfde id, van iets anders.
786 db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('mx','s1','x.mp3','audio/mpeg',1,'/x')").run();
787 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('t-bots','s1','Al van mij','mx')").run();
788
789 const kaart2 = new Map([
790 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
791 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
792 [`${BRON}/tracks`, {
793 type: 'OrderedCollection',
794 orderedItems: [{
795 id: `${BRON}/tracks/t-bots`, type: 'Audio', name: 'Van de bron',
796 url: [{ type: 'Link', href: `${BRON}/audio/y.mp3`, mediaType: 'audio/mpeg' }],
797 }],
798 }],
799 ]);
800 await stil(() => Mig.ingestFromSource(s2, {
801 deps: {
802 getJson: async (_slug, url) => kaart2.get(url) || null,
803 noteId: (b, id) => `${b}/ap/notes/${id}`,
804 noteVisibility: AP.noteVisibility,
805 audioRoot: '/nep/audio', mediaRoot: '/nep/media',
806 signHeaders: () => ({ Signature: 'nep' }),
807 safeFetch: async () => ({ ok: true, arrayBuffer: async () => Buffer.from('y'), headers: { get: () => 'audio/mpeg' } }),
808 fs: { mkdirSync() {}, writeFileSync() {} },
809 path,
810 },
811 }));
812 // Het bestaande nummer blijft van jou; de tekst wijst naar iets dat bestaat.
813 const inhoud = db.prepare("SELECT content FROM posts WHERE id = 'pb'").get().content;
814 const m = /\[\[track:([^\]]+)\]\]/.exec(inhoud);
815 assert.ok(m);
816 assert.ok(db.prepare('SELECT 1 FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], 's1'),
817 'wat er ook gebeurde met het id, de verwijzing wijst naar een bestaand nummer');
[919b82d]818});
819
[fbfd7a1]820test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
821 const s = site({ aliases: [BRON] });
822 const deps = bronnetje({ items: [note(`${BRON}/notes/priv`, { to: [`${BRON}/followers`] })] });
823 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
824 assert.equal(r.posts, 1);
825 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 0);
826 assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 1);
827});
Note: See TracBrowser for help on using the repository browser.