source: Klonkt/test/fep1580-migration.test.js

main
Last change on this file was 7e474f7, checked in by Robin <roboburr@…>, 4 weeks ago

De publieke vertaaltabel laadde alles om er twintig te tonen

Robin, ter herinnering: honderd miljoen Klonkt-gebruikers. Terecht, en het
wees meteen een echte fout aan in wat ik vandaag bouwde.

migrationItems deed SELECT zonder LIMIT. buildMigration bouwde daar objecten
van en liet pagedCollection er twintig uit snijden. Bij een gebruiker met
honderdduizend berichten betekent dat: honderdduizend rijen uit de database,
honderdduizend objecten in geheugen, 99,98 procent daarvan direct de prullenbak
in. Per verzoek.

En het is geen intern verzoek. Dit endpoint is publiek EN FEP-1580 schrijft
derden voor het herhaaldelijk op te halen tot migrationComplete waar is. Een
ontbrekende LIMIT is daar geen inefficientie maar een hefboom.

Nu in SQL gepagineerd, met dezelfde alGesneden-aanpak die de outbox al had.

DE KALE COLLECTIE DRAAGT GEEN RIJEN MEER, alleen de telling en first/last. Dat
is niet alleen goedkoper maar ook veiliger: een kale collectie MET items was
precies de valstrik waar onze eigen ingest vanmorgen in liep. Hij zag items,
sloeg first over, vond geen next en dacht klaar te zijn bij 18 van de 35.

Changed files:
src/services/MigrationService.js

  • migrationItems kent limit en offset
  • buildMigration telt apart en haalt alleen de gevraagde pagina op

test/fep1580-migration.test.js

  • de kale collectie heeft een telling en geen rijen
  • 45 items geven pagina's van 20/20/5, zonder overlap en zonder verlies

remarks: de rate-limiter dekt /ap al, dus die kant zat goed. Wat NIET opgelost
is en bij deze schaal het echte plafond blijft: buildArchive bouwt de hele zip
in geheugen, inclusief elk audiobestand. Die grens van 512 MB is nog steeds een
beredeneerde gok en geen meting. Streamen staat als shaer-190t genoteerd; bij
honderd miljoen gebruikers is dat geen nice-to-have meer. Suite 1014 groen,
tegenproef gedaan.

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

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