| 1 | // Herstel uit andermans tijdlijn-cache (shaer-l1v).
|
|---|
| 2 | //
|
|---|
| 3 | // De opzet bootst de boiert-situatie na: een verloren site waarvan de posts nog
|
|---|
| 4 | // in de ap_timeline van een volgende instance staan, en waarvan de mediamap de
|
|---|
| 5 | // ramp wel heeft overleefd.
|
|---|
| 6 | //
|
|---|
| 7 | // De zwaarste toets is de KETTING: cache -> herstelarchief -> importer -> posts.
|
|---|
| 8 | // Er is met opzet geen apart herstelpad; als die ketting niet sluit is het hele
|
|---|
| 9 | // idee van een gedeeld formaat niets waard.
|
|---|
| 10 | //
|
|---|
| 11 | // Run: npm test
|
|---|
| 12 |
|
|---|
| 13 | import { test } from 'node:test';
|
|---|
| 14 | import assert from 'node:assert/strict';
|
|---|
| 15 | import fs from 'fs';
|
|---|
| 16 | import os from 'os';
|
|---|
| 17 | import path from 'path';
|
|---|
| 18 | import Database from 'better-sqlite3';
|
|---|
| 19 |
|
|---|
| 20 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 21 | process.env.PUBLIC_BASE_URL = 'https://boiert.eu';
|
|---|
| 22 | const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-herstel-'));
|
|---|
| 23 | process.env.MEDIA_PATH = MEDIA;
|
|---|
| 24 |
|
|---|
| 25 | const dbMod = await import('../src/config/database.js');
|
|---|
| 26 | const db = dbMod.default;
|
|---|
| 27 | dbMod.initializeDatabase();
|
|---|
| 28 | const AR = await import('../src/services/ArchiveRecoveryService.js');
|
|---|
| 29 | const AI = await import('../src/services/ArchiveImportService.js');
|
|---|
| 30 |
|
|---|
| 31 | const ACTOR = 'https://boiert.eu/ap/users/boiert';
|
|---|
| 32 | const werk = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-bron-'));
|
|---|
| 33 |
|
|---|
| 34 | // De GEREDDE mediamap van de verloren site: de database is weg, de bestanden niet.
|
|---|
| 35 | const GERED = path.join(werk, 'gered-media');
|
|---|
| 36 | fs.mkdirSync(path.join(GERED, 'post-images'), { recursive: true });
|
|---|
| 37 | const COVER = Buffer.from('bytes-van-de-cover-van-boiert');
|
|---|
| 38 | fs.writeFileSync(path.join(GERED, 'post-images', 'cover.webp'), COVER);
|
|---|
| 39 |
|
|---|
| 40 | /** Een tijdlijn-cache van een instance die boiert volgt. */
|
|---|
| 41 | function maakBron(naam, rijen) {
|
|---|
| 42 | const p = path.join(werk, naam);
|
|---|
| 43 | const s = new Database(p);
|
|---|
| 44 | s.exec(`CREATE TABLE ap_timeline (id TEXT NOT NULL, slug TEXT NOT NULL, author_uri TEXT, author_name TEXT,
|
|---|
| 45 | author_handle TEXT, author_icon TEXT, author_url TEXT, content TEXT, url TEXT, published TEXT,
|
|---|
| 46 | media_json TEXT, created_at DATETIME, boosted INTEGER, liked INTEGER, nsfw INTEGER, cw TEXT,
|
|---|
| 47 | reblog_name TEXT, poll_json TEXT, quote_json TEXT, emoji_json TEXT, embed_json TEXT, UNIQUE(slug, id))`);
|
|---|
| 48 | const ins = s.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, content, url, published, media_json, nsfw, cw, poll_json, quote_json)
|
|---|
| 49 | VALUES (@id,'volger',@author_uri,@content,@url,@published,@media_json,@nsfw,@cw,@poll_json,@quote_json)`);
|
|---|
| 50 | for (const r of rijen) {
|
|---|
| 51 | ins.run({
|
|---|
| 52 | author_uri: ACTOR, content: '', url: null, published: null, media_json: null,
|
|---|
| 53 | nsfw: 0, cw: null, poll_json: null, quote_json: null, ...r,
|
|---|
| 54 | });
|
|---|
| 55 | }
|
|---|
| 56 | s.close();
|
|---|
| 57 | return p;
|
|---|
| 58 | }
|
|---|
| 59 |
|
|---|
| 60 | const BRON = maakBron('sound-fabrics.sqlite', [
|
|---|
| 61 | {
|
|---|
| 62 | id: 'https://boiert.eu/ap/notes/post-1',
|
|---|
| 63 | content: '<p><strong>Back to 1987!</strong></p><p>Rick Astley in een AI-cover.</p>',
|
|---|
| 64 | url: 'https://boiert.eu/waiting-on-you',
|
|---|
| 65 | published: '2026-04-24T06:32:45.000Z',
|
|---|
| 66 | media_json: JSON.stringify([{ url: 'https://boiert.eu/media/post-images/cover.webp', type: 'image/webp', name: 'de hoes' }]),
|
|---|
| 67 | },
|
|---|
| 68 | {
|
|---|
| 69 | id: 'https://boiert.eu/ap/notes/post-2',
|
|---|
| 70 | content: '<p>Een korte post zonder titel.</p>',
|
|---|
| 71 | url: 'https://boiert.eu/kort',
|
|---|
| 72 | published: '2026-05-01T10:00:00.000Z',
|
|---|
| 73 | cw: 'spoiler', nsfw: 1,
|
|---|
| 74 | },
|
|---|
| 75 | {
|
|---|
| 76 | id: 'https://boiert.eu/ap/notes/post-3',
|
|---|
| 77 | content: '<p><strong>Stemming</strong></p><p>Wat wordt het?</p>',
|
|---|
| 78 | url: 'https://boiert.eu/stemming',
|
|---|
| 79 | published: '2026-05-02T10:00:00.000Z',
|
|---|
| 80 | poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja', count: 3 }, { name: 'nee', count: 1 }], endTime: '2026-05-09T10:00:00Z', closed: true }),
|
|---|
| 81 | },
|
|---|
| 82 | // Iemand ANDERS in dezelfde tijdlijn: die hoort er niet in.
|
|---|
| 83 | { id: 'https://elders.test/ap/notes/x', author_uri: 'https://elders.test/users/iemand', content: '<p>niet van boiert</p>', url: 'https://elders.test/x' },
|
|---|
| 84 | ]);
|
|---|
| 85 |
|
|---|
| 86 | // Een tweede volger die verder terug gaat: de vereniging dekt meer dan een bron.
|
|---|
| 87 | const BRON2 = maakBron('tweede.sqlite', [
|
|---|
| 88 | {
|
|---|
| 89 | id: 'https://boiert.eu/ap/notes/post-0',
|
|---|
| 90 | content: '<p><strong>De oudste</strong></p><p>van voor de andere volger.</p>',
|
|---|
| 91 | url: 'https://boiert.eu/de-oudste',
|
|---|
| 92 | published: '2026-01-05T09:00:00.000Z',
|
|---|
| 93 | },
|
|---|
| 94 | // Dezelfde post als in bron 1, maar afgekapt: de RIJKSTE versie hoort te winnen.
|
|---|
| 95 | {
|
|---|
| 96 | id: 'https://boiert.eu/ap/notes/post-1',
|
|---|
| 97 | content: '<p><strong>Back to 1987!</strong></p>',
|
|---|
| 98 | url: 'https://boiert.eu/waiting-on-you',
|
|---|
| 99 | published: '2026-04-24T06:32:45.000Z',
|
|---|
| 100 | },
|
|---|
| 101 | ]);
|
|---|
| 102 |
|
|---|
| 103 | const herstel = (extra = {}) => AR.recoverFromCache({
|
|---|
| 104 | sources: [BRON, BRON2], actorUri: ACTOR, mediaRoot: GERED, exportedAt: 'X', ...extra,
|
|---|
| 105 | });
|
|---|
| 106 |
|
|---|
| 107 | test('alleen de posts van de verloren actor komen mee', () => {
|
|---|
| 108 | const { rapport, files } = herstel();
|
|---|
| 109 | assert.equal(rapport.posts, 4);
|
|---|
| 110 | assert.equal(files.has('posts/x.json'), false, 'andermans post hoort er niet in');
|
|---|
| 111 | assert.ok(files.has('posts/post-1.json'));
|
|---|
| 112 | });
|
|---|
| 113 |
|
|---|
| 114 | test('de AP-ids blijven staan -- daar hangt het hele herstel aan', () => {
|
|---|
| 115 | const { files, manifest } = herstel();
|
|---|
| 116 | const o = JSON.parse(files.get('posts/post-1.json').toString());
|
|---|
| 117 | assert.equal(o.id, 'https://boiert.eu/ap/notes/post-1');
|
|---|
| 118 | assert.equal(manifest.origin, 'https://boiert.eu', 'anders weigert de importer de ids te behouden');
|
|---|
| 119 | });
|
|---|
| 120 |
|
|---|
| 121 | test('de titel wordt uit de tekst gevist en de rest blijft heel', () => {
|
|---|
| 122 | const o = JSON.parse(herstel().files.get('posts/post-1.json').toString());
|
|---|
| 123 | assert.equal(o.name, 'Back to 1987!');
|
|---|
| 124 | assert.equal(o.type, 'Article');
|
|---|
| 125 | assert.equal(o.content, '<p>Rick Astley in een AI-cover.</p>');
|
|---|
| 126 | assert.equal(o['shaer:slug'], 'waiting-on-you', 'de slug komt uit de permalink, niet uit de titel');
|
|---|
| 127 | });
|
|---|
| 128 |
|
|---|
| 129 | test('een post zonder vetgedrukte openingsregel houdt zijn hele tekst', () => {
|
|---|
| 130 | const o = JSON.parse(herstel().files.get('posts/post-2.json').toString());
|
|---|
| 131 | assert.equal(o.name, undefined);
|
|---|
| 132 | assert.equal(o.type, 'Note');
|
|---|
| 133 | assert.equal(o.content, '<p>Een korte post zonder titel.</p>');
|
|---|
| 134 | assert.equal(o.summary, 'spoiler');
|
|---|
| 135 | assert.equal(o.sensitive, true);
|
|---|
| 136 | });
|
|---|
| 137 |
|
|---|
| 138 | test('met --houd-titel blijft de tekst onaangeroerd', () => {
|
|---|
| 139 | // Er is geen sluitend signaal dat een vetgedrukte eerste regel een titel was,
|
|---|
| 140 | // dus er moet een uitweg zijn.
|
|---|
| 141 | const o = JSON.parse(herstel({ houdTitelInTekst: true }).files.get('posts/post-1.json').toString());
|
|---|
| 142 | assert.equal(o.name, undefined);
|
|---|
| 143 | assert.match(o.content, /<strong>Back to 1987!<\/strong>/);
|
|---|
| 144 | });
|
|---|
| 145 |
|
|---|
| 146 | test('de media komen van de geredde schijf, niet uit de cache', () => {
|
|---|
| 147 | // De cache bewaart alleen URL's. De bytes overleefden de ramp los van de
|
|---|
| 148 | // database, en het URL-pad is de sleutel ertussen.
|
|---|
| 149 | const { files, rapport } = herstel();
|
|---|
| 150 | const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
|
|---|
| 151 | assert.equal(a['shaer:availability'], 'included');
|
|---|
| 152 | assert.equal(a['shaer:role'], 'cover');
|
|---|
| 153 | assert.deepEqual(files.get(a.url), COVER);
|
|---|
| 154 | assert.equal(rapport.media, 1);
|
|---|
| 155 | });
|
|---|
| 156 |
|
|---|
| 157 | test('zonder geredde mediamap wordt het gat benoemd, niet verzwegen', () => {
|
|---|
| 158 | const { files, rapport } = herstel({ mediaRoot: null });
|
|---|
| 159 | const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
|
|---|
| 160 | assert.equal(a['shaer:availability'], 'missing');
|
|---|
| 161 | assert.equal(a['shaer:originalUrl'], 'https://boiert.eu/media/post-images/cover.webp');
|
|---|
| 162 | assert.equal(rapport.mediaMissing, 1);
|
|---|
| 163 | });
|
|---|
| 164 |
|
|---|
| 165 | test('een poll overleeft, inclusief dat hij gesloten was', () => {
|
|---|
| 166 | const o = JSON.parse(herstel().files.get('posts/post-3.json').toString());
|
|---|
| 167 | assert.equal(o.type, 'Question');
|
|---|
| 168 | assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
|
|---|
| 169 | assert.equal(o.closed, true);
|
|---|
| 170 | assert.equal(o.endTime, '2026-05-09T10:00:00Z');
|
|---|
| 171 | });
|
|---|
| 172 |
|
|---|
| 173 | test('twee bronnen samen dekken meer, en de rijkste versie wint', () => {
|
|---|
| 174 | const { files, rapport } = herstel();
|
|---|
| 175 | assert.ok(files.has('posts/post-0.json'), 'alleen de tweede volger had deze');
|
|---|
| 176 | assert.match(JSON.parse(files.get('posts/post-1.json').toString()).content, /Rick Astley/,
|
|---|
| 177 | 'de afgekapte versie uit de tweede bron mag de volledige niet overschrijven');
|
|---|
| 178 | assert.equal(rapport.bronnen.length, 2);
|
|---|
| 179 | });
|
|---|
| 180 |
|
|---|
| 181 | test('het manifest zegt dat dit GERECONSTRUEERD is en wat er mist', () => {
|
|---|
| 182 | // Anders ziet iemand dit over een jaar aan voor een gewone export en denkt hij
|
|---|
| 183 | // dat de site compleet is.
|
|---|
| 184 | const m = herstel().manifest;
|
|---|
| 185 | assert.equal(m['shaer:recovered'].from, 'timeline-cache');
|
|---|
| 186 | assert.deepEqual(m['shaer:recovered'].window, { oldest: '2026-01-05T09:00:00.000Z', newest: '2026-05-02T10:00:00.000Z' });
|
|---|
| 187 | assert.match(m['shaer:recovered'].missing.join(' '), /replies/);
|
|---|
| 188 | });
|
|---|
| 189 |
|
|---|
| 190 | test('DE KETTING: cache -> archief -> importer -> posts', () => {
|
|---|
| 191 | // Bewust geen eigen herstelpad. Zou dit niet sluiten, dan was het gedeelde
|
|---|
| 192 | // formaat een papieren afspraak.
|
|---|
| 193 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 194 | .run('u1', 'boiert', 'b@test', 'x', 'god');
|
|---|
| 195 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'boiert', 'Boiert', 'u1');
|
|---|
| 196 |
|
|---|
| 197 | const r = AI.importArchive(herstel().files, { slug: 'boiert' });
|
|---|
| 198 | assert.equal(r.idsBehouden, true, 'zelfde origin, dus de boosts elders vinden hun post terug');
|
|---|
| 199 | assert.equal(r.posts, 4);
|
|---|
| 200 |
|
|---|
| 201 | const p = db.prepare("SELECT * FROM posts WHERE id = 'post-1'").get();
|
|---|
| 202 | assert.ok(p, 'het oorspronkelijke post-id staat er weer');
|
|---|
| 203 | assert.equal(p.title, 'Back to 1987!');
|
|---|
| 204 | assert.equal(p.slug, 'waiting-on-you');
|
|---|
| 205 | assert.equal(p.status, 'published');
|
|---|
| 206 | assert.equal(p.cover_image_url, '/media/post-images/cover.webp');
|
|---|
| 207 | assert.deepEqual(fs.readFileSync(path.join(MEDIA, 'post-images', 'cover.webp')), COVER);
|
|---|
| 208 |
|
|---|
| 209 | const q = db.prepare("SELECT poll_json FROM posts WHERE id = 'post-3'").get();
|
|---|
| 210 | assert.equal(JSON.parse(q.poll_json).closed, true);
|
|---|
| 211 | });
|
|---|
| 212 |
|
|---|
| 213 | test('gehoste audio wordt ook teruggevonden, van een ANDERE map', () => {
|
|---|
| 214 | // Op echte cachedata bleek een deel van de bijlagen niet via /media/ te lopen
|
|---|
| 215 | // maar via /audio/stream/<bestandsnaam>, en dat is AUDIO_DIR -- een andere map
|
|---|
| 216 | // naast de mediamap. Zonder dit blijft de halve muziekcatalogus liggen.
|
|---|
| 217 | const AUDIO = path.join(werk, 'gered-audio');
|
|---|
| 218 | fs.mkdirSync(AUDIO, { recursive: true });
|
|---|
| 219 | const MP3 = Buffer.from('nep-mp3-bytes');
|
|---|
| 220 | fs.writeFileSync(path.join(AUDIO, 'track.mp3'), MP3);
|
|---|
| 221 | const bron = maakBron('audio.sqlite', [{
|
|---|
| 222 | id: 'https://boiert.eu/ap/notes/post-audio',
|
|---|
| 223 | content: '<p>luister</p>',
|
|---|
| 224 | url: 'https://boiert.eu/luister',
|
|---|
| 225 | published: '2026-06-01T10:00:00.000Z',
|
|---|
| 226 | media_json: JSON.stringify([{ url: 'https://boiert.eu/audio/stream/track.mp3', type: 'audio/mpeg' }]),
|
|---|
| 227 | }]);
|
|---|
| 228 | const uit = AR.recoverFromCache({ sources: [bron], actorUri: ACTOR, mediaRoot: GERED, audioRoot: AUDIO, exportedAt: 'X' });
|
|---|
| 229 | const a = JSON.parse(uit.files.get('posts/post-audio.json').toString()).attachment[0];
|
|---|
| 230 | assert.equal(a['shaer:availability'], 'included');
|
|---|
| 231 | assert.deepEqual(uit.files.get(a.url), MP3);
|
|---|
| 232 |
|
|---|
| 233 | // En het moet ook de import overleven: het bestand hoort ergens te landen EN
|
|---|
| 234 | // vanuit de kolommen vindbaar te zijn, niet alleen weggeschreven.
|
|---|
| 235 | const r = AI.importArchive(uit.files, { slug: 'boiert' });
|
|---|
| 236 | assert.equal(r.media, 1);
|
|---|
| 237 | const p = db.prepare("SELECT c2s_attachments, cover_image_url FROM posts WHERE id = 'post-audio'").get();
|
|---|
| 238 | const plek = p.cover_image_url;
|
|---|
| 239 | assert.ok(plek && plek.startsWith('/media/'), `de bijlage hoort een plek te hebben, kreeg ${plek}`);
|
|---|
| 240 | assert.ok(fs.existsSync(path.join(MEDIA, plek.slice('/media/'.length))), 'en daar hoort het bestand te staan');
|
|---|
| 241 | });
|
|---|
| 242 |
|
|---|
| 243 | test('zonder actor weigert hij te beginnen', () => {
|
|---|
| 244 | assert.throws(() => AR.recoverFromCache({ sources: [BRON] }), /actorUri is verplicht/);
|
|---|
| 245 | });
|
|---|
| 246 |
|
|---|
| 247 | test('een onleesbare bron stopt het herstel niet, maar wordt wel gemeld', () => {
|
|---|
| 248 | const { rapport } = AR.recoverFromCache({ sources: [BRON, path.join(werk, 'bestaat-niet.sqlite')], actorUri: ACTOR, exportedAt: 'X' });
|
|---|
| 249 | assert.ok(rapport.posts > 0);
|
|---|
| 250 | assert.match(rapport.waarschuwingen.join(' '), /niet te openen/);
|
|---|
| 251 | });
|
|---|
| 252 |
|
|---|
| 253 | test.after(() => {
|
|---|
| 254 | for (const d of [MEDIA, werk]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* niets */ } }
|
|---|
| 255 | });
|
|---|