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

main
Last change on this file since dc802b4 was dc802b4, checked in by Robin <roboburr@â€Ļ>, 4 weeks ago

Een verhuizing krijgt de rauwe inhoud, niet de gebakken

Robin: het is gelukt, maar posts laden de playlists, albums of audio niet. Het
bericht toonde "đŸŽĩ TikTik — listen on soundfabrics" als tekstlink, waar een
speler hoort te staan.

buildNote bakt de shorthand om voor de fediverse: [[track:]], [[album:]] en
[[playlist:]] worden GESTRIPT en er komt achteraan een blok met de titels. Dat
is precies goed voor Mastodon, en precies fout voor een verhuizing: de
doelinstantie is zelf een Klonkt en maakt van [[track:]] gewoon een speler.

Ik heb eerst overwogen om die link terug te vertalen naar de shorthand. Dat kan
niet zonder verlies:

  • de shorthand wordt gestript, dus zijn PLAATS in de post is weg; het blok komt altijd achteraan;
  • er gaan hooguit VIER titels in, dus een album van tien nummers is onherstelbaar;
  • [[album:]] en [[playlist:]] worden opgelost naar losse tracks, dus je krijgt tien spelers in plaats van een plaat.

Dus niet terugvertalen maar niet bakken. buildNote kreeg een optie
rauweInhoud, de outbox-route zet hem aan voor precies de actor waar dit
account naartoe verhuisd is. Dezelfde regel als bij de outbox-audience, de
tracks en de playlists: wie ondertekend vraagt namens de doel-actor krijgt onze
eigen kijk.

Gemeten op twee instanties, dezelfde post twee keer opgehaald:

anoniem <p>đŸŽĩ <strong><a href=".../tiktik#track-t-a">Nummer A</a>...
verhuizing <p>hoor dit</p>[[track:t-a]]

En [[playlist:pl]] komt heel aan, wat met terugvertalen nooit was gelukt.

Changed files:
src/services/ActivityPubService.js

  • buildNote kent rauweInhoud: niet strippen, geen listen-blok aanplakken
  • buildCreate en buildOutbox geven de optie door

src/routes/activitypub.js

  • de outbox zet hem aan voor de doel-actor van een Move

test/fep1580-migration.test.js

  • dezelfde post gebakken en rauw, naast elkaar

remarks: dit raakt alleen wie ondertekend vraagt namens moved_to. Voor de rest
van de fediverse verandert er niets aan de outbox. Suite 1001 groen, tegenproef
gedaan.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@â€Ļ>

  • Property mode set to 100644
