// De importer (shaer-pmr), getoetst aan het formaat in docs/EXPORT-FORMAT.md. // // De zwaarste toets is de RONDGANG: exporteer de moeilijkste post die Klonkt kan // maken, gooi hem weg, lees hem terug, en kijk of er nog hetzelfde staat. Een // exporter en een importer die elk apart kloppen maar niet op elkaar aansluiten // zijn samen niets waard. // // Run: npm test import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; import path from 'path'; process.env.DATABASE_PATH = ':memory:'; process.env.PUBLIC_BASE_URL = 'https://klonkt.test'; const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-imp-')); process.env.MEDIA_PATH = MEDIA; const dbMod = await import('../src/config/database.js'); const db = dbMod.default; dbMod.initializeDatabase(); const AX = await import('../src/services/ArchiveExportService.js'); const AI = await import('../src/services/ArchiveImportService.js'); db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') .run('u1', 'u1', 'u1@test', 'x', 'god'); db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1'); fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true }); const PLAATJE = Buffer.from('bytes-van-de-cover'); fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE); db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, published_at, created_at, updated_at, content_warning, nsfw, paid, paid_min_cents, quote_uri, quote_actor, poll_json, language, tags, pinned, view_count) VALUES ('zwaar','s1','zware-post','u1','Een zware post', '
body
','de teaser','published',
'2026-08-01 10:00:00','2026-08-01 10:00:00','2026-08-01 12:00:00','spoiler',1,1,500,
'https://mstdn.social/users/x/statuses/1','https://mstdn.social/users/x',
'{"multiple":false,"options":[{"name":"ja"},{"name":"nee"}],"endTime":"2026-08-09T10:00:00Z"}',
'nl','muziek, code',1,42)`).run();
db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, created_at)
VALUES ('reply','zwaar',?,?,'Anna','@anna@mstdn.social','hoi
','2026-08-02T10:00:00Z','2026-08-02 10:00:00')`) .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna'); const ARCHIEF = AX.buildArchive('me', { exportedAt: 'X' }); const leeg = () => { db.prepare('DELETE FROM posts').run(); db.prepare('DELETE FROM ap_interactions').run(); try { fs.rmSync(path.join(MEDIA, '2026'), { recursive: true, force: true }); } catch { /* niets */ } }; test('de rondgang: wat eruit ging komt er hetzelfde weer in', () => { leeg(); const r = AI.importArchive(ARCHIEF.files, { slug: 'me' }); assert.equal(r.posts, 1); assert.equal(r.idsBehouden, true, 'zelfde origin, dus de AP-ids blijven'); const p = db.prepare('SELECT * FROM posts WHERE id = ?').get('zwaar'); assert.ok(p, 'het post-id is behouden -- daar wijzen de boosts elders naar'); assert.equal(p.title, 'Een zware post'); assert.equal(p.slug, 'zware-post'); assert.match(p.content, /body<\/p>/);
assert.equal(p.excerpt, 'de teaser');
assert.equal(p.content_warning, 'spoiler');
assert.equal(p.nsfw, 1);
assert.equal(p.paid, 1);
assert.equal(p.paid_min_cents, 500);
assert.equal(p.quote_uri, 'https://mstdn.social/users/x/statuses/1');
assert.equal(p.language, 'nl');
assert.equal(p.tags, 'muziek, code');
assert.equal(p.pinned, 1);
assert.equal(p.view_count, 42);
assert.deepEqual(JSON.parse(p.poll_json).options.map((o) => o.name), ['ja', 'nee']);
});
test('en de media staan terug op hun oude plek, want de content wijst daarheen', () => {
const op = path.join(MEDIA, '2026', 'cover.jpg');
assert.ok(fs.existsSync(op), 'anders is de body<\/p>/);
});
test('de zip-lezer haalt hetzelfde eruit als de map-lezer', () => {
const zip = path.join(MEDIA, 'rond.zip');
fs.writeFileSync(zip, AX.zipArchive(ARCHIEF.files));
const uit = AI.readArchive(zip);
assert.deepEqual([...uit.keys()].sort(), [...ARCHIEF.files.keys()].sort());
for (const k of uit.keys()) assert.deepEqual(uit.get(k), ARCHIEF.files.get(k), `${k} verschilt`);
});
test('een GECOMPRIMEERDE zip van elders wordt ook gelezen', async () => {
// Onze eigen zip is store-only. Zou de lezer alleen dat aankunnen, dan is het
// geen uitwisselformaat maar een privé-doosje.
const zlib = await import('zlib');
const inhoud = Buffer.from('{"formatVersion":1,"origin":"x"}');
const naam = Buffer.from('manifest.json', 'utf8');
const comp = zlib.deflateRawSync(inhoud);
const crc = (() => {
let c = -1;
for (const b of inhoud) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xEDB88320 : c >>> 1; }
return (c ^ -1) >>> 0;
})();
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(8, 8);
lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(inhoud.length, 22);
lh.writeUInt16LE(naam.length, 26);
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(8, 10);
ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(inhoud.length, 24);
ch.writeUInt16LE(naam.length, 28); ch.writeUInt32LE(0, 42);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(46 + naam.length, 12); eocd.writeUInt32LE(30 + naam.length + comp.length, 16);
const zip = path.join(MEDIA, 'deflate.zip');
fs.writeFileSync(zip, Buffer.concat([lh, naam, comp, ch, naam, eocd]));
assert.deepEqual(AI.readArchiveZip(fs.readFileSync(zip)).get('manifest.json'), inhoud);
});
test('een gehoste track overleeft de rondgang, met zijn bestand erbij', () => {
leeg();
db.prepare('DELETE FROM audio_tracks').run();
fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
const WAV = Buffer.from('nep-audio-bytes');
fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
.run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license)
VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY')`).run();
db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
VALUES ('audio','s1','met-audio','u1','Met audio',' luister x x x in de body een dode verwijzing');
assert.deepEqual(fs.readFileSync(op), PLAATJE);
});
test('de antwoorden komen terug als archief', () => {
const r = db.prepare("SELECT * FROM ap_interactions WHERE post_id = 'zwaar'").all();
assert.equal(r.length, 1);
assert.equal(r[0].actor_name, 'Anna');
assert.equal(r[0].object_uri, 'https://mstdn.social/users/anna/statuses/9');
});
test('twee keer importeren levert geen dubbele posts op', () => {
const voor = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
assert.equal(r.overgeslagen, 1, 'de bestaande post wordt overgeslagen, niet overschreven');
assert.equal(r.posts, 0);
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, voor);
});
test('overschrijven gebeurt alleen als je erom vraagt', () => {
db.prepare("UPDATE posts SET title = 'handmatig gewijzigd' WHERE id = 'zwaar'").run();
AI.importArchive(ARCHIEF.files, { slug: 'me' });
assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'handmatig gewijzigd',
'zonder --overwrite blijft wat er staat staan');
const r = AI.importArchive(ARCHIEF.files, { slug: 'me', overwrite: true });
assert.equal(r.overschreven, 1);
assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'Een zware post');
});
test('een droogloop schrijft niets', () => {
leeg();
const r = AI.importArchive(ARCHIEF.files, { slug: 'me', dryRun: true });
assert.equal(r.posts, 1, 'hij vertelt wel wat er zou gebeuren');
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0);
assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
});
test('een nieuwere formaatversie wordt in zijn GEHEEL geweigerd', () => {
// Niet half lezen: een half begrepen herstel ziet eruit alsof het gelukt is.
const files = new Map(ARCHIEF.files);
const m = JSON.parse(files.get('manifest.json').toString());
m.formatVersion = 99;
files.set('manifest.json', Buffer.from(JSON.stringify(m)));
assert.throws(() => AI.importArchive(files, { slug: 'me' }), /nieuwer dan deze Klonkt kent/);
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0, 'en er is niets geschreven');
});
test('een andere origin geeft een nieuw AP-adres, maar het interne id BLIJFT', () => {
// Omgedraaid op 14-8 (Robin: "altijd behouden"). Het interne id is niet
// hetzelfde als de AP-URI: https://nieuw/ap/notes/