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

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

Een tweede ronde vult aan in plaats van over te slaan

Robin vroeg of hij eerst moest opschonen. Bij het uitzoeken bleek dat het
antwoord "ja" had moeten zijn, en dat dat een val in mijn eigen ontwerp was.

De ingest onthield per bron-URI dat hij hem gehad had, en passeerde hem daarna
altijd. Dat is prima zolang er niets verandert, maar er kwam juist iets bij:
hoezen, duur en playlists. Wie al opgehaald had zat vast. Opnieuw drukken sloeg
alles over, en opruimen hielp niet, want die blokkade zat in ap_migration en
niet in de tracks. Je zou drie dingen tegelijk moeten wissen om er los te komen,
en dat is geen opdracht die je een gebruiker geeft.

Nu kijkt hij op waar een bron-URI hier terechtkwam, en vult LEGE velden aan op
de rij die er al staat. Wat je zelf hebt aangepast blijft staan: een migratie
hoort je correcties niet terug te draaien. Het geluidsbestand wordt niet opnieuw
gedownload. Playlists werken hetzelfde: dezelfde rij, en de volgorde opnieuw
gezet, want een half bijgewerkte volgorde is erger dan een verse.

Gemeten op de gemeten stand van soundfabrics.nl (139 nummers met bestand, geen
hoes, geen duur, geen playlist), nagebouwd met 3 nummers:

ronde 1 3 nummers binnen, geen hoes, duur 212s uit PT212S
ronde 2 0 erbij, 3 aangevuld, 1 playlist, geen dubbele, mp3 niet opnieuw
ronde 3 0 aangevuld, alles compleet, niets verandert

Changed files:
src/services/MigrationService.js

  • migrationTarget(): waar kwam deze bron-URI terecht
  • tracks worden aangevuld in plaats van gepasseerd; alleen lege velden
  • de trackkaart wordt OOK in die tak gevuld, anders vinden de playlists hun nummers niet en komt er geen plaat
  • playlists werken dezelfde kant op, met hun eigen mapping

src/views/pages/admin-migrate.ejs

  • "nummers aangevuld" en playlists in het ophaalverslag; anders meldt een ronde die drie nummers compleet maakte "0 erbij gekomen"

src/services/i18n.js

  • mig.r_tracks_updated in nl/en/de

remarks: het verslag zegt ook "1 playlists" als er niets aan die plaat
veranderde, want de volgorde wordt elke ronde opnieuw gezet. Klopt wel, leest
iets te groot. Robin hoeft nu niets op te schonen: een keer ophalen na de uitrol
volstaat.

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

  • Property mode set to 100644
File size: 22.8 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('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
461 const s = site({ aliases: [BRON] });
462 const deps = bronnetje({ items: [note(`${BRON}/notes/priv`, { to: [`${BRON}/followers`] })] });
463 const r = await stil(() => Mig.ingestFromSource(s, { deps }));
464 assert.equal(r.posts, 1);
465 assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 0);
466 assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 1);
467});
Note: See TracBrowser for help on using the repository browser.