File size: 39.2 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 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
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
371test('een nummer waarvan de bytes niet komen levert GEEN track op', async () => {
372 // Dezelfde regel als bij de zip. Een nummer dat in de lijst staat en 404't is
373 // erger dan een nummer dat ontbreekt.
374 const s = site({ aliases: [BRON] });
375 const kaart = new Map([
376 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
377 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
378 [`${BRON}/tracks`, {
379 type: 'OrderedCollection',
380 orderedItems: [{ id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Weg', url: `${BRON}/audio/weg.mp3` }],
381 }],
382 ]);
383 const r = await stil(() => Mig.ingestFromSource(s, {
384 deps: {
385 getJson: async (_slug, url) => kaart.get(url) || null,
386 noteId: (b, id) => `${b}/ap/notes/${id}`,
387 noteVisibility: AP.noteVisibility,
388 audioRoot: '/nep/audio',
389 signHeaders: () => ({ Signature: 'nep' }),
390 safeFetch: async () => ({ ok: false, status: 403 }),
391 fs: { mkdirSync() {}, writeFileSync() { throw new Error('mag niet gebeuren'); } },
392 path,
393 },
394 }));
395 assert.equal(r.tracksBinnen, 0);
396 assert.equal(r.tracksMislukt, 1);
397 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0);
398});
399
400test('een tweede ronde VULT AAN en slaat niet over', async () => {
401 // De val die Robin bijna in liep: de eerste versie van de ingest onthield per
402 // bron-URI "al gehad" en passeerde die daarna altijd. Kwam er later iets bij
403 // (hoezen, duur, playlists), dan kon je opnieuw drukken zoveel je wilde en
404 // gebeurde er niets. Opruimen hielp ook niet, want ap_migration hield de
405 // blokkade in stand. Dus: aanvullen, niet overslaan.
406 const s = site({ aliases: [BRON] });
407 const AUDIO = `${BRON}/audio/x.mp3`;
408 const HOES = `${BRON}/media/hoes.png`;
409 const maakKaart = (metHoes) => new Map([
410 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
411 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
412 [`${BRON}/tracks`, {
413 type: 'OrderedCollection',
414 orderedItems: [{
415 id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Nummer', summary: 'Robo', duration: 'PT212S',
416 url: [{ type: 'Link', href: AUDIO, mediaType: 'audio/mpeg' }],
417 ...(metHoes ? { icon: { type: 'Image', url: HOES } } : {}),
418 }],
419 }],
420 ]);
421 const gehaald = [];
422 const deps = (metHoes) => ({
423 getJson: async (_slug, url) => maakKaart(metHoes).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 (url) => {
429 gehaald.push(url);
430 return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } };
431 },
432 fs: { mkdirSync() {}, writeFileSync() {} },
433 path,
434 });
435
436 // Ronde 1: zonder hoes, zoals de eerste uitrol.
437 const r1 = await stil(() => Mig.ingestFromSource(s, { deps: deps(false) }));
438 assert.equal(r1.tracksBinnen, 1);
439 const na1 = db.prepare('SELECT id, cover_url, duration FROM audio_tracks').get();
440 assert.equal(na1.cover_url, null);
441 assert.equal(na1.duration, 212, 'PT212S hoort 212 seconden te worden');
442
443 // Ronde 2: nu MET hoes. Hij moet aanvullen, niet passeren en niet verdubbelen.
444 const voor = gehaald.length;
445 const r2 = await stil(() => Mig.ingestFromSource(s, { deps: deps(true) }));
446 assert.equal(r2.tracksBinnen, 0, 'er komt niets nieuws bij');
447 assert.equal(r2.tracksBijgewerkt, 1, 'maar het bestaande nummer wordt wel aangevuld');
448 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 1, 'geen dubbele');
449 const na2 = db.prepare('SELECT id, cover_url FROM audio_tracks').get();
450 assert.equal(na2.id, na1.id, 'dezelfde rij, niet een nieuwe');
451 assert.ok(na2.cover_url, 'en de hoes is er nu wel');
452 assert.ok(!gehaald.slice(voor).includes(AUDIO), 'het geluidsbestand wordt NIET opnieuw gedownload');
453
454 // Ronde 3: alles compleet, dus er valt niets meer aan te vullen.
455 const r3 = await stil(() => Mig.ingestFromSource(s, { deps: deps(true) }));
456 assert.equal(r3.tracksBijgewerkt, 0);
457 assert.equal(r3.overgeslagenTracks, 1);
458});
459
460test('de outbox-keten wordt gevolgd, ook als de kale collectie zelf items draagt', async () => {
461 // Robins 18 van 35. Klonkts kale outbox draagt first EN een kopie van
462 // pagina 1 (Pleroma eiste ooit een first, sindsdien staan ze er allebei).
463 // De ingest zag items, sloeg first over, vond daarna geen next (dat veld
464 // bestaat alleen op echte pagina's) en dacht klaar te zijn. Zonder een
465 // waarschuwing, en dat was het ergste deel.
466 const s = site({ aliases: [BRON] });
467 const maak = (i) => note(`${BRON}/notes/n${i}`);
468 const p1 = Array.from({ length: 20 }, (_, i) => maak(i + 1));
469 const p2 = Array.from({ length: 8 }, (_, i) => maak(i + 21));
470 const kaart = new Map([
471 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
472 // de valstrik: totalItems, first, EN de eerste twintig inline
473 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 28, first: `${BRON}/outbox?page=1`, orderedItems: p1 }],
474 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', next: `${BRON}/outbox?page=2`, orderedItems: p1 }],
475 [`${BRON}/outbox?page=2`, { type: 'OrderedCollectionPage', orderedItems: p2 }],
476 ]);
477 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
478 getJson: async (_s, url) => kaart.get(url) || null,
479 noteId: (b, id) => `${b}/ap/notes/${id}`,
480 noteVisibility: AP.noteVisibility,
481 } }));
482 assert.equal(r.posts, 28, 'alle pagina\'s, niet alleen de kopie op de kale collectie');
483});
484
485test('een niet opgehaalde pagina wordt GEMELD, niet verzwegen', async () => {
486 const s = site({ aliases: [BRON] });
487 const p1 = Array.from({ length: 20 }, (_, i) => note(`${BRON}/notes/n${i + 1}`));
488 const kaart = new Map([
489 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
490 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 28, first: `${BRON}/outbox?page=1` }],
491 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', next: `${BRON}/outbox?page=2`, orderedItems: p1 }],
492 // pagina 2 antwoordt niet (rate limit, netwerkstoring, wat dan ook)
493 ]);
494 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
495 getJson: async (_s, url) => kaart.get(url) || null,
496 noteId: (b, id) => `${b}/ap/notes/${id}`,
497 noteVisibility: AP.noteVisibility,
498 } }));
499 assert.equal(r.posts, 20);
500 assert.ok(r.waarschuwingen.some((w) => /28 items en er zijn er 20/.test(w)),
501 'stil minder ophalen dan de bron meldt is hoe 18 van 35 wekenlang op klaar had gestaan');
502});
503
504test('een plaatje in de tekst wordt gedownload en de verwijzing wordt relatief', async () => {
505 // Robins hotlinks. En de valkuil die de eerste versie half liet werken: de
506 // regex stond als STRING in een template literal, \s werd een kale s, en
507 // elke URL met een s erin (post-images!) knapte af. Vandaar de assert op de
508 // VOLLEDIGE url, letter voor letter.
509 const s = site({ aliases: [BRON] });
510 const IMG = `${BRON.replace('/ap/users/robo', '')}/media/post-images/plaatje-strak.png`;
511 const kaart = new Map([
512 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
513 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 1, first: `${BRON}/outbox?page=1` }],
514 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [
515 note(`${BRON}/notes/pi`, { content: `<p>kijk</p><a href="${IMG}"><img src="${IMG}"></a>` }),
516 ] }],
517 ]);
518 const opgehaald = [];
519 const geschreven = [];
520 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
521 getJson: async (_s, url) => kaart.get(url) || null,
522 noteId: (b, id) => `${b}/ap/notes/${id}`,
523 noteVisibility: AP.noteVisibility,
524 mediaRoot: '/nep/media', audioRoot: '/nep/audio',
525 signHeaders: () => ({ Signature: 'nep' }),
526 safeFetch: async (url) => { opgehaald.push(url); return { ok: true, arrayBuffer: async () => Buffer.from('png'), headers: { get: () => 'image/png' } }; },
527 fs: { mkdirSync() {}, writeFileSync: (pad) => geschreven.push(pad), statSync() { throw new Error('ENOENT'); } },
528 path,
529 } }));
530 assert.equal(r.posts, 1);
531 assert.deepEqual(opgehaald, [IMG], 'de VOLLEDIGE url, niet een afgekapt stuk');
532 assert.deepEqual(geschreven, ['/nep/media/post-images/plaatje-strak.png'], 'op het pad van de bron');
533 const c = db.prepare("SELECT content FROM posts WHERE site_id = 's1'").get().content;
534 assert.ok(c.includes('src="/media/post-images/plaatje-strak.png"'), `relatief herschreven, kreeg: ${c}`);
535 assert.ok(!c.includes('oud.example'), 'en er hotlinkt niets meer naar de bron');
536});
537
538test('de omslag komt ook mee als hij in `image` zit in plaats van in attachment', async () => {
539 // Robins 9 covers van de 36. Klonkt onderdrukt de beeldbijlage met opzet
540 // zodra een post een speler of embed heeft (noImages in buildNote), en zet
541 // de omslag dan in `image` zodat Mastodon zijn spelerkaart toont. 18 van de
542 // 20 berichten op pagina 1 hadden daardoor geen enkele bijlage, en toch een
543 // cover. Mijn ingest keek alleen naar attachment.
544 const s = site({ aliases: [BRON] });
545 const OMSLAG = 'https://oud.example/media/post-images/omslag.png';
546 const HOES = 'https://oud.example/media/audio-covers/hoes.webp';
547 const kaart = new Map([
548 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
549 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 2, first: `${BRON}/outbox?page=1` }],
550 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [
551 // een speler-post: GEEN attachment, omslag in image
552 note(`${BRON}/notes/speler`, { content: '<p>muziek</p>', image: { type: 'Image', url: OMSLAG } }),
553 // een audio-bijlage draagt zijn eigen hoes in icon
554 note(`${BRON}/notes/audio`, { content: '<p>track</p>', attachment: [
555 { type: 'Audio', mediaType: 'audio/mpeg', url: 'https://oud.example/audio/stream/x.mp3',
556 icon: { type: 'Image', mediaType: 'image/webp', url: HOES } },
557 ] }),
558 ] }],
559 ]);
560 const opgehaald = [];
561 const r = await stil(() => Mig.ingestFromSource(s, { deps: {
562 getJson: async (_s, url) => kaart.get(url) || null,
563 noteId: (b, id) => `${b}/ap/notes/${id}`,
564 noteVisibility: AP.noteVisibility,
565 mediaRoot: '/nep/media', audioRoot: '/nep/audio',
566 signHeaders: () => ({ Signature: 'nep' }),
567 safeFetch: async (url) => { opgehaald.push(url); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'image/png' } }; },
568 fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
569 path,
570 } }));
571 assert.equal(r.posts, 2);
572 assert.ok(opgehaald.includes(OMSLAG), 'de omslag uit `image` hoort opgehaald te worden');
573 assert.ok(opgehaald.includes(HOES), 'en de hoes uit de icon van een Audio-bijlage ook');
574 const covers = db.prepare('SELECT cover_image_url c FROM posts WHERE site_id = ?').all('s1').map((x) => x.c);
575 assert.equal(covers.filter(Boolean).length, 2, 'beide berichten krijgen hun omslag');
576 assert.ok(covers.includes('/media/post-images/omslag.png'), 'op het pad van de bron');
577});
578
579test('links naar de bronpost buigen om naar hier, in dezelfde ronde', async () => {
580 // De gebakken "luister op"-links: buildNote zet
581 // https://oud/<slug>#track-<id> en ?fc=2 in de content. Die bleven naar de
582 // oude site wijzen, en dat is een tijdbom: zodra dat domein weg is staan er
583 // dode links in je eigen berichten. Slugs en track-ids blijven behouden, dus
584 // de fragmenten kloppen vanzelf zodra de host eraf is.
585 const s = site({ aliases: [BRON] });
586 const OUD = 'https://oud.example';
587 const kaart = new Map([
588 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
589 [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 2, first: `${BRON}/outbox?page=1` }],
590 [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [
591 note(`${BRON}/notes/tiktik`, { url: `${OUD}/tiktik`, content:
592 `<p>đŸŽĩ <a href="${OUD}/tiktik#track-t1">TikTik</a> — <a href="${OUD}/tiktik?fc=2">luister</a></p>` }),
593 // een link naar iets dat we NIET hebben blijft met rust
594 note(`${BRON}/notes/ander`, { url: `${OUD}/ander`, content:
595 `<p>zie <a href="${OUD}/bestaat-hier-niet">daar</a></p>` }),
596 ] }],
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, 2);
604 const tik = db.prepare("SELECT content FROM posts WHERE slug = 'tiktik'").get().content;
605 assert.ok(tik.includes('href="/tiktik#track-t1"'), `fragment blijft heel, kreeg: ${tik}`);
606 assert.ok(tik.includes('href="/tiktik?fc=2"'), 'en de querystring ook');
607 assert.ok(!tik.includes('oud.example'), 'er wijst niets meer naar de oude site');
608 const ander = db.prepare("SELECT content FROM posts WHERE slug = 'ander'").get().content;
609 assert.ok(ander.includes(`${OUD}/bestaat-hier-niet`),
610 'een link naar iets dat hier niet bestaat blijft staan: beter de oude site dan een 404 op de nieuwe');
611 assert.equal(r.linksBijgetrokken, 1);
612});
613
614test('de doelinstantie krijgt de RAUWE inhoud, met de shorthand er nog in', () => {
615 // Robins laatste klacht: de post kwam aan met "đŸŽĩ TikTik — listen on
616 // soundfabrics" en zonder speler. De bron BAKT de shorthand namelijk om voor
617 // de fediverse: [[track:]] wordt gestript en er komt achteraan een blok met
618 // hooguit VIER titels. Onherstelbaar dus: een album van tien nummers is weg.
619 //
620 // Voor een verhuizing hoort dat bakken niet te gebeuren. De doelinstantie is
621 // zelf een Klonkt en maakt van [[track:]] gewoon weer een speler.
622 const s = site();
623 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();
624 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('t1','s1','Nummer A','m1')").run();
625 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
626 VALUES ('pa','s1','u1','plaat','Plaat','<p>hoor dit</p>[[track:t1]]','published','2026-01-01T10:00:00Z')`).run();
627 const post = db.prepare("SELECT * FROM posts WHERE id = 'pa'").get();
628
629 const gebakken = AP.buildNote('https://oud.example', s, post);
630 assert.ok(!gebakken.content.includes('[[track:t1]]'), 'de gewone federatie bakt hem om');
631 assert.match(gebakken.content, /listen on/, 'en plakt er een tekstlink aan');
632
633 const rauw = AP.buildNote('https://oud.example', s, post, { rauweInhoud: true });
634 assert.ok(rauw.content.includes('[[track:t1]]'), 'voor een verhuizing blijft de shorthand staan');
635 assert.ok(!/listen on/.test(rauw.content), 'en er wordt geen tekstlink aangeplakt');
636});
637
638test('een bericht dat je zelf hebt verwijderd komt bij een tweede ronde terug', async () => {
639 // Robin: "ik kan handmatig deze keer de posts verwijderen en opnieuw ophalen."
640 // Met de eerste opzet kon dat niet: de mapping in ap_migration zei "al gehad"
641 // en dan werd alles overgeslagen, hoe leeg je site ook was. Dezelfde val als
642 // bij de nummers, en juist deze zou hij als eerste tegenkomen.
643 const s = site({ aliases: [BRON] });
644 const deps = bronnetje({ items: [note(`${BRON}/notes/1`), note(`${BRON}/notes/2`)] });
645
646 const r1 = await stil(() => Mig.ingestFromSource(s, { deps }));
647 assert.equal(r1.posts, 2);
648
649 // Eentje weg, de mapping blijft staan.
650 const weg = db.prepare('SELECT id FROM posts LIMIT 1').get().id;
651 db.prepare('DELETE FROM posts WHERE id = ?').run(weg);
652 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 1);
653
654 const r2 = await stil(() => Mig.ingestFromSource(s, { deps }));
655 assert.equal(r2.posts, 1, 'het verwijderde bericht hoort terug te komen');
656 assert.equal(r2.opnieuw, 1, 'en het verslag zegt dat het opnieuw is opgehaald');
657 assert.equal(r2.overgeslagen, 1, 'terwijl het bericht dat er nog stond met rust blijft');
658 assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 2, 'geen dubbele');
659});
660
661test('de [[track:]]-verwijzing in een bericht blijft naar een bestaand nummer wijzen', async () => {
662 // Wat Robin op TikTik zag: het bericht toonde de shorthand zelf in plaats van
663 // een speler. Zijn posts kwamen uit de ZIP (die bewaart posts.content
664 // letterlijk, inclusief [[track:<oud id>]]) en zijn nummers uit de PULL (die
665 // gaf ze een nieuw id). De tekst wees dus naar een nummer dat hier niet
666 // bestaat, en dan valt hij terug op de kale code.
667 const s = site({ aliases: [BRON] });
668 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
669 VALUES ('pz','s1','u1','tiktik','TikTik','<p>[[track:t-oud]]</p>','published','2026-08-13T10:00:00Z')`).run();
670
671 const kaart = new Map([
672 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
673 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
674 [`${BRON}/tracks`, {
675 type: 'OrderedCollection',
676 orderedItems: [{
677 id: `${BRON}/tracks/t-oud`, type: 'Audio', name: 'Nummer',
678 url: [{ type: 'Link', href: `${BRON}/audio/x.mp3`, mediaType: 'audio/mpeg' }],
679 }],
680 }],
681 ]);
682 await stil(() => Mig.ingestFromSource(s, {
683 deps: {
684 getJson: async (_slug, url) => kaart.get(url) || null,
685 noteId: (b, id) => `${b}/ap/notes/${id}`,
686 noteVisibility: AP.noteVisibility,
687 audioRoot: '/nep/audio', mediaRoot: '/nep/media',
688 signHeaders: () => ({ Signature: 'nep' }),
689 safeFetch: async () => ({ ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }),
690 fs: { mkdirSync() {}, writeFileSync() {} },
691 path,
692 },
693 }));
694
695 // DE EIS, ongeacht hoe: na een verhuizing wijst de shorthand naar een nummer
696 // dat hier bestaat. Sinds "altijd behouden" (14-8) klopt dat meestal vanzelf,
697 // want het id verandert niet meer. Botst het id wel, dan trekt de ingest de
698 // tekst bij. Deze test toetst de UITKOMST en niet de route ernaartoe.
699 const inhoud = db.prepare("SELECT content FROM posts WHERE id = 'pz'").get().content;
700 const m = /\[\[track:([^\]]+)\]\]/.exec(inhoud);
701 assert.ok(m, 'de shorthand blijft staan');
702 const bestaat = db.prepare('SELECT 1 FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], 's1');
703 assert.ok(bestaat, `[[track:${m[1]}]] hoort bij een nummer dat er echt is`);
704 assert.equal(m[1], 't-oud', 'en omdat het id behouden blijft, hoefde er niets herschreven');
705});
706
707test('botst het track-id wel, dan wordt de tekst bijgetrokken', async () => {
708 // Het vangnet. "Altijd behouden" kan niet als er hier al iets anders met dat
709 // id staat; dan krijgt het nummer een ander id en moet de shorthand mee.
710 const s2 = site({ aliases: [BRON] });
711 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
712 VALUES ('pb','s1','u1','botsing','Botsing','<p>[[track:t-bots]]</p>','published','2026-08-13T10:00:00Z')`).run();
713 // Een nummer dat hier AL bestaat onder datzelfde id, van iets anders.
714 db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('mx','s1','x.mp3','audio/mpeg',1,'/x')").run();
715 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('t-bots','s1','Al van mij','mx')").run();
716
717 const kaart2 = new Map([
718 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
719 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
720 [`${BRON}/tracks`, {
721 type: 'OrderedCollection',
722 orderedItems: [{
723 id: `${BRON}/tracks/t-bots`, type: 'Audio', name: 'Van de bron',
724 url: [{ type: 'Link', href: `${BRON}/audio/y.mp3`, mediaType: 'audio/mpeg' }],
725 }],
726 }],
727 ]);
728 await stil(() => Mig.ingestFromSource(s2, {
729 deps: {
730 getJson: async (_slug, url) => kaart2.get(url) || null,
731 noteId: (b, id) => `${b}/ap/notes/${id}`,
732 noteVisibility: AP.noteVisibility,
733 audioRoot: '/nep/audio', mediaRoot: '/nep/media',
734 signHeaders: () => ({ Signature: 'nep' }),
735 safeFetch: async () => ({ ok: true, arrayBuffer: async () => Buffer.from('y'), headers: { get: () => 'audio/mpeg' } }),
736 fs: { mkdirSync() {}, writeFileSync() {} },
737 path,
738 },
739 }));
740 // Het bestaande nummer blijft van jou; de tekst wijst naar iets dat bestaat.
741 const inhoud = db.prepare("SELECT content FROM posts WHERE id = 'pb'").get().content;
742 const m = /\[\[track:([^\]]+)\]\]/.exec(inhoud);
743 assert.ok(m);
744 assert.ok(db.prepare('SELECT 1 FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], 's1'),
745 'wat er ook gebeurde met het id, de verwijzing wijst naar een bestaand nummer');
746});
747
748test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
749 const s = site({ aliases: [BRON] });
750 const deps = bronnetje({ items: [note(`${BRON}/notes/priv`, { to: [`${BRON}/followers`] })] });
751 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
752 assert.equal(r.posts, 1);
753 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 0);
754 assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 1);
755});
Note: See TracBrowser for help on using the repository browser.