| 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.
|
|---|
| 13 | import { test, beforeEach } from 'node:test';
|
|---|
| 14 | import assert from 'node:assert/strict';
|
|---|
| 15 | import path from 'node:path';
|
|---|
| 16 |
|
|---|
| 17 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 18 | process.env.PUBLIC_BASE_URL = 'https://nieuw.example';
|
|---|
| 19 |
|
|---|
| 20 | const dbMod = await import('../src/config/database.js');
|
|---|
| 21 | const db = dbMod.default;
|
|---|
| 22 | {
|
|---|
| 23 | const stil = console.log;
|
|---|
| 24 | console.log = () => {};
|
|---|
| 25 | try { dbMod.initializeDatabase(); } finally { console.log = stil; }
|
|---|
| 26 | }
|
|---|
| 27 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 28 | const Mig = await import('../src/services/MigrationService.js');
|
|---|
| 29 |
|
|---|
| 30 | const BRON = 'https://oud.example/ap/users/robo';
|
|---|
| 31 | const IK = 'https://nieuw.example/ap/users/ik';
|
|---|
| 32 |
|
|---|
| 33 | function 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 |
|
|---|
| 43 | async 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 |
|
|---|
| 49 | beforeEach(() => {
|
|---|
| 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 |
|
|---|
| 57 | test('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 |
|
|---|
| 66 | test('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 |
|
|---|
| 72 | test('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 |
|
|---|
| 84 | test('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 |
|
|---|
| 94 | test('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 |
|
|---|
| 101 | test('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 |
|
|---|
| 131 | test('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 |
|
|---|
| 139 | test('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 |
|
|---|
| 147 | test('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 |
|
|---|
| 159 | test('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 |
|
|---|
| 171 | test('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 |
|
|---|
| 179 | test('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 |
|
|---|
| 193 | test('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.
|
|---|
| 207 | function 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 |
|
|---|
| 227 | function 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 |
|
|---|
| 238 | test('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 |
|
|---|
| 245 | test('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 |
|
|---|
| 257 | test('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 |
|
|---|
| 263 | test('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 |
|
|---|
| 277 | test('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 |
|
|---|
| 283 | test('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 |
|
|---|
| 293 | test('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 |
|
|---|
| 305 | test('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 |
|
|---|
| 318 | test('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 |
|
|---|
| 371 | test('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 |
|
|---|
| 400 | test('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 |
|
|---|
| 460 | test('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 |
|
|---|
| 485 | test('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 |
|
|---|
| 504 | test('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 |
|
|---|
| 538 | test('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 |
|
|---|
| 579 | test('een bericht dat je zelf hebt verwijderd komt bij een tweede ronde terug', async () => {
|
|---|
| 580 | // Robin: "ik kan handmatig deze keer de posts verwijderen en opnieuw ophalen."
|
|---|
| 581 | // Met de eerste opzet kon dat niet: de mapping in ap_migration zei "al gehad"
|
|---|
| 582 | // en dan werd alles overgeslagen, hoe leeg je site ook was. Dezelfde val als
|
|---|
| 583 | // bij de nummers, en juist deze zou hij als eerste tegenkomen.
|
|---|
| 584 | const s = site({ aliases: [BRON] });
|
|---|
| 585 | const deps = bronnetje({ items: [note(`${BRON}/notes/1`), note(`${BRON}/notes/2`)] });
|
|---|
| 586 |
|
|---|
| 587 | const r1 = await stil(() => Mig.ingestFromSource(s, { deps }));
|
|---|
| 588 | assert.equal(r1.posts, 2);
|
|---|
| 589 |
|
|---|
| 590 | // Eentje weg, de mapping blijft staan.
|
|---|
| 591 | const weg = db.prepare('SELECT id FROM posts LIMIT 1').get().id;
|
|---|
| 592 | db.prepare('DELETE FROM posts WHERE id = ?').run(weg);
|
|---|
| 593 | assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 1);
|
|---|
| 594 |
|
|---|
| 595 | const r2 = await stil(() => Mig.ingestFromSource(s, { deps }));
|
|---|
| 596 | assert.equal(r2.posts, 1, 'het verwijderde bericht hoort terug te komen');
|
|---|
| 597 | assert.equal(r2.opnieuw, 1, 'en het verslag zegt dat het opnieuw is opgehaald');
|
|---|
| 598 | assert.equal(r2.overgeslagen, 1, 'terwijl het bericht dat er nog stond met rust blijft');
|
|---|
| 599 | assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 2, 'geen dubbele');
|
|---|
| 600 | });
|
|---|
| 601 |
|
|---|
| 602 | test('de [[track:]]-verwijzing in een bericht blijft naar een bestaand nummer wijzen', async () => {
|
|---|
| 603 | // Wat Robin op TikTik zag: het bericht toonde de shorthand zelf in plaats van
|
|---|
| 604 | // een speler. Zijn posts kwamen uit de ZIP (die bewaart posts.content
|
|---|
| 605 | // letterlijk, inclusief [[track:<oud id>]]) en zijn nummers uit de PULL (die
|
|---|
| 606 | // gaf ze een nieuw id). De tekst wees dus naar een nummer dat hier niet
|
|---|
| 607 | // bestaat, en dan valt hij terug op de kale code.
|
|---|
| 608 | const s = site({ aliases: [BRON] });
|
|---|
| 609 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
|
|---|
| 610 | VALUES ('pz','s1','u1','tiktik','TikTik','<p>[[track:t-oud]]</p>','published','2026-08-13T10:00:00Z')`).run();
|
|---|
| 611 |
|
|---|
| 612 | const kaart = new Map([
|
|---|
| 613 | [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
|
|---|
| 614 | [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
|
|---|
| 615 | [`${BRON}/tracks`, {
|
|---|
| 616 | type: 'OrderedCollection',
|
|---|
| 617 | orderedItems: [{
|
|---|
| 618 | id: `${BRON}/tracks/t-oud`, type: 'Audio', name: 'Nummer',
|
|---|
| 619 | url: [{ type: 'Link', href: `${BRON}/audio/x.mp3`, mediaType: 'audio/mpeg' }],
|
|---|
| 620 | }],
|
|---|
| 621 | }],
|
|---|
| 622 | ]);
|
|---|
| 623 | await stil(() => Mig.ingestFromSource(s, {
|
|---|
| 624 | deps: {
|
|---|
| 625 | getJson: async (_slug, url) => kaart.get(url) || null,
|
|---|
| 626 | noteId: (b, id) => `${b}/ap/notes/${id}`,
|
|---|
| 627 | noteVisibility: AP.noteVisibility,
|
|---|
| 628 | audioRoot: '/nep/audio', mediaRoot: '/nep/media',
|
|---|
| 629 | signHeaders: () => ({ Signature: 'nep' }),
|
|---|
| 630 | safeFetch: async () => ({ ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }),
|
|---|
| 631 | fs: { mkdirSync() {}, writeFileSync() {} },
|
|---|
| 632 | path,
|
|---|
| 633 | },
|
|---|
| 634 | }));
|
|---|
| 635 |
|
|---|
| 636 | // DE EIS, ongeacht hoe: na een verhuizing wijst de shorthand naar een nummer
|
|---|
| 637 | // dat hier bestaat. Sinds "altijd behouden" (14-8) klopt dat meestal vanzelf,
|
|---|
| 638 | // want het id verandert niet meer. Botst het id wel, dan trekt de ingest de
|
|---|
| 639 | // tekst bij. Deze test toetst de UITKOMST en niet de route ernaartoe.
|
|---|
| 640 | const inhoud = db.prepare("SELECT content FROM posts WHERE id = 'pz'").get().content;
|
|---|
| 641 | const m = /\[\[track:([^\]]+)\]\]/.exec(inhoud);
|
|---|
| 642 | assert.ok(m, 'de shorthand blijft staan');
|
|---|
| 643 | const bestaat = db.prepare('SELECT 1 FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], 's1');
|
|---|
| 644 | assert.ok(bestaat, `[[track:${m[1]}]] hoort bij een nummer dat er echt is`);
|
|---|
| 645 | assert.equal(m[1], 't-oud', 'en omdat het id behouden blijft, hoefde er niets herschreven');
|
|---|
| 646 | });
|
|---|
| 647 |
|
|---|
| 648 | test('botst het track-id wel, dan wordt de tekst bijgetrokken', async () => {
|
|---|
| 649 | // Het vangnet. "Altijd behouden" kan niet als er hier al iets anders met dat
|
|---|
| 650 | // id staat; dan krijgt het nummer een ander id en moet de shorthand mee.
|
|---|
| 651 | const s2 = site({ aliases: [BRON] });
|
|---|
| 652 | db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
|
|---|
| 653 | VALUES ('pb','s1','u1','botsing','Botsing','<p>[[track:t-bots]]</p>','published','2026-08-13T10:00:00Z')`).run();
|
|---|
| 654 | // Een nummer dat hier AL bestaat onder datzelfde id, van iets anders.
|
|---|
| 655 | db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('mx','s1','x.mp3','audio/mpeg',1,'/x')").run();
|
|---|
| 656 | db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('t-bots','s1','Al van mij','mx')").run();
|
|---|
| 657 |
|
|---|
| 658 | const kaart2 = new Map([
|
|---|
| 659 | [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
|
|---|
| 660 | [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
|
|---|
| 661 | [`${BRON}/tracks`, {
|
|---|
| 662 | type: 'OrderedCollection',
|
|---|
| 663 | orderedItems: [{
|
|---|
| 664 | id: `${BRON}/tracks/t-bots`, type: 'Audio', name: 'Van de bron',
|
|---|
| 665 | url: [{ type: 'Link', href: `${BRON}/audio/y.mp3`, mediaType: 'audio/mpeg' }],
|
|---|
| 666 | }],
|
|---|
| 667 | }],
|
|---|
| 668 | ]);
|
|---|
| 669 | await stil(() => Mig.ingestFromSource(s2, {
|
|---|
| 670 | deps: {
|
|---|
| 671 | getJson: async (_slug, url) => kaart2.get(url) || null,
|
|---|
| 672 | noteId: (b, id) => `${b}/ap/notes/${id}`,
|
|---|
| 673 | noteVisibility: AP.noteVisibility,
|
|---|
| 674 | audioRoot: '/nep/audio', mediaRoot: '/nep/media',
|
|---|
| 675 | signHeaders: () => ({ Signature: 'nep' }),
|
|---|
| 676 | safeFetch: async () => ({ ok: true, arrayBuffer: async () => Buffer.from('y'), headers: { get: () => 'audio/mpeg' } }),
|
|---|
| 677 | fs: { mkdirSync() {}, writeFileSync() {} },
|
|---|
| 678 | path,
|
|---|
| 679 | },
|
|---|
| 680 | }));
|
|---|
| 681 | // Het bestaande nummer blijft van jou; de tekst wijst naar iets dat bestaat.
|
|---|
| 682 | const inhoud = db.prepare("SELECT content FROM posts WHERE id = 'pb'").get().content;
|
|---|
| 683 | const m = /\[\[track:([^\]]+)\]\]/.exec(inhoud);
|
|---|
| 684 | assert.ok(m);
|
|---|
| 685 | assert.ok(db.prepare('SELECT 1 FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], 's1'),
|
|---|
| 686 | 'wat er ook gebeurde met het id, de verwijzing wijst naar een bestaand nummer');
|
|---|
| 687 | });
|
|---|
| 688 |
|
|---|
| 689 | test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
|
|---|
| 690 | const s = site({ aliases: [BRON] });
|
|---|
| 691 | const deps = bronnetje({ items: [note(`${BRON}/notes/priv`, { to: [`${BRON}/followers`] })] });
|
|---|
| 692 | const r = await stil(() => Mig.ingestFromSource(s, { deps }));
|
|---|
| 693 | assert.equal(r.posts, 1);
|
|---|
| 694 | assert.equal(Mig.buildMigration('https://nieuw.example', s).totalItems, 0);
|
|---|
| 695 | assert.equal(Mig.buildMigration('https://nieuw.example', s, { alles: true }).totalItems, 1);
|
|---|
| 696 | });
|
|---|