// De exporter (shaer-1a6), getoetst aan het formaat in docs/EXPORT-FORMAT.md. // // De acceptatietest uit dat document is de MOEILIJKSTE post die Klonkt kan // maken, niet de makkelijkste: een poll met bijlagen, een quote-kaart, een // content warning en betaalde toegang. Die staat hieronder als eerste. // // 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'; import crypto from 'crypto'; process.env.DATABASE_PATH = ':memory:'; process.env.PUBLIC_BASE_URL = 'https://klonkt.test'; // De media-map moet bestaan voordat paths.js hem uitleest. const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-media-')); 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'); 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('dit-zijn-de-bytes-van-een-plaatje'); fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE); const PLAATJE_HASH = crypto.createHash('sha256').update(PLAATJE).digest('hex'); function post(id, extra = {}) { const kol = { id, site_id: 's1', slug: id, author_id: 'u1', title: 'Titel', content: '
inhoud
', status: 'published', published_at: '2026-08-01 10:00:00', created_at: '2026-08-01 10:00:00', updated_at: '2026-08-01 10:00:00', ...extra, }; const namen = Object.keys(kol); db.prepare(`INSERT INTO posts (${namen.join(',')}) VALUES (${namen.map(() => '?').join(',')})`) .run(...namen.map((n) => kol[n])); } const lees = (uit, pad) => JSON.parse(uit.files.get(pad).toString('utf8')); test('de moeilijkste post overleeft de export heel', () => { post('zwaar', { title: 'Een zware post', content: 'body
',
content_warning: 'spoiler', nsfw: 1, paid: 1, paid_min_cents: 500,
quote_uri: 'https://mstdn.social/users/x/statuses/1', quote_actor: 'https://mstdn.social/users/x',
poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja' }, { name: 'nee' }], endTime: '2026-08-09T10:00:00Z' }),
language: 'nl', tags: 'muziek, code',
});
const uit = AX.buildArchive('me');
const o = lees(uit, 'posts/zwaar.json');
assert.equal(o.type, 'Question', 'een poll exporteert als Question');
assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
assert.equal(o.endTime, '2026-08-09T10:00:00Z');
assert.equal(o.summary, 'spoiler', 'de content warning is de AS2 summary');
assert.equal(o.sensitive, true);
assert.equal(o.quoteUrl, 'https://mstdn.social/users/x/statuses/1');
assert.equal(o['shaer:quoteActor'], 'https://mstdn.social/users/x');
assert.equal(o['shaer:paidMinCents'], 500);
assert.deepEqual(o.contentMap, { nl: 'body
' });
assert.deepEqual(o.tag.map((t) => t.name), ['#muziek', '#code']);
});
test('een betaalde post exporteert de VOLLEDIGE inhoud, niet de teaser', () => {
// buildNote() federeert bewust alleen een teaser. Zou de export die projectie
// volgen, dan archiveer je je eigen betaalde post als samenvatting.
const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
assert.match(o.content, /body<\/p>/);
assert.equal(o['shaer:paid'], true);
});
test('de titel staat in name, niet in de content gebakken', () => {
// Ook een verschil met de federatie-projectie: Mastodon negeert `name`, dus
// daar gaat de titel de content in. Een archief hoort dat niet over te nemen.
const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
assert.equal(o.name, 'Een zware post');
assert.equal(o.type, 'Question');
assert.doesNotMatch(o.content, /Een zware post<\/strong>/);
});
test('media reizen mee als bytes, gesleuteld op hun inhoud', () => {
const uit = AX.buildArchive('me');
const o = lees(uit, 'posts/zwaar.json');
const a = o.attachment[0];
assert.equal(a['shaer:availability'], 'included');
assert.equal(a['shaer:sha256'], PLAATJE_HASH);
assert.equal(a.url, `media/${PLAATJE_HASH}.jpg`, 'de verwijzing is container-relatief');
assert.equal(a['shaer:originalUrl'], 'https://klonkt.test/media/2026/cover.jpg');
assert.deepEqual(uit.files.get(a.url), PLAATJE, 'en de bytes zitten er echt in');
});
test('een verdwenen bestand wordt gemeld, niet weggelaten', () => {
// De derde mediastaat. Een lege medialijst zou een leugen zijn en een
// verwijzing naar een ontbrekend bestand een kapot archief.
post('kwijt', { content: ' x x hoi luister x
' });
const uit = AX.buildArchive('me');
const a = lees(uit, 'posts/kwijt.json').attachment[0];
assert.equal(a['shaer:availability'], 'missing');
assert.equal(a.url, 'https://klonkt.test/media/2026/weg.jpg', 'de oorspronkelijke plek blijft staan');
assert.equal(uit.counts.mediaMissing, 1);
assert.equal(uit.missing[0].post, 'kwijt');
});
test('een pad buiten de media-map wordt niet gevolgd', () => {
// Zonder deze grens trekt een verzonnen pad in oude inhoud een willekeurig
// bestand van de schijf het archief in.
post('sluipweg', { content: '' });
const uit = AX.buildArchive('me');
const a = lees(uit, 'posts/sluipweg.json').attachment[0];
assert.equal(a['shaer:availability'], 'missing');
});
test('antwoorden van anderen staan apart en zijn gemarkeerd als archief', () => {
db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, created_at)
VALUES ('reply', 'zwaar', ?, ?, 'Anna', '