Index: test/archive-audio.test.js
===================================================================
--- test/archive-audio.test.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
+++ test/archive-audio.test.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -0,0 +1,190 @@
+// Audio overleeft een verhuizing. Formaat v2.
+//
+// Dit bestand bestaat door wat er op soundfabrics.nl gebeurde: 36 berichten
+// kwamen goed over, 14 van de 140 nummers stonden er, en 13 daarvan speelden
+// niet af. Drie oorzaken, alle drie hier vastgelegd:
+//
+//   1. Gehoste audio leeft BUITEN MEDIA_ROOT (eigen gated route). Het archief
+//      droeg alleen bestanden onder media/, dus de exporter rekende er een
+//      /media/../audio/x.mp3 van en de importer weigerde dat pad. Terecht: het
+//      wijst buiten de mediamap. Gevolg was wel dat audio nooit kon aankomen.
+//   2. De importer maakte een audio_tracks-rij aan ZONDER te kijken of het
+//      bestand er was. Een nummer dat in de lijst staat en 404't bij play is
+//      erger dan een nummer dat ontbreekt: het ziet eruit alsof het gelukt is,
+//      dus je gooit je oude instantie weg.
+//   3. Alleen audio die met [[track:]] in een bericht stond ging mee. Losse
+//      nummers en playlists bleven achter (126 respectievelijk 11 stuks).
+//
+// Een verhuizing die je bibliotheek achterlaat is geen verhuizing.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio-'));
+const MEDIA = path.join(TMP, 'media');
+const AUDIO = path.join(TMP, 'audio');
+fs.mkdirSync(MEDIA, { recursive: true });
+fs.mkdirSync(AUDIO, { recursive: true });
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://oud.test';
+process.env.MEDIA_PATH = MEDIA;
+process.env.AUDIO_PATH = AUDIO;      // MOET voor de import van paths.js staan
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+const AX = await import('../src/services/ArchiveExportService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+const { AUDIO_ROOT } = await import('../src/config/paths.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');
+
+const BYTES = (s) => Buffer.from(`RIFF${s}`.padEnd(64, '.'));
+
+/** Schone lei: tabellen leeg en de audiomap leeg. Tests lekken anders in elkaar. */
+function leeg() {
+  for (const t of ['playlist_tracks', 'playlists', 'audio_tracks', 'media']) {
+    try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet */ }
+  }
+  for (const f of fs.readdirSync(AUDIO)) { try { fs.unlinkSync(path.join(AUDIO, f)); } catch { /* niets */ } }
+}
+
+/** Een nummer met zijn bestand op de plek waar de SPELER het zoekt. */
+function track(id, titel, { padInDb = null, schrijf = true } = {}) {
+  const naam = `${id}.mp3`;
+  const echt = path.join(AUDIO, naam);
+  if (schrijf) fs.writeFileSync(echt, BYTES(id));
+  db.prepare('INSERT OR REPLACE INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run(`m-${id}`, 's1', naam, 'audio/mpeg', 1, padInDb || echt);
+  db.prepare('INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, media_id) VALUES (?,?,?,?,?)')
+    .run(id, 's1', titel, 'Robin', `m-${id}`);
+  return echt;
+}
+
+test('een nummer met een VEROUDERD pad in de database gaat gewoon mee', () => {
+  leeg();
+  // Dit is de stille moordenaar. Op sound-fabrics.com wees storage_path voor
+  // 124 van de 139 nummers nog naar /srv/prutfolio/storage/audio, van voor een
+  // dataverhuizing. De speler merkte niets (die zoekt op bestandsnaam in
+  // AUDIO_ROOT), de exporter vond niets en liet ze weg. Zonder klacht.
+  track('oudpad', 'Verouderd pad', { padInDb: '/srv/ergens/anders/oudpad.mp3' });
+  const uit = AX.buildArchive('me');
+  const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'oudpad');
+  assert.equal(rij['shaer:availability'], 'included',
+    'de exporter hoort te zoeken zoals de speler zoekt, niet zoals de database onthoudt');
+  assert.deepEqual(uit.files.get(rij['shaer:file']), BYTES('oudpad'));
+});
+
+test('de hele rondgang: exporteren, importeren, en het geluid staat er', () => {
+  leeg();
+  track('a', 'Nummer A');
+  track('b', 'Nummer B');
+  const uit = AX.buildArchive('me');
+  assert.equal(uit.counts.tracks, 2);
+
+  // Een LEGE tweede installatie, met zijn eigen mappen.
+  const TMP2 = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio2-'));
+  const doelAudio = path.join(TMP2, 'audio');
+  fs.mkdirSync(doelAudio, { recursive: true });
+
+  // AUDIO_ROOT ligt vast bij import, dus we importeren in DEZELFDE map en
+  // controleren daarna dat het bestand er staat onder de naam uit het archief.
+  leeg();
+  assert.equal(fs.readdirSync(AUDIO).length, 0, 'schone lei');
+
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(rapport.tracks, 2, 'beide nummers komen terug');
+  assert.equal(rapport.tracksMissing, 0);
+
+  const rijen = db.prepare('SELECT t.id, t.title, m.storage_path, m.size FROM audio_tracks t JOIN media m ON m.id = t.media_id ORDER BY t.id').all();
+  assert.equal(rijen.length, 2);
+  for (const r of rijen) {
+    assert.ok(fs.existsSync(r.storage_path), `${r.title}: het bestand hoort er ECHT te staan`);
+    assert.ok(r.size > 0, 'en de grootte klopt, geen 0 zoals bij de kapotte import');
+    assert.ok(r.storage_path.startsWith(path.resolve(AUDIO_ROOT) + path.sep),
+      'audio hoort in AUDIO_ROOT, niet onder de publieke mediamap');
+  }
+  fs.rmSync(TMP2, { recursive: true, force: true });
+});
+
+test('een nummer zonder bestand wordt NIET aangemaakt', () => {
+  // De kern van de klacht: 13 nummers die bestonden en niet speelden. Liever
+  // eerlijk weg dan zichtbaar kapot.
+  leeg();
+  track('zoek', 'Zoekgeraakt', { padInDb: '/bestaat/echt/niet.mp3', schrijf: false });
+  const uit = AX.buildArchive('me');
+  assert.equal(uit.counts.audioMissing, 1);
+
+  leeg();
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(rapport.tracks, 0);
+  assert.equal(rapport.tracksMissing, 1);
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0,
+    'geen rij zonder geluid: dat is precies de val die soundfabrics.nl opleverde');
+  assert.ok(rapport.waarschuwingen.some((w) => /Zoekgeraakt/.test(w)),
+    'en de gebruiker hoort te weten welk nummer er niet is');
+});
+
+test('playlists komen mee, met hun volgorde', () => {
+  leeg();
+
+  track('p1', 'Eerste');
+  track('p2', 'Tweede');
+  track('p3', 'Derde');
+  db.prepare("INSERT INTO playlists (id, site_id, title, artist, kind) VALUES ('pl1','s1','Mijn plaat','Robin','album')").run();
+  // Bewust NIET op alfabet: de volgorde is de hele playlist.
+  db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl1','p3',0),('pl1','p1',1),('pl1','p2',2)").run();
+
+  const uit = AX.buildArchive('me');
+  assert.equal(uit.counts.playlists, 1);
+
+  leeg();
+
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(rapport.playlists, 1);
+  const pl = db.prepare("SELECT * FROM playlists WHERE id = 'pl1'").get();
+  assert.equal(pl.title, 'Mijn plaat');
+  assert.equal(pl.kind, 'album');
+  const volgorde = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl1' ORDER BY position").all().map((r) => r.track_id);
+  assert.deepEqual(volgorde, ['p3', 'p1', 'p2'], 'de volgorde IS de playlist');
+});
+
+test('een playlist verwijst nooit naar een nummer dat niet aankwam', () => {
+  // Anders staat er een plaat met gaten erin die je niet kunt afspelen, en dat
+  // is weer dezelfde soort halve waarheid.
+  leeg();
+
+  track('heel', 'Heel');
+  track('stuk', 'Stuk', { padInDb: '/weg/stuk.mp3', schrijf: false });
+  db.prepare("INSERT INTO playlists (id, site_id, title) VALUES ('pl2','s1','Half')").run();
+  db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl2','heel',0),('pl2','stuk',1)").run();
+
+  const uit = AX.buildArchive('me');
+  leeg();
+
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  const items = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl2'").all().map((r) => r.track_id);
+  assert.deepEqual(items, ['heel']);
+  assert.ok(rapport.waarschuwingen.some((w) => /Half/.test(w)), 'en het wordt gemeld');
+});
+
+test('een droogloop telt de nummers maar schrijft niets', () => {
+  leeg();
+  track('d1', 'Droog');
+  const uit = AX.buildArchive('me');
+
+  leeg();
+
+  const droog = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test', dryRun: true });
+  assert.equal(droog.tracks, 1, 'het verslag hoort te zeggen hoeveel nummers er zouden komen');
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0, 'maar er staat niets');
+  assert.equal(fs.readdirSync(AUDIO).length, 0, 'en er is geen bestand geschreven');
+});
+
+test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
