source: Klonkt/test/archive-export.test.js@ 6c4ff7e

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

Audio verhuisde niet mee: archiefformaat v2

Robin verhuisde naar soundfabrics.nl en meldde: de berichten zijn er, de
plaatjes zijn er, een deel van de audio staat er maar speelt niet af, en
daarnaast mist er veel. Drie losse oorzaken, alle drie echt.

Gemeten op de twee live instanties:

berichten 36 -> 36
tracks 140 -> 14, waarvan 13 stuk
playlists 11 -> 0

DE EXPORTER LAS storage_path, DE SPELER NIET. routes/audio.js resolvet met
AUDIO_DIR plus bestandsnaam en kijkt niet naar media.storage_path. Op
sound-fabrics.com wees storage_path voor 124 van de 139 audiorijen nog naar
/srv/prutfolio/storage/audio, van voor de dataverhuizing. De site speelde
gewoon door; de exporter vond niets, markeerde availability: missing, en liet
124 nummers stil achter. Vanuit de exporter gezien was het bestand er echt niet,
dus er kwam ook geen klacht. Nu is er een resolveAudioPath die zoekt zoals de
speler zoekt.

DE IMPORTER MAAKTE EEN TRACK ZONDER BESTAND. Bij gewone bijlagen werd
availability: missing netjes gecontroleerd, in de audio-tak eronder niet. Die
zette een media-rij met size 0 en een audio_tracks-rij neer voor bytes die nooit
zijn weggeschreven. Dat is erger dan ontbreken: het ziet eruit alsof de
verhuizing gelukt is, dus je zegt je oude instantie op. Nu geldt er een harde
regel: geen bestand, geen track.

AUDIO PASTE NIET IN HET FORMAAT. Gehoste audio staat buiten MEDIA_ROOT, achter
een eigen gated route, en het archief droeg alleen bestanden onder media/. De
exporter rekende er met path.relative een /media/../audio/x.mp3 van, en
veiligMediaPad weigert dat pad terecht. Audio kon dus principieel niet
aankomen. Daarbovenop ging alleen mee wat met [[track:]] in een bericht stond,
14 van de 140, en playlists helemaal niet.

De ophaalknop leerde audio erbij. Dat vroeg meer dan verwacht: de bron toont
over AP alleen fedi_open-nummers, en de bestanden zitten achter dezelfde poort.
Beide staan nu open voor precies de doel-actor van de Move, en de ingest haalt
de bytes ONDERTEKEND op. De eerste poging gaf drie keer "nummer niet opgehaald",
want haalBijlage deed een kale fetch en kreeg een 403 van onze eigen poort.

Bewezen op twee draaiende instanties, met een track die opzettelijk een
verouderd pad had. Zip: 3/3 included, playlist-volgorde intact, bestanden op
schijf met de goede grootte, /audio/stream geeft 200. Ophaalknop: 5 berichten
plus 3 tracks met echte bytes, ook de gesloten nummers.

Changed files:
src/config/paths.js

  • AUDIO_ROOT en resolveAudioPath(): een audiobestand zoeken zoals de speler het zoekt, met het opgeslagen pad als terugval

src/services/ArchiveExportService.js

  • FORMAT_VERSION 2
  • audioBibliotheek(): de HELE tracklijst plus de playlists, met de bestanden in een eigen audio/-gebied
  • [[track:]] weg uit de media-tak; die kon nooit aankomen
  • audioOf() wijst naar het archiefpad in plaats van naar een bijlage

src/services/ArchiveImportService.js

  • tracksTerug() en playlistsTerug(); de per-post audio-tak is weg
  • bestanden naar AUDIO_ROOT, en geen rij zonder bestand
  • een playlist verwijst nooit naar een nummer dat niet aankwam
  • nieuwe rapportvelden tracks / tracksMissing / playlists

src/services/MigrationService.js

  • de ingest loopt de tracks-collectie af en haalt de bytes ondertekend op
  • haalBijlage kreeg submap en headers

src/services/ActivityPubService.js

  • signedGetHeaders() losgetrokken uit signedGetJson, want een verhuizing moet ook bytes kunnen ophalen

src/services/music/index.js

  • siteOpenTracks({ alles }): de doel-actor ziet ook de gesloten nummers

src/routes/audio.js

  • isMoveTargetAudio(): een ondertekend verzoek van de doel-actor mag alle audio ophalen, ook wat niet fedi_open is

src/routes/activitypub.js

  • magAlles(): een plek voor de vraag "mag deze aanvrager alles zien"

