source: Klonkt/test/archive-import.test.js@ 0475b13

main
Last change on this file since 0475b13 was 812fea9, checked in by roboburr <roboburr@…>, 5 weeks ago

Elke bijlage zegt waar hij voor was (shaer-pmr, vervolg)

Gevonden bij een oefenherstel: ik vergeleek dertien kolommen, noemde het een
schone rondgang, en vergeleek daarna ALLE kolommen. Toen bleken er drie dingen
weg te vallen.

COVER de bytes zaten in het archief, maar nergens stond dat ze de

cover waren. cover_image_url kwam als null terug, dus de post
stond zonder cover.

C2S-BIJLAGE idem: het audiobestand en zijn poster zaten erin, maar

c2s_attachments werd niet hersteld, dus de speler verdween.

SUB-SECONDE de import zette tijdstempels om naar SQL-notatie en verloor de

milliseconden. Twee posts in dezelfde seconde staan dan in
willekeurige volgorde.

Elke bijlage draagt nu een shaer:role -- cover, coverVideo, inline, c2s, poster,
track -- en een poster wijst met shaer:posterFor naar de bijlage waar hij bij
hoort. De importer bouwt cover_image_url, cover_video_url en c2s_attachments
daaruit terug. Een rol is geen versiering: zonder rol heeft het archief wel de
bytes maar niet het feit dat ze de cover WAREN, en dat zie je pas als je alle
kolommen vergelijkt.

Tijdstempels gaan nu ongewijzigd de database in. Klonkt schrijft zelf in twee
spellingen; het archief normaliseert naar ISO. Het MOMENT overleeft de rondgang,
de spelling niet -- nu ook zo gedocumenteerd in plaats van stilzwijgend.

Rondgang op echte beta-data: geen verschil meer over 29 kolommen, met de tijden
op moment vergeleken in plaats van op tekst. 21 tests. Suite 514/514.

  • Property mode set to 100644
