Index: test/outbox-diepte.test.js
===================================================================
--- test/outbox-diepte.test.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
+++ test/outbox-diepte.test.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
@@ -0,0 +1,88 @@
+// De outbox pagineert nu ECHT door (shaer-sk4).
+//
+// Er stond geen paginering maar een KAP: de route haalde twintig posts uit SQL
+// en hield daar twintig items van over. Alles daarvoor was niet op een volgende
+// pagina maar helemaal onbereikbaar.
+//
+// Posts en tracks vlechten op datum, dus een offset over die twee kan niet met
+// twee losse queries -- je weet niet hoeveel van elk er in pagina drie horen.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://ons.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'dev', 'Dev', 'u1');
+
+// 45 posts en 5 open tracks, met dagen ertussen zodat de volgorde vaststaat.
+const dag = (n) => new Date(Date.UTC(2026, 0, 1, 12) + (n - 1) * 86400000).toISOString();
+for (let i = 1; i <= 45; i++) {
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+              VALUES (?,?,?,?,?,?,'published',?)`)
+    .run(`p${i}`, 's1', 'u1', `post-${i}`, `Post ${i}`, '<p>x</p>', dag(i));
+}
+const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,1000)');
+const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, duration, media_id, fedi_open, created_at) VALUES (?,?,?,?,?,1,?)');
+for (let i = 1; i <= 5; i++) {
+  insM.run(`m${i}`, 's1', `t${i}.mp3`, `audio/t${i}.mp3`, 'audio/mpeg');
+  insT.run(`t${i}`, 's1', `Track ${i}`, 100, `m${i}`, dag(20 + i));
+}
+
+test('de telling is het GEHEEL, niet de pagina', () => {
+  const { totaal } = AP.outboxSlice('s1', { offset: 0, limit: 20 });
+  assert.equal(totaal, 50, '45 posts + 5 tracks');
+});
+
+test('een pagina haalt uit BEIDE bronnen, gevlochten op datum', () => {
+  // Pagina 2 valt precies over de tracks van dag 21..25 heen; met twee losse
+  // queries en een offset per tabel zou dat niet kloppen.
+  const p2 = AP.outboxSlice('s1', { offset: 20, limit: 20 });
+  assert.equal(p2.posts.length + p2.tracks.length, 20);
+  assert.ok(p2.tracks.length > 0, 'de tracks zitten midden in de reeks');
+  assert.ok(p2.posts.length > 0);
+});
+
+test('opeenvolgende paginas overlappen niet en laten niets vallen', () => {
+  const zie = new Set();
+  let som = 0;
+  for (let n = 1; n <= 3; n++) {
+    const s = AP.outboxSlice('s1', { offset: (n - 1) * 20, limit: 20 });
+    for (const p of s.posts) zie.add('p:' + p.id);
+    for (const t of s.tracks) zie.add('t:' + t.id);
+    som += s.posts.length + s.tracks.length;
+  }
+  assert.equal(som, 50, 'drie paginas dekken alles');
+  assert.equal(zie.size, 50, 'en niets twee keer');
+});
+
+test('de laatste pagina is korter en de pagina erna is leeg', () => {
+  assert.equal(AP.outboxSlice('s1', { offset: 40, limit: 20 }).posts.length
+             + AP.outboxSlice('s1', { offset: 40, limit: 20 }).tracks.length, 10);
+  const voorbij = AP.outboxSlice('s1', { offset: 60, limit: 20 });
+  assert.equal(voorbij.posts.length + voorbij.tracks.length, 0);
+});
+
+test('de collectie biedt een next zolang er meer is', () => {
+  const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+  const { posts, tracks, totaal } = AP.outboxSlice('s1', { offset: 0, limit: 20 });
+  const ob = AP.buildOutbox('https://ons.test', site, posts, tracks, { page: 1, totalItems: totaal, alGesneden: true });
+  assert.equal(ob.totalItems, 50);
+  assert.equal(ob.orderedItems.length, 20);
+  assert.equal(ob.next, 'https://ons.test/ap/users/dev/outbox?page=2',
+    'zonder alGesneden zou een volle pagina zichzelf als de enige zien');
+});
+
+test('een fans-only post blijft buiten de telling van een vreemde', () => {
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at, fan_only)
+              VALUES (?,?,?,?,?,?,'published',?,1)`)
+    .run('geheim', 's1', 'u1', 'geheim', 'Geheim', '<p>x</p>', dag(28));
+  assert.equal(AP.outboxSlice('s1', { fanOnly: false }).totaal, 50, 'een vreemde telt hem niet mee');
+  assert.equal(AP.outboxSlice('s1', { fanOnly: true }).totaal, 51, 'een vriend wel');
+});
