Index: test/track-delete-federates.test.js
===================================================================
--- test/track-delete-federates.test.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
+++ test/track-delete-federates.test.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
@@ -0,0 +1,87 @@
+// Een track verwijderen hoort de fediverse te bereiken.
+//
+// Tot 21-8 gebeurde dat niet. Een post kondigde zijn verwijdering aan
+// (posts.js roept deliverDelete), maar een track niet: de rij ging weg, het
+// Audio-object gaf 404, en elke server die hem had geindexeerd bleef ernaar
+// wijzen. Op de hub kwam dat boven als "ongbakbeat (1)", een kaartje met een
+// dode link, en het viel alleen op omdat robo het zag staan.
+//
+// De test gaat door de ECHTE route en kijkt in de bezorgwachtrij, want een
+// eigen testhaak op deliver() bestaat hier niet. De volger krijgt een inbox op
+// een dichte poort: de directe poging faalt meteen, en dan hoort de activiteit
+// in ap_delivery te belanden. Dat is precies het bewijs dat we zoeken, want die
+// tabel was in het echte geval leeg.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'baas', 'b@t.nl', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+  .run('s1', 'robo', 'Soundfabrics', 'u1');
+// Een dichte poort: faalt onmiddellijk, geen DNS, geen wachttijd.
+db.prepare('INSERT INTO ap_followers (slug, actor_uri, inbox) VALUES (?,?,?)')
+  .run('robo', 'https://elders.test/ap/users/x', 'http://127.0.0.1:1/inbox');
+
+const TRACK = 'trk-481fef66';
+db.prepare('INSERT INTO audio_tracks (id, site_id, title) VALUES (?,?,?)')
+  .run(TRACK, 's1', 'ongbakbeat (1)');
+
+const express = (await import('express')).default;
+const router = (await import('../src/routes/admin-audio.js')).default;
+const app = express();
+app.use(express.urlencoded({ extended: true }));
+app.use((req, res, next) => {
+  req.session = { user: { id: 'u1', role: 'god' } };
+  res.locals.site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('robo');
+  res.locals.siteUrlBase = '';
+  next();
+});
+app.use('/admin/audio', router);
+
+const server = app.listen(0);
+server.unref();
+const poort = server.address().port;
+
+const wacht = (ms) => new Promise((r) => setTimeout(r, ms));
+
+test('een verwijderde track kondigt zichzelf af bij de volgers', async () => {
+  const r = await fetch(`http://127.0.0.1:${poort}/admin/audio/${TRACK}/delete`, {
+    method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(5000),
+  }).catch((e) => assert.fail(`de route antwoordde niet (${e.name})`));
+  assert.ok(r.status === 302 || r.status === 200, 'verwijderen mag niet stranden, kreeg ' + r.status);
+
+  assert.equal(
+    db.prepare('SELECT count(*) c FROM audio_tracks WHERE id = ?').get(TRACK).c, 0,
+    'de rij hoort weg te zijn',
+  );
+
+  // De bezorging is best-effort en async: even de mislukte poging laten landen.
+  for (let i = 0; i < 20 && !db.prepare('SELECT count(*) c FROM ap_delivery').get().c; i++) await wacht(100);
+
+  const rij = db.prepare('SELECT * FROM ap_delivery').get();
+  assert.ok(rij, 'er hoort een bezorging in de wachtrij te staan; leeg betekent dat er niets is verstuurd');
+
+  const act = JSON.parse(rij.body);
+  assert.equal(act.type, 'Delete');
+  assert.equal(act.object.type, 'Tombstone');
+  // De identiteit moet dezelfde zijn als die het Audio-object droeg, anders
+  // ruimt de ontvanger niets op: hij kent dat id niet.
+  assert.equal(act.object.id, `https://test.example/ap/users/robo/tracks/${TRACK}`);
+  assert.equal(act.actor, 'https://test.example/ap/users/robo');
+});
+
+test('het id in de Delete is exact het id waaronder de track de deur uit ging', async () => {
+  // De echte valkuil bij dit soort fixes: een tweede plek die het id opnieuw
+  // samenstelt en er net naast zit. Daarom bouwt trackUri() hem nu op een plek,
+  // en vergelijken we hier met wat de bouwkant zelf produceert.
+  const muziek = await import('../src/services/music/index.js');
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('robo');
+  const audio = muziek.buildTrackAudio('https://test.example', site, { id: 'abc-123', title: 'x' });
+  assert.equal(audio.id, muziek.trackUri('https://test.example', site, 'abc-123'));
+});