File size: 17.6 KB
Line 
1// De importer (shaer-pmr), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
2//
3// De zwaarste toets is de RONDGANG: exporteer de moeilijkste post die Klonkt kan
4// maken, gooi hem weg, lees hem terug, en kijk of er nog hetzelfde staat. Een
5// exporter en een importer die elk apart kloppen maar niet op elkaar aansluiten
6// zijn samen niets waard.
7//
8// Run: npm test
9
10import { test } from 'node:test';
11import assert from 'node:assert/strict';
12import fs from 'fs';
13import os from 'os';
14import path from 'path';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
18const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-imp-'));
19process.env.MEDIA_PATH = MEDIA;
20
21const dbMod = await import('../src/config/database.js');
22const db = dbMod.default;
23dbMod.initializeDatabase();
24const AX = await import('../src/services/ArchiveExportService.js');
25const AI = await import('../src/services/ArchiveImportService.js');
26
27db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
28 .run('u1', 'u1', 'u1@test', 'x', 'god');
29db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
30
31fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
32const PLAATJE = Buffer.from('bytes-van-de-cover');
33fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
34
35db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, published_at,
36 created_at, updated_at, content_warning, nsfw, paid, paid_min_cents, quote_uri, quote_actor,
37 poll_json, language, tags, pinned, view_count)
38 VALUES ('zwaar','s1','zware-post','u1','Een zware post',
39 '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">','de teaser','published',
40 '2026-08-01 10:00:00','2026-08-01 10:00:00','2026-08-01 12:00:00','spoiler',1,1,500,
41 'https://mstdn.social/users/x/statuses/1','https://mstdn.social/users/x',
42 '{"multiple":false,"options":[{"name":"ja"},{"name":"nee"}],"endTime":"2026-08-09T10:00:00Z"}',
43 'nl','muziek, code',1,42)`).run();
44db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, created_at)
45 VALUES ('reply','zwaar',?,?,'Anna','@anna@mstdn.social','<p>hoi</p>','2026-08-02T10:00:00Z','2026-08-02 10:00:00')`)
46 .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
47
48const ARCHIEF = AX.buildArchive('me', { exportedAt: 'X' });
49const leeg = () => {
50 db.prepare('DELETE FROM posts').run();
51 db.prepare('DELETE FROM ap_interactions').run();
52 try { fs.rmSync(path.join(MEDIA, '2026'), { recursive: true, force: true }); } catch { /* niets */ }
53};
54
55test('de rondgang: wat eruit ging komt er hetzelfde weer in', () => {
56 leeg();
57 const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
58 assert.equal(r.posts, 1);
59 assert.equal(r.idsBehouden, true, 'zelfde origin, dus de AP-ids blijven');
60
61 const p = db.prepare('SELECT * FROM posts WHERE id = ?').get('zwaar');
62 assert.ok(p, 'het post-id is behouden -- daar wijzen de boosts elders naar');
63 assert.equal(p.title, 'Een zware post');
64 assert.equal(p.slug, 'zware-post');
65 assert.match(p.content, /<p>body<\/p>/);
66 assert.equal(p.excerpt, 'de teaser');
67 assert.equal(p.content_warning, 'spoiler');
68 assert.equal(p.nsfw, 1);
69 assert.equal(p.paid, 1);
70 assert.equal(p.paid_min_cents, 500);
71 assert.equal(p.quote_uri, 'https://mstdn.social/users/x/statuses/1');
72 assert.equal(p.language, 'nl');
73 assert.equal(p.tags, 'muziek, code');
74 assert.equal(p.pinned, 1);
75 assert.equal(p.view_count, 42);
76 assert.deepEqual(JSON.parse(p.poll_json).options.map((o) => o.name), ['ja', 'nee']);
77});
78
79test('en de media staan terug op hun oude plek, want de content wijst daarheen', () => {
80 const op = path.join(MEDIA, '2026', 'cover.jpg');
81 assert.ok(fs.existsSync(op), 'anders is de <img> in de body een dode verwijzing');
82 assert.deepEqual(fs.readFileSync(op), PLAATJE);
83});
84
85test('de antwoorden komen terug als archief', () => {
86 const r = db.prepare("SELECT * FROM ap_interactions WHERE post_id = 'zwaar'").all();
87 assert.equal(r.length, 1);
88 assert.equal(r[0].actor_name, 'Anna');
89 assert.equal(r[0].object_uri, 'https://mstdn.social/users/anna/statuses/9');
90});
91
92test('twee keer importeren levert geen dubbele posts op', () => {
93 const voor = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
94 const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
95 assert.equal(r.overgeslagen, 1, 'de bestaande post wordt overgeslagen, niet overschreven');
96 assert.equal(r.posts, 0);
97 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, voor);
98});
99
100test('overschrijven gebeurt alleen als je erom vraagt', () => {
101 db.prepare("UPDATE posts SET title = 'handmatig gewijzigd' WHERE id = 'zwaar'").run();
102 AI.importArchive(ARCHIEF.files, { slug: 'me' });
103 assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'handmatig gewijzigd',
104 'zonder --overwrite blijft wat er staat staan');
105 const r = AI.importArchive(ARCHIEF.files, { slug: 'me', overwrite: true });
106 assert.equal(r.overschreven, 1);
107 assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'Een zware post');
108});
109
110test('een droogloop schrijft niets', () => {
111 leeg();
112 const r = AI.importArchive(ARCHIEF.files, { slug: 'me', dryRun: true });
113 assert.equal(r.posts, 1, 'hij vertelt wel wat er zou gebeuren');
114 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0);
115 assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
116});
117
118test('een nieuwere formaatversie wordt in zijn GEHEEL geweigerd', () => {
119 // Niet half lezen: een half begrepen herstel ziet eruit alsof het gelukt is.
120 const files = new Map(ARCHIEF.files);
121 const m = JSON.parse(files.get('manifest.json').toString());
122 m.formatVersion = 99;
123 files.set('manifest.json', Buffer.from(JSON.stringify(m)));
124 assert.throws(() => AI.importArchive(files, { slug: 'me' }), /nieuwer dan deze Klonkt kent/);
125 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0, 'en er is niets geschreven');
126});
127
128test('een andere origin levert NIEUWE ids op, met een waarschuwing', () => {
129 // Oude ids houden op een ander domein zou objecten publiceren onder een id dat
130 // je niet beheert -- andere servers halen dat daar op, en het is bovendien een
131 // vervalsingsoppervlak.
132 leeg();
133 const files = new Map(ARCHIEF.files);
134 const m = JSON.parse(files.get('manifest.json').toString());
135 m.origin = 'https://ergens-anders.test';
136 files.set('manifest.json', Buffer.from(JSON.stringify(m)));
137 const r = AI.importArchive(files, { slug: 'me' });
138 assert.equal(r.idsBehouden, false);
139 assert.match(r.waarschuwingen.join(' '), /origin verschilt/);
140 assert.equal(db.prepare("SELECT COUNT(*) AS n FROM posts WHERE id = 'zwaar'").get().n, 0);
141 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 1, 'wel geimporteerd, met een nieuw id');
142});
143
144test('ontbrekende media worden geteld en gemeld', () => {
145 leeg();
146 const files = new Map(ARCHIEF.files);
147 const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
148 const o = JSON.parse(files.get(pad).toString());
149 o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: 'https://oud.test/media/weg.jpg', 'shaer:availability': 'missing', 'shaer:originalUrl': 'https://oud.test/media/weg.jpg' }];
150 files.set(pad, Buffer.from(JSON.stringify(o)));
151 const r = AI.importArchive(files, { slug: 'me' });
152 assert.equal(r.mediaMissing, 1);
153 assert.equal(r.gemist[0].url, 'https://oud.test/media/weg.jpg');
154});
155
156test('een archief dat zijn eigen bestand mist wordt apart gemeld', () => {
157 // 'included' met een ontbrekend bestand is een KAPOT archief, geen verdwenen
158 // media. Die twee op een hoop gooien verbergt een echt probleem.
159 leeg();
160 const files = new Map(ARCHIEF.files);
161 for (const k of [...files.keys()]) if (k.startsWith('media/')) files.delete(k);
162 const r = AI.importArchive(files, { slug: 'me' });
163 assert.equal(r.mediaMissing, 0);
164 assert.match(r.waarschuwingen.join(' '), /dat er niet in zit/);
165});
166
167test('een bestand met een verkeerde checksum wordt niet weggeschreven', () => {
168 leeg();
169 const files = new Map(ARCHIEF.files);
170 const k = [...files.keys()].find((x) => x.startsWith('media/'));
171 files.set(k, Buffer.from('iets heel anders'));
172 const r = AI.importArchive(files, { slug: 'me' });
173 assert.match(r.waarschuwingen.join(' '), /checksum klopt niet/);
174 assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
175});
176
177test('een antwoordenbundel zonder archiefmarkering wordt overgeslagen', () => {
178 leeg();
179 const files = new Map(ARCHIEF.files);
180 const pad = [...files.keys()].find((k) => k.startsWith('replies/'));
181 const c = JSON.parse(files.get(pad).toString());
182 delete c['shaer:archive'];
183 files.set(pad, Buffer.from(JSON.stringify(c)));
184 const r = AI.importArchive(files, { slug: 'me' });
185 assert.equal(r.replies, 0);
186 assert.match(r.waarschuwingen.join(' '), /niet gemarkeerd als archief/);
187});
188
189test('een pad buiten de media-map wordt niet geschreven', () => {
190 leeg();
191 const files = new Map(ARCHIEF.files);
192 const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
193 const o = JSON.parse(files.get(pad).toString());
194 const mk = [...files.keys()].find((k) => k.startsWith('media/'));
195 o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: mk, 'shaer:availability': 'included', 'shaer:originalUrl': '/media/../../../tmp/klonkt-inbraak' }];
196 files.set(pad, Buffer.from(JSON.stringify(o)));
197 AI.importArchive(files, { slug: 'me' });
198 assert.equal(fs.existsSync('/tmp/klonkt-inbraak'), false);
199});
200
201test('readable/ wordt nooit gelezen', () => {
202 // Dat is de hele reden dat het afgeleid is: twee bronnen die allebei gelezen
203 // worden gaan uiteenlopen.
204 leeg();
205 const files = new Map(ARCHIEF.files);
206 for (const k of [...files.keys()]) if (k.startsWith('readable/')) files.set(k, Buffer.from('ONZIN'));
207 const r = AI.importArchive(files, { slug: 'me' });
208 assert.equal(r.posts, 1);
209 assert.match(db.prepare("SELECT content FROM posts WHERE id = 'zwaar'").get().content, /<p>body<\/p>/);
210});
211
212test('de zip-lezer haalt hetzelfde eruit als de map-lezer', () => {
213 const zip = path.join(MEDIA, 'rond.zip');
214 fs.writeFileSync(zip, AX.zipArchive(ARCHIEF.files));
215 const uit = AI.readArchive(zip);
216 assert.deepEqual([...uit.keys()].sort(), [...ARCHIEF.files.keys()].sort());
217 for (const k of uit.keys()) assert.deepEqual(uit.get(k), ARCHIEF.files.get(k), `${k} verschilt`);
218});
219
220test('een GECOMPRIMEERDE zip van elders wordt ook gelezen', async () => {
221 // Onze eigen zip is store-only. Zou de lezer alleen dat aankunnen, dan is het
222 // geen uitwisselformaat maar een privé-doosje.
223 const zlib = await import('zlib');
224 const inhoud = Buffer.from('{"formatVersion":1,"origin":"x"}');
225 const naam = Buffer.from('manifest.json', 'utf8');
226 const comp = zlib.deflateRawSync(inhoud);
227 const crc = (() => {
228 let c = -1;
229 for (const b of inhoud) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xEDB88320 : c >>> 1; }
230 return (c ^ -1) >>> 0;
231 })();
232 const lh = Buffer.alloc(30);
233 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(8, 8);
234 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(inhoud.length, 22);
235 lh.writeUInt16LE(naam.length, 26);
236 const ch = Buffer.alloc(46);
237 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(8, 10);
238 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(inhoud.length, 24);
239 ch.writeUInt16LE(naam.length, 28); ch.writeUInt32LE(0, 42);
240 const eocd = Buffer.alloc(22);
241 eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
242 eocd.writeUInt32LE(46 + naam.length, 12); eocd.writeUInt32LE(30 + naam.length + comp.length, 16);
243 const zip = path.join(MEDIA, 'deflate.zip');
244 fs.writeFileSync(zip, Buffer.concat([lh, naam, comp, ch, naam, eocd]));
245 assert.deepEqual(AI.readArchiveZip(fs.readFileSync(zip)).get('manifest.json'), inhoud);
246});
247
248test('een gehoste track overleeft de rondgang, met zijn bestand erbij', () => {
249 leeg();
250 db.prepare('DELETE FROM audio_tracks').run();
251 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
252 const WAV = Buffer.from('nep-audio-bytes');
253 fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
254 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
255 .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
256 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license)
257 VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY')`).run();
258 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
259 VALUES ('audio','s1','met-audio','u1','Met audio','<p>luister</p>[[track:tr1]]','published','2026-08-03 10:00:00')`).run();
260
261 const arch = AX.buildArchive('me', { exportedAt: 'X' });
262 db.prepare('DELETE FROM posts').run();
263 db.prepare('DELETE FROM audio_tracks').run();
264 db.prepare('DELETE FROM media').run();
265 fs.rmSync(path.join(MEDIA, 'audio'), { recursive: true, force: true });
266
267 AI.importArchive(arch.files, { slug: 'me' });
268 const t = db.prepare("SELECT * FROM audio_tracks WHERE id = 'tr1'").get();
269 assert.ok(t, '[[track:tr1]] in de content valt anders op niets terug');
270 assert.equal(t.title, 'Kanonnen');
271 assert.equal(t.license, 'CC BY');
272 const m = db.prepare('SELECT * FROM media WHERE id = ?').get(t.media_id);
273 assert.ok(m && fs.existsSync(m.storage_path), 'en het bestand staat er weer');
274 assert.deepEqual(fs.readFileSync(m.storage_path), WAV);
275});
276
277test('een vroegtijdig gesloten poll blijft gesloten', () => {
278 // Gevonden bij de rondgang op echte beta-data: poll_json.closed viel weg, dus
279 // een poll die je met een einddatum in de TOEKOMST had gesloten stond na een
280 // herstel weer open. AS2 kent hiervoor `closed` op een Question.
281 leeg();
282 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, poll_json)
283 VALUES ('dicht','s1','dichte-poll','u1','Dicht','<p>x</p>','published','2026-08-04 10:00:00',
284 '{"multiple":false,"options":[{"name":"a"},{"name":"b"}],"endTime":"2030-01-01T00:00:00Z","closed":true}')`).run();
285 const arch = AX.buildArchive('me', { exportedAt: 'X' });
286 assert.equal(JSON.parse(arch.files.get('posts/dicht.json').toString()).closed, true);
287 db.prepare('DELETE FROM posts').run();
288 AI.importArchive(arch.files, { slug: 'me' });
289 const d = JSON.parse(db.prepare("SELECT poll_json FROM posts WHERE id = 'dicht'").get().poll_json);
290 assert.equal(d.closed, true, 'anders staat een gesloten stemming na een herstel weer open');
291 assert.equal(d.endTime, '2030-01-01T00:00:00Z');
292});
293
294test('de cover en de speler komen terug, niet alleen hun bestanden', () => {
295 // Gevonden bij het oefenherstel, door ALLE kolommen te vergelijken in plaats
296 // van een handjevol: de bytes zaten in het archief, maar nergens stond dat ze
297 // de cover waren. De post kwam zonder cover en zonder speler terug.
298 leeg();
299 fs.mkdirSync(path.join(MEDIA, 'c'), { recursive: true });
300 for (const [n, b] of [['cov.jpg', 'cover'], ['op.m4a', 'audio'], ['op.png', 'poster']]) {
301 fs.writeFileSync(path.join(MEDIA, 'c', n), Buffer.from(b));
302 }
303 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, cover_image_url, cover_alt, c2s_attachments)
304 VALUES ('rijk','s1','rijke-post','u1','Rijk','<p>x</p>','published','2026-08-05 10:00:00',
305 '/media/c/cov.jpg','de cover', ?)`)
306 .run(JSON.stringify([{ url: '/media/c/op.m4a', mediaType: 'audio/mp4', name: 'opname.m4a', poster: '/media/c/op.png' }]));
307
308 const arch = AX.buildArchive('me', { exportedAt: 'X' });
309 const rollen = JSON.parse(arch.files.get('posts/rijk.json').toString()).attachment.map((a) => a['shaer:role']);
310 assert.deepEqual(rollen.sort(), ['c2s', 'cover', 'poster'], 'elke bijlage zegt waar hij voor was');
311
312 db.prepare('DELETE FROM posts').run();
313 fs.rmSync(path.join(MEDIA, 'c'), { recursive: true, force: true });
314 AI.importArchive(arch.files, { slug: 'me' });
315
316 const p = db.prepare("SELECT * FROM posts WHERE id = 'rijk'").get();
317 assert.equal(p.cover_image_url, '/media/c/cov.jpg', 'zonder dit staat de post zonder cover terug');
318 assert.equal(p.cover_alt, 'de cover');
319 const c2s = JSON.parse(p.c2s_attachments);
320 assert.equal(c2s[0].url, '/media/c/op.m4a');
321 assert.equal(c2s[0].poster, '/media/c/op.png', 'de poster hoort weer bij zijn opname');
322 assert.ok(fs.existsSync(path.join(MEDIA, 'c', 'cov.jpg')));
323});
324
325test('het MOMENT van een tijdstempel overleeft, ook uit SQL-notatie', () => {
326 // Klonkt schrijft in twee spellingen. Het archief normaliseert naar ISO; het
327 // moment blijft, de spelling niet. Dat is gedocumenteerd gedrag, geen verlies.
328 leeg();
329 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
330 VALUES ('tijd','s1','tijd','u1','T','<p>x</p>','published','2026-07-01 12:56:10')`).run();
331 const arch = AX.buildArchive('me', { exportedAt: 'X' });
332 db.prepare('DELETE FROM posts').run();
333 AI.importArchive(arch.files, { slug: 'me' });
334 const p = db.prepare("SELECT published_at FROM posts WHERE id = 'tijd'").get();
335 assert.equal(Date.parse(p.published_at), Date.parse('2026-07-01T12:56:10Z'));
336});
337
338test('zonder manifest is het geen archief', () => {
339 const files = new Map(ARCHIEF.files);
340 files.delete('manifest.json');
341 assert.throws(() => AI.importArchive(files, { slug: 'me' }), /geen manifest/);
342});
343
344test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.