source: Klonkt/test/archive-recovery.test.js@ 7aa3140

main
Last change on this file since 7aa3140 was 7aa3140, checked in by roboburr <roboburr@…>, 5 weeks ago

Herstel uit andermans tijdlijn-cache (shaer-l1v)

De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
verschil tussen herstellen en opnieuw posten -- boosts, likes en antwoorden
elders wijzen naar die ids.

Er is bewust GEEN apart herstelpad. recover-from-cache.mjs maakt een gewoon
archief in het formaat uit docs/EXPORT-FORMAT.md, dat door dezelfde importer gaat
als een export: droogloop, versiecontrole, en dezelfde regel rond het behouden
van AP-ids. Een tweede implementatie zou uiteenlopen met de eerste.

Meerdere bronnen mogen samen: verschillende volgers dekken verschillende
periodes, en per AP-id wint de rijkste versie.

Titels moeten terug uit de TEKST, want buildNote bakt ze daar in (Mastodon
negeert name). Er is geen sluitend signaal: op echte data heet een post "Back
to 1987!" met slug "waiting-on-you", dus de slug is niet van de titel afgeleid.
We gaan op de vorm af -- een openende alinea met alleen vetgedrukte platte tekst
-- en het script drukt AF wat het lostrok, zodat een mens het naloopt. --houd-titel
laat alles staan.

Gevonden door tegen echte cachedata te draaien (24 posts van sound-fabrics uit de
beta-cache): een deel van de bijlagen loopt niet via /media/ maar via
/audio/stream/<bestandsnaam>, en dat is AUDIO_DIR, een andere map. Zonder dat pad
blijft op een muzieksite de halve catalogus liggen. De importer had hetzelfde
gat aan zijn kant: zo'n bestand werd wel weggeschreven maar verdween uit de
kolommen, omdat er geen bestemming onder /media voor was. Nu krijgt het er een.

Het manifest draagt shaer:recovered met de bronnen, het tijdvenster en wat er
principieel ontbreekt -- eigen antwoorden, alles van voor het volgmoment, en
concepten. Anders ziet iemand dit over een jaar aan voor een gewone export en
denkt hij dat de site compleet is.

14 tests, waaronder de hele ketting: cache -> archief -> importer -> posts met
behouden ids, titel, cover en gesloten poll. Suite 528/528.

  • Property mode set to 100644
File size: 11.8 KB
Line 
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
13import { test } from 'node:test';
14import assert from 'node:assert/strict';
15import fs from 'fs';
16import os from 'os';
17import path from 'path';
18import Database from 'better-sqlite3';
19
20process.env.DATABASE_PATH = ':memory:';
21process.env.PUBLIC_BASE_URL = 'https://boiert.eu';
22const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-herstel-'));
23process.env.MEDIA_PATH = MEDIA;
24
25const dbMod = await import('../src/config/database.js');
26const db = dbMod.default;
27dbMod.initializeDatabase();
28const AR = await import('../src/services/ArchiveRecoveryService.js');
29const AI = await import('../src/services/ArchiveImportService.js');
30
31const ACTOR = 'https://boiert.eu/ap/users/boiert';
32const 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.
35const GERED = path.join(werk, 'gered-media');
36fs.mkdirSync(path.join(GERED, 'post-images'), { recursive: true });
37const COVER = Buffer.from('bytes-van-de-cover-van-boiert');
38fs.writeFileSync(path.join(GERED, 'post-images', 'cover.webp'), COVER);
39
40/** Een tijdlijn-cache van een instance die boiert volgt. */
41function 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
60const 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.
87const 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
103const herstel = (extra = {}) => AR.recoverFromCache({
104 sources: [BRON, BRON2], actorUri: ACTOR, mediaRoot: GERED, exportedAt: 'X', ...extra,
105});
106
107test('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
114test('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
121test('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
129test('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
138test('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
146test('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
157test('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
165test('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
173test('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
181test('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
190test('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
213test('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
243test('zonder actor weigert hij te beginnen', () => {
244 assert.throws(() => AR.recoverFromCache({ sources: [BRON] }), /actorUri is verplicht/);
245});
246
247test('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
253test.after(() => {
254 for (const d of [MEDIA, werk]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* niets */ } }
255});
Note: See TracBrowser for help on using the repository browser.