source: Klonkt/test/archive-import.test.js

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

GUID's blijven altijd behouden bij een verhuizing

Robin vroeg of we bestaande GUID's hergebruiken. Het antwoord was zes vakjes:

zip ophaalknop

posts nieuw bij andere origin altijd nieuw
tracks OUD BEHOUDEN altijd nieuw
playlists OUD BEHOUDEN altijd nieuw

Vier keer nieuw, twee keer niet, en die twee uitzonderingen waren niet
besloten maar ontstaan: de zip schreef INSERT OR REPLACE met het id uit het
archief zonder dat daar ooit over nagedacht is. Die scheve tabel was precies de
oorzaak van de shortcode die Robin op TikTik zag: post uit de zip met
[[track:oud]], nummer uit de pull met een nieuw id.

Zijn besluit: altijd behouden. Nu is het één regel.

WAAROM DAT MAG. Het interne id is niet de AP-URI. https://nieuw/ap/notes/<id>
is een ander adres dan https://oud/ap/notes/<id>, dus je claimt niets van een
ander door het GUID te hergebruiken. Het oude argument in de code ("een id op
andermans domein publiceren is een vervalsingsoppervlak") haalde die twee door
elkaar. Wat je wint: elke interne verwijzing blijft kloppen, [[track:]],
[[playlist:]] en [[album:]] wijzen na de verhuizing nog naar het goede ding.

Wat NIET verandert is de AP-URI. Die is domeingebonden en hoort nieuw te zijn,
en daar is de migration-collectie voor. idsBehouden gaat voortaan alleen daar
nog over.

Gemeten door dezelfde inhoud via BEIDE routes over elkaar heen te halen:

posts oud 6 | nieuw 6 | zelfde id 6 | afwijkend 0
audio_tracks oud 3 | nieuw 3 | zelfde id 3 | afwijkend 0
playlists oud 1 | nieuw 1 | zelfde id 1 | afwijkend 0

Geen dubbele. Zip en ophaalknop zijn daarmee inwisselbaar geworden, en dat was
eerder de combinatie die stukging.

Changed files:
src/services/ArchiveImportService.js

  • posts houden hun id, ongeacht de origin
  • de waarschuwing zegt nu wat er echt verandert: het AP-adres, niet het id

src/services/MigrationService.js

  • posts, tracks en playlists nemen het id van de bron over
  • "staat hij hier al" is daarmee een blik in de tabel in plaats van een omweg via ap_migration; verwijderen en opnieuw ophalen werkt vanzelf
  • de eerderPl/eerder-omwegen konden weg

test/archive-import.test.js

  • de origin-test omgedraaid: het AP-adres verandert, het id blijft

test/fep1580-migration.test.js

  • de shortcode-test toetst nu de UITKOMST (wijst naar een bestaand nummer) in plaats van de route ernaartoe
  • nieuwe test voor het botsingsgeval, waar het bijtrekken wel nodig is

remarks: het bijtrekken van [[track:]] blijft bestaan als vangnet voor een
botsend id. In het normale geval doet het niets meer, en dat is de bedoeling.
Suite 991 groen.

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

  • Property mode set to 100644
File size: 18.0 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 geeft een nieuw AP-adres, maar het interne id BLIJFT', () => {
129 // Omgedraaid op 14-8 (Robin: "altijd behouden"). Het interne id is niet
130 // hetzelfde als de AP-URI: https://nieuw/ap/notes/<id> is een ander adres dan
131 // https://oud/ap/notes/<id>, dus je claimt niets van een ander door het GUID
132 // te hergebruiken. Wat je wint is dat elke INTERNE verwijzing blijft kloppen.
133 //
134 // Het oude gedrag brak precies dat: een post uit de zip hield
135 // [[track:<oud id>]] in zijn tekst terwijl het nummer een nieuw id kreeg, en
136 // dan zie je de shorthand als kale tekst in je bericht staan.
137 leeg();
138 const files = new Map(ARCHIEF.files);
139 const m = JSON.parse(files.get('manifest.json').toString());
140 m.origin = 'https://ergens-anders.test';
141 files.set('manifest.json', Buffer.from(JSON.stringify(m)));
142 const r = AI.importArchive(files, { slug: 'me' });
143 assert.equal(r.idsBehouden, false, 'de AP-URI verandert wel, en dat blijft gemeld');
144 assert.match(r.waarschuwingen.join(' '), /origin verschilt/);
145 assert.equal(db.prepare("SELECT COUNT(*) AS n FROM posts WHERE id = 'zwaar'").get().n, 1,
146 'het interne id komt ongewijzigd terug');
147 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 1);
148});
149
150test('ontbrekende media worden geteld en gemeld', () => {
151 leeg();
152 const files = new Map(ARCHIEF.files);
153 const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
154 const o = JSON.parse(files.get(pad).toString());
155 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' }];
156 files.set(pad, Buffer.from(JSON.stringify(o)));
157 const r = AI.importArchive(files, { slug: 'me' });
158 assert.equal(r.mediaMissing, 1);
159 assert.equal(r.gemist[0].url, 'https://oud.test/media/weg.jpg');
160});
161
162test('een archief dat zijn eigen bestand mist wordt apart gemeld', () => {
163 // 'included' met een ontbrekend bestand is een KAPOT archief, geen verdwenen
164 // media. Die twee op een hoop gooien verbergt een echt probleem.
165 leeg();
166 const files = new Map(ARCHIEF.files);
167 for (const k of [...files.keys()]) if (k.startsWith('media/')) files.delete(k);
168 const r = AI.importArchive(files, { slug: 'me' });
169 assert.equal(r.mediaMissing, 0);
170 assert.match(r.waarschuwingen.join(' '), /dat er niet in zit/);
171});
172
173test('een bestand met een verkeerde checksum wordt niet weggeschreven', () => {
174 leeg();
175 const files = new Map(ARCHIEF.files);
176 const k = [...files.keys()].find((x) => x.startsWith('media/'));
177 files.set(k, Buffer.from('iets heel anders'));
178 const r = AI.importArchive(files, { slug: 'me' });
179 assert.match(r.waarschuwingen.join(' '), /checksum klopt niet/);
180 assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
181});
182
183test('een antwoordenbundel zonder archiefmarkering wordt overgeslagen', () => {
184 leeg();
185 const files = new Map(ARCHIEF.files);
186 const pad = [...files.keys()].find((k) => k.startsWith('replies/'));
187 const c = JSON.parse(files.get(pad).toString());
188 delete c['shaer:archive'];
189 files.set(pad, Buffer.from(JSON.stringify(c)));
190 const r = AI.importArchive(files, { slug: 'me' });
191 assert.equal(r.replies, 0);
192 assert.match(r.waarschuwingen.join(' '), /niet gemarkeerd als archief/);
193});
194
195test('een pad buiten de media-map wordt niet geschreven', () => {
196 leeg();
197 const files = new Map(ARCHIEF.files);
198 const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
199 const o = JSON.parse(files.get(pad).toString());
200 const mk = [...files.keys()].find((k) => k.startsWith('media/'));
201 o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: mk, 'shaer:availability': 'included', 'shaer:originalUrl': '/media/../../../tmp/klonkt-inbraak' }];
202 files.set(pad, Buffer.from(JSON.stringify(o)));
203 AI.importArchive(files, { slug: 'me' });
204 assert.equal(fs.existsSync('/tmp/klonkt-inbraak'), false);
205});
206
207test('readable/ wordt nooit gelezen', () => {
208 // Dat is de hele reden dat het afgeleid is: twee bronnen die allebei gelezen
209 // worden gaan uiteenlopen.
210 leeg();
211 const files = new Map(ARCHIEF.files);
212 for (const k of [...files.keys()]) if (k.startsWith('readable/')) files.set(k, Buffer.from('ONZIN'));
213 const r = AI.importArchive(files, { slug: 'me' });
214 assert.equal(r.posts, 1);
215 assert.match(db.prepare("SELECT content FROM posts WHERE id = 'zwaar'").get().content, /<p>body<\/p>/);
216});
217
218test('de zip-lezer haalt hetzelfde eruit als de map-lezer', () => {
219 const zip = path.join(MEDIA, 'rond.zip');
220 fs.writeFileSync(zip, AX.zipArchive(ARCHIEF.files));
221 const uit = AI.readArchive(zip);
222 assert.deepEqual([...uit.keys()].sort(), [...ARCHIEF.files.keys()].sort());
223 for (const k of uit.keys()) assert.deepEqual(uit.get(k), ARCHIEF.files.get(k), `${k} verschilt`);
224});
225
226test('een GECOMPRIMEERDE zip van elders wordt ook gelezen', async () => {
227 // Onze eigen zip is store-only. Zou de lezer alleen dat aankunnen, dan is het
228 // geen uitwisselformaat maar een privé-doosje.
229 const zlib = await import('zlib');
230 const inhoud = Buffer.from('{"formatVersion":1,"origin":"x"}');
231 const naam = Buffer.from('manifest.json', 'utf8');
232 const comp = zlib.deflateRawSync(inhoud);
233 const crc = (() => {
234 let c = -1;
235 for (const b of inhoud) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xEDB88320 : c >>> 1; }
236 return (c ^ -1) >>> 0;
237 })();
238 const lh = Buffer.alloc(30);
239 lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(8, 8);
240 lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(inhoud.length, 22);
241 lh.writeUInt16LE(naam.length, 26);
242 const ch = Buffer.alloc(46);
243 ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(8, 10);
244 ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(inhoud.length, 24);
245 ch.writeUInt16LE(naam.length, 28); ch.writeUInt32LE(0, 42);
246 const eocd = Buffer.alloc(22);
247 eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
248 eocd.writeUInt32LE(46 + naam.length, 12); eocd.writeUInt32LE(30 + naam.length + comp.length, 16);
249 const zip = path.join(MEDIA, 'deflate.zip');
250 fs.writeFileSync(zip, Buffer.concat([lh, naam, comp, ch, naam, eocd]));
251 assert.deepEqual(AI.readArchiveZip(fs.readFileSync(zip)).get('manifest.json'), inhoud);
252});
253
254test('een gehoste track overleeft de rondgang, met zijn bestand erbij', () => {
255 leeg();
256 db.prepare('DELETE FROM audio_tracks').run();
257 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
258 const WAV = Buffer.from('nep-audio-bytes');
259 fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
260 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
261 .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
262 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license)
263 VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY')`).run();
264 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
265 VALUES ('audio','s1','met-audio','u1','Met audio','<p>luister</p>[[track:tr1]]','published','2026-08-03 10:00:00')`).run();
266
267 const arch = AX.buildArchive('me', { exportedAt: 'X' });
268 db.prepare('DELETE FROM posts').run();
269 db.prepare('DELETE FROM audio_tracks').run();
270 db.prepare('DELETE FROM media').run();
271 fs.rmSync(path.join(MEDIA, 'audio'), { recursive: true, force: true });
272
273 AI.importArchive(arch.files, { slug: 'me' });
274 const t = db.prepare("SELECT * FROM audio_tracks WHERE id = 'tr1'").get();
275 assert.ok(t, '[[track:tr1]] in de content valt anders op niets terug');
276 assert.equal(t.title, 'Kanonnen');
277 assert.equal(t.license, 'CC BY');
278 const m = db.prepare('SELECT * FROM media WHERE id = ?').get(t.media_id);
279 assert.ok(m && fs.existsSync(m.storage_path), 'en het bestand staat er weer');
280 assert.deepEqual(fs.readFileSync(m.storage_path), WAV);
281});
282
283test('een vroegtijdig gesloten poll blijft gesloten', () => {
284 // Gevonden bij de rondgang op echte beta-data: poll_json.closed viel weg, dus
285 // een poll die je met een einddatum in de TOEKOMST had gesloten stond na een
286 // herstel weer open. AS2 kent hiervoor `closed` op een Question.
287 leeg();
288 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, poll_json)
289 VALUES ('dicht','s1','dichte-poll','u1','Dicht','<p>x</p>','published','2026-08-04 10:00:00',
290 '{"multiple":false,"options":[{"name":"a"},{"name":"b"}],"endTime":"2030-01-01T00:00:00Z","closed":true}')`).run();
291 const arch = AX.buildArchive('me', { exportedAt: 'X' });
292 assert.equal(JSON.parse(arch.files.get('posts/dicht.json').toString()).closed, true);
293 db.prepare('DELETE FROM posts').run();
294 AI.importArchive(arch.files, { slug: 'me' });
295 const d = JSON.parse(db.prepare("SELECT poll_json FROM posts WHERE id = 'dicht'").get().poll_json);
296 assert.equal(d.closed, true, 'anders staat een gesloten stemming na een herstel weer open');
297 assert.equal(d.endTime, '2030-01-01T00:00:00Z');
298});
299
300test('de cover en de speler komen terug, niet alleen hun bestanden', () => {
301 // Gevonden bij het oefenherstel, door ALLE kolommen te vergelijken in plaats
302 // van een handjevol: de bytes zaten in het archief, maar nergens stond dat ze
303 // de cover waren. De post kwam zonder cover en zonder speler terug.
304 leeg();
305 fs.mkdirSync(path.join(MEDIA, 'c'), { recursive: true });
306 for (const [n, b] of [['cov.jpg', 'cover'], ['op.m4a', 'audio'], ['op.png', 'poster']]) {
307 fs.writeFileSync(path.join(MEDIA, 'c', n), Buffer.from(b));
308 }
309 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, cover_image_url, cover_alt, c2s_attachments)
310 VALUES ('rijk','s1','rijke-post','u1','Rijk','<p>x</p>','published','2026-08-05 10:00:00',
311 '/media/c/cov.jpg','de cover', ?)`)
312 .run(JSON.stringify([{ url: '/media/c/op.m4a', mediaType: 'audio/mp4', name: 'opname.m4a', poster: '/media/c/op.png' }]));
313
314 const arch = AX.buildArchive('me', { exportedAt: 'X' });
315 const rollen = JSON.parse(arch.files.get('posts/rijk.json').toString()).attachment.map((a) => a['shaer:role']);
316 assert.deepEqual(rollen.sort(), ['c2s', 'cover', 'poster'], 'elke bijlage zegt waar hij voor was');
317
318 db.prepare('DELETE FROM posts').run();
319 fs.rmSync(path.join(MEDIA, 'c'), { recursive: true, force: true });
320 AI.importArchive(arch.files, { slug: 'me' });
321
322 const p = db.prepare("SELECT * FROM posts WHERE id = 'rijk'").get();
323 assert.equal(p.cover_image_url, '/media/c/cov.jpg', 'zonder dit staat de post zonder cover terug');
324 assert.equal(p.cover_alt, 'de cover');
325 const c2s = JSON.parse(p.c2s_attachments);
326 assert.equal(c2s[0].url, '/media/c/op.m4a');
327 assert.equal(c2s[0].poster, '/media/c/op.png', 'de poster hoort weer bij zijn opname');
328 assert.ok(fs.existsSync(path.join(MEDIA, 'c', 'cov.jpg')));
329});
330
331test('het MOMENT van een tijdstempel overleeft, ook uit SQL-notatie', () => {
332 // Klonkt schrijft in twee spellingen. Het archief normaliseert naar ISO; het
333 // moment blijft, de spelling niet. Dat is gedocumenteerd gedrag, geen verlies.
334 leeg();
335 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
336 VALUES ('tijd','s1','tijd','u1','T','<p>x</p>','published','2026-07-01 12:56:10')`).run();
337 const arch = AX.buildArchive('me', { exportedAt: 'X' });
338 db.prepare('DELETE FROM posts').run();
339 AI.importArchive(arch.files, { slug: 'me' });
340 const p = db.prepare("SELECT published_at FROM posts WHERE id = 'tijd'").get();
341 assert.equal(Date.parse(p.published_at), Date.parse('2026-07-01T12:56:10Z'));
342});
343
344test('zonder manifest is het geen archief', () => {
345 const files = new Map(ARCHIEF.files);
346 files.delete('manifest.json');
347 assert.throws(() => AI.importArchive(files, { slug: 'me' }), /geen manifest/);
348});
349
350test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.