src/routes/admin-migrate.js

  • AUDIO_ROOT en signedGetHeaders doorgegeven aan de ingest

test/archive-export.test.js

  • de audio-testen naar het v2-contract, plus bibliotheek en ontbrekend
  • resolveAudioPath getoetst op VOLGORDE met een nep-fs

New file:
test/archive-audio.test.js

  • de hele rondgang met audio, inclusief het verouderde pad, de playlist- volgorde en de droogloop

remarks: soundfabrics.nl staat nu met 13 lege tracks; die verdwijnen niet
vanzelf bij een nieuwe import. En een archief van voor deze commit is v1 en
bevat de audio niet, dus opnieuw exporteren is nodig. Suite 985 groen;
tegenproef gedaan op de resolver en op de geen-bestand-geen-track-regel.

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

  • Property mode set to 100644
File size: 15.9 KB
Line 
1// De exporter (shaer-1a6), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
2//
3// De acceptatietest uit dat document is de MOEILIJKSTE post die Klonkt kan
4// maken, niet de makkelijkste: een poll met bijlagen, een quote-kaart, een
5// content warning en betaalde toegang. Die staat hieronder als eerste.
6//
7// Run: npm test
8
9import { test } from 'node:test';
10import assert from 'node:assert/strict';
11import fs from 'fs';
12import os from 'os';
13import path from 'path';
14import crypto from 'crypto';
15
16process.env.DATABASE_PATH = ':memory:';
17process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
18
19// De media-map moet bestaan voordat paths.js hem uitleest.
20const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-media-'));
21process.env.MEDIA_PATH = MEDIA;
22
23const dbMod = await import('../src/config/database.js');
24const db = dbMod.default;
25dbMod.initializeDatabase();
26const AX = await import('../src/services/ArchiveExportService.js');
27
28db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
29 .run('u1', 'u1', 'u1@test', 'x', 'god');
30db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
31
32fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
33const PLAATJE = Buffer.from('dit-zijn-de-bytes-van-een-plaatje');
34fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
35const PLAATJE_HASH = crypto.createHash('sha256').update(PLAATJE).digest('hex');
36
37function post(id, extra = {}) {
38 const kol = {
39 id, site_id: 's1', slug: id, author_id: 'u1', title: 'Titel', content: '<p>inhoud</p>',
40 status: 'published', published_at: '2026-08-01 10:00:00', created_at: '2026-08-01 10:00:00',
41 updated_at: '2026-08-01 10:00:00', ...extra,
42 };
43 const namen = Object.keys(kol);
44 db.prepare(`INSERT INTO posts (${namen.join(',')}) VALUES (${namen.map(() => '?').join(',')})`)
45 .run(...namen.map((n) => kol[n]));
46}
47
48const lees = (uit, pad) => JSON.parse(uit.files.get(pad).toString('utf8'));
49
50test('de moeilijkste post overleeft de export heel', () => {
51 post('zwaar', {
52 title: 'Een zware post',
53 content: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">',
54 content_warning: 'spoiler', nsfw: 1, paid: 1, paid_min_cents: 500,
55 quote_uri: 'https://mstdn.social/users/x/statuses/1', quote_actor: 'https://mstdn.social/users/x',
56 poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja' }, { name: 'nee' }], endTime: '2026-08-09T10:00:00Z' }),
57 language: 'nl', tags: 'muziek, code',
58 });
59 const uit = AX.buildArchive('me');
60 const o = lees(uit, 'posts/zwaar.json');
61
62 assert.equal(o.type, 'Question', 'een poll exporteert als Question');
63 assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
64 assert.equal(o.endTime, '2026-08-09T10:00:00Z');
65 assert.equal(o.summary, 'spoiler', 'de content warning is de AS2 summary');
66 assert.equal(o.sensitive, true);
67 assert.equal(o.quoteUrl, 'https://mstdn.social/users/x/statuses/1');
68 assert.equal(o['shaer:quoteActor'], 'https://mstdn.social/users/x');
69 assert.equal(o['shaer:paidMinCents'], 500);
70 assert.deepEqual(o.contentMap, { nl: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">' });
71 assert.deepEqual(o.tag.map((t) => t.name), ['#muziek', '#code']);
72});
73
74test('een betaalde post exporteert de VOLLEDIGE inhoud, niet de teaser', () => {
75 // buildNote() federeert bewust alleen een teaser. Zou de export die projectie
76 // volgen, dan archiveer je je eigen betaalde post als samenvatting.
77 const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
78 assert.match(o.content, /<p>body<\/p>/);
79 assert.equal(o['shaer:paid'], true);
80});
81
82test('de titel staat in name, niet in de content gebakken', () => {
83 // Ook een verschil met de federatie-projectie: Mastodon negeert `name`, dus
84 // daar gaat de titel de content in. Een archief hoort dat niet over te nemen.
85 const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
86 assert.equal(o.name, 'Een zware post');
87 assert.equal(o.type, 'Question');
88 assert.doesNotMatch(o.content, /<strong>Een zware post<\/strong>/);
89});
90
91test('media reizen mee als bytes, gesleuteld op hun inhoud', () => {
92 const uit = AX.buildArchive('me');
93 const o = lees(uit, 'posts/zwaar.json');
94 const a = o.attachment[0];
95 assert.equal(a['shaer:availability'], 'included');
96 assert.equal(a['shaer:sha256'], PLAATJE_HASH);
97 assert.equal(a.url, `media/${PLAATJE_HASH}.jpg`, 'de verwijzing is container-relatief');
98 assert.equal(a['shaer:originalUrl'], 'https://klonkt.test/media/2026/cover.jpg');
99 assert.deepEqual(uit.files.get(a.url), PLAATJE, 'en de bytes zitten er echt in');
100});
101
102test('een verdwenen bestand wordt gemeld, niet weggelaten', () => {
103 // De derde mediastaat. Een lege medialijst zou een leugen zijn en een
104 // verwijzing naar een ontbrekend bestand een kapot archief.
105 post('kwijt', { content: '<p>x</p><img src="/media/2026/weg.jpg">' });
106 const uit = AX.buildArchive('me');
107 const a = lees(uit, 'posts/kwijt.json').attachment[0];
108 assert.equal(a['shaer:availability'], 'missing');
109 assert.equal(a.url, 'https://klonkt.test/media/2026/weg.jpg', 'de oorspronkelijke plek blijft staan');
110 assert.equal(uit.counts.mediaMissing, 1);
111 assert.equal(uit.missing[0].post, 'kwijt');
112});
113
114test('een pad buiten de media-map wordt niet gevolgd', () => {
115 // Zonder deze grens trekt een verzonnen pad in oude inhoud een willekeurig
116 // bestand van de schijf het archief in.
117 post('sluipweg', { content: '<p>x</p><img src="/media/../../../etc/passwd">' });
118 const uit = AX.buildArchive('me');
119 const a = lees(uit, 'posts/sluipweg.json').attachment[0];
120 assert.equal(a['shaer:availability'], 'missing');
121});
122
123test('antwoorden van anderen staan apart en zijn gemarkeerd als archief', () => {
124 db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, created_at)
125 VALUES ('reply', 'zwaar', ?, ?, 'Anna', '<p>hoi</p>', '2026-08-02 10:00:00')`)
126 .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
127 const uit = AX.buildArchive('me');
128 const c = lees(uit, 'replies/zwaar.json');
129 assert.equal(c['shaer:archive'], true, 'hierop keert een importer: nooit opnieuw bezorgen');
130 assert.equal(c.totalItems, 1);
131 assert.equal(c.orderedItems[0].id, 'https://mstdn.social/users/anna/statuses/9');
132 assert.equal(uit.counts.replies, 1);
133});
134
135test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => {
136 // Sinds v2 gaat audio NIET meer als media-bijlage mee. Dat kon ook nooit:
137 // gehoste audio staat buiten MEDIA_ROOT, de exporter rekende er met
138 // path.relative een /media/../audio/x.mp3 van, en de importer weigerde dat
139 // terecht. Resultaat op soundfabrics.nl: 13 tracks in de lijst, nul bestanden.
140 // Nu heeft het archief een eigen audio/-gebied.
141 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
142 const WAV = Buffer.from('RIFF-nep-audio');
143 fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
144 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
145 .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
146 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license, link_spotify)
147 VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY','https://open.spotify.com/track/x')`).run();
148 post('metaudio', { content: '<p>luister</p>[[track:tr1]]' });
149
150 const uit = AX.buildArchive('me');
151 const o = lees(uit, 'posts/metaudio.json');
152 const t = o['shaer:audio'][0];
153 assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan');
154 assert.equal(t.name, 'Kanonnen');
155 assert.equal(t.artist, 'Robin');
156 assert.equal(t.license, 'CC BY');
157 assert.deepEqual(t.url, ['https://open.spotify.com/track/x']);
158
159 // Dit is de assertie die er echt toe doet: de BYTES zitten in het archief.
160 assert.ok(String(t['shaer:media']).startsWith('audio/'), 'de track wijst naar het audio-gebied');
161 assert.deepEqual(uit.files.get(t['shaer:media']), WAV, 'en daar staat het geluid ook echt');
162
163 // En hij staat in de bibliotheek, los van de post.
164 const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8'));
165 const rij = lib.orderedItems.find((x) => x.id === 'tr1');
166 assert.equal(rij['shaer:availability'], 'included');
167 assert.equal(rij['shaer:file'], t['shaer:media']);
168 assert.equal(uit.counts.tracks, 1);
169});
170
171test('de HELE bibliotheek gaat mee, ook wat in geen enkel bericht staat', () => {
172 // De reden dat dit bestaat: op sound-fabrics.com stonden 140 tracks en gingen
173 // er 14 mee, want alleen wat met [[track:]] in een bericht stond werd
174 // geexporteerd. Een verhuizing die je bibliotheek achterlaat is er geen.
175 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
176 const B = Buffer.from('RIFF-los-nummer');
177 fs.writeFileSync(path.join(MEDIA, 'audio', 'los.mp3'), B);
178 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
179 .run('m9', 's1', 'los.mp3', 'audio/mpeg', B.length, path.join(MEDIA, 'audio', 'los.mp3'));
180 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('los','s1','Los nummer','m9')").run();
181
182 const uit = AX.buildArchive('me');
183 const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8'));
184 const rij = lib.orderedItems.find((x) => x.id === 'los');
185 assert.ok(rij, 'een track zonder bericht hoort er gewoon in');
186 assert.deepEqual(uit.files.get(rij['shaer:file']), B);
187});
188
189test('een track waarvan het bestand zoek is wordt gemeld, niet stil weggelaten', () => {
190 db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('mz','s1','zoek.mp3','audio/mpeg',1,'/bestaat/niet/zoek.mp3')").run();
191 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('zoek','s1','Zoek','mz')").run();
192 const uit = AX.buildArchive('me');
193 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'zoek');
194 assert.equal(rij['shaer:availability'], 'missing');
195 assert.equal(rij['shaer:file'], undefined, 'geen verwijzing naar een bestand dat er niet is');
196 assert.ok(uit.counts.audioMissing >= 1);
197 assert.ok(uit.missing.some((m) => m.track === 'Zoek'), 'en de beller kan het aan de gebruiker melden');
198});
199
200test('audio wordt gezocht zoals de SPELER hem zoekt, niet zoals de database hem onthoudt', async () => {
201 // De stille moordenaar op sound-fabrics.com: na een dataverhuizing wees
202 // storage_path voor 124 van de 139 tracks nog naar /srv/prutfolio/storage/audio.
203 // De speler merkte er niets van, want die pakt AUDIO_ROOT + bestandsnaam en
204 // negeert storage_path. De exporter las wel storage_path, vond niets, en liet
205 // 124 nummers weg zonder dat iemand het zag.
206 //
207 // Deze test pint de VOLGORDE vast met een nep-fs, want dat is precies waar de
208 // twee uit elkaar liepen. AUDIO_ROOT ligt bij import van paths.js al vast, dus
209 // hem hier omzetten kan niet meer; de volgorde toetsen wel.
210 const { resolveAudioPath, AUDIO_ROOT } = await import('../src/config/paths.js');
211 const gevraagd = [];
212 const nepFs = {
213 statSync(p) {
214 gevraagd.push(p);
215 if (p === path.join(AUDIO_ROOT, 'x.mp3')) return { isFile: () => true };
216 throw new Error('ENOENT');
217 },
218 };
219 const uit = resolveAudioPath('/een/heel/oud/pad/x.mp3', nepFs);
220 assert.equal(uit, path.join(AUDIO_ROOT, 'x.mp3'), 'de speler-plek wint');
221 assert.equal(gevraagd[0], path.join(AUDIO_ROOT, 'x.mp3'), 'en wordt als EERSTE geprobeerd');
222
223 // Staat hij daar niet, dan telt het opgeslagen pad alsnog: installaties die
224 // hun audio ergens anders hebben mogen niet stuk.
225 const gevraagd2 = [];
226 const nepFs2 = {
227 statSync(p) {
228 gevraagd2.push(p);
229 if (p === path.resolve('/ergens/anders/y.mp3')) return { isFile: () => true };
230 throw new Error('ENOENT');
231 },
232 };
233 assert.equal(resolveAudioPath('/ergens/anders/y.mp3', nepFs2), path.resolve('/ergens/anders/y.mp3'));
234 assert.equal(resolveAudioPath('', nepFs2), null);
235});
236
237test('de poster van een audio-bijlage gaat mee', () => {
238 // Gevonden bij de export van echte beta-data: c2s_attachments draagt naast url
239 // ook een poster, en die viel er stil uit.
240 fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a'), Buffer.from('audio'));
241 fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a.poster.webp'), Buffer.from('poster'));
242 post('metposter', {
243 c2s_attachments: JSON.stringify([{
244 url: '/media/2026/opname.m4a', mediaType: 'audio/mp4', name: 'opname.m4a',
245 poster: '/media/2026/opname.m4a.poster.webp',
246 }]),
247 });
248 const o = lees(AX.buildArchive('me'), 'posts/metposter.json');
249 assert.equal(o.attachment.length, 2, 'de opname en de poster');
250 assert.ok(o.attachment.every((a) => a['shaer:availability'] === 'included'));
251});
252
253test('een SQL-tijdstempel wordt als UTC gelezen, niet als lokale tijd', () => {
254 // SQLite schrijft CURRENT_TIMESTAMP in UTC zonder zone erbij. Date.parse leest
255 // die vorm als LOKALE tijd, en dan schuift elk tijdstempel in het archief mee
256 // met de machine die de export draait -- in Amsterdam twee uur.
257 //
258 // Deze test is alleen zinvol onder een NIET-UTC tijdzone; draai hem daarom ook
259 // eens als: TZ=Europe/Amsterdam node --test test/archive-export.test.js
260 post('tijdstip', { published_at: '2026-07-01 12:56:10', created_at: '2026-07-01 12:56:10' });
261 const o = lees(AX.buildArchive('me'), 'posts/tijdstip.json');
262 assert.equal(o.published, '2026-07-01T12:56:10.000Z',
263 'het moment uit de database is UTC en hoort dat te blijven');
264});
265
266test('een concept gaat gewoon mee', () => {
267 post('concept', { status: 'draft', published_at: null });
268 const o = lees(AX.buildArchive('me'), 'posts/concept.json');
269 assert.equal(o['shaer:status'], 'draft');
270});
271
272test('twee exports van ongewijzigde inhoud zijn gelijk, op exportedAt na', () => {
273 const a = AX.buildArchive('me', { exportedAt: 'X' });
274 const b = AX.buildArchive('me', { exportedAt: 'Y' });
275 assert.deepEqual([...a.files.keys()].sort(), [...b.files.keys()].sort());
276 for (const pad of a.files.keys()) {
277 if (pad === 'manifest.json') continue;
278 assert.deepEqual(a.files.get(pad), b.files.get(pad), `${pad} verschilt`);
279 }
280 assert.deepEqual(a.manifest.files, b.manifest.files, 'de checksum-kaart hoort gelijk te zijn');
281});
282
283test('en de zip die eruit komt ook', () => {
284 const a = AX.buildArchive('me', { exportedAt: 'X' });
285 const b = AX.buildArchive('me', { exportedAt: 'X' });
286 assert.deepEqual(AX.zipArchive(a.files), AX.zipArchive(b.files), 'zonder vaste mtime en volgorde is dit toeval');
287});
288
289test('de zip is door unzip te lezen', async () => {
290 // Een zelfgeschreven zip die alleen zichzelf kan lezen is geen uitwisselformaat.
291 const uit = AX.buildArchive('me');
292 const zip = path.join(MEDIA, 'test.zip');
293 fs.writeFileSync(zip, AX.zipArchive(uit.files));
294 const { execFileSync } = await import('child_process');
295 let lijst;
296 try { lijst = execFileSync('unzip', ['-l', zip], { encoding: 'utf8' }); }
297 catch (e) { if (e.code === 'ENOENT') return; throw e; } // geen unzip op deze machine
298 assert.match(lijst, /manifest\.json/);
299 assert.match(lijst, /posts\/zwaar\.json/);
300});
301
302test('het manifest draagt de origin, want daar hangt het behoud van AP-ids aan', () => {
303 const m = AX.buildArchive('me').manifest;
304 assert.equal(m.origin, 'https://klonkt.test');
305 assert.equal(m.formatVersion, 2, 'v2 = audio zit er echt in');
306 assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
307});
308
309test('posts van een ANDERE site komen er niet in', () => {
310 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
311 db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
312 .run('vanbuur', 's2', 'vanbuur', 'u1', 'Niet van mij', '<p>x</p>', 'published');
313 assert.equal(AX.buildArchive('me').files.has('posts/vanbuur.json'), false);
314});
315
316test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.