Index: test/ap-track-context.test.js
===================================================================
--- test/ap-track-context.test.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
+++ test/ap-track-context.test.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
@@ -0,0 +1,104 @@
+// Een track wijst naar de post die hem uitbrengt (shaer-0nh).
+//
+// Aanleiding: sinds Create(Audio) in de outbox staat (fb22f78) bouwt Shaer zijn
+// HomeBase-feed uit diezelfde outbox, en maakte het van elke Audio een lege
+// kaart -- een Audio heeft geen `content` en onze `url` is een Link-array.
+//
+// De relatie stond alleen in posts.content ([[track:]], [[playlist:]],
+// [[album:]]) en nergens op de draad. Nu wel: AS2-kern `context` wijst naar de
+// Note van de post, en de text/html-link vooraan wijst naar de pagina. Een
+// lezer die de post al heeft kan het nummer daarmee overslaan in plaats van er
+// een dode kaart van te maken.
+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();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const BASE = 'https://test.example';
+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, is_public, is_primary) VALUES (?,?,?,?,1,1)')
+  .run('s1', 'band', 'De Band', 'u1');
+
+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, album, fedi_open) VALUES (?,?,?,?,?,?,1)');
+for (const [id, titel, album] of [['t-los', 'Losse track', null], ['t-pl', 'Via playlist', null],
+  ['t-alb', 'Via album', 'De Plaat'], ['t-wees', 'Nergens ingesloten', null]]) {
+  insM.run('m-' + id, 's1', id + '.mp3', 'audio/' + id + '.mp3', 'audio/mpeg');
+  insT.run(id, 's1', titel, 60, 'm-' + id, album);
+}
+db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('plaat','s1','De Plaat','album')").run();
+db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('plaat','t-pl',1)").run();
+
+const insP = db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+                         VALUES (?,?,?,?,?,?,'published',?)`);
+insP.run('p-los', 's1', 'u1', 'losse-post', 'Losse post', 'Hier: [[track:t-los]]', '2026-01-01T00:00:00Z');
+insP.run('p-pl', 's1', 'u1', 'playlist-post', 'Playlist-post', 'Nieuw album [[playlist:plaat]]', '2026-02-01T00:00:00Z');
+insP.run('p-alb', 's1', 'u1', 'album-post', 'Album-post', 'Luister [[album:De Plaat]]', '2026-03-01T00:00:00Z');
+
+const site = () => db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+const audioVoor = (id) => {
+  const r = AP.siteOpenTracks('s1').find((x) => x.id === id);
+  return AP.buildTrackAudio(BASE, site(), r);
+};
+
+test('rechtstreeks ingesloten: context wijst naar de Note van die post', () => {
+  const a = audioVoor('t-los');
+  assert.equal(a.context, 'https://test.example/ap/notes/p-los');
+  assert.deepEqual(a.url[0], { type: 'Link', href: 'https://test.example/losse-post', mediaType: 'text/html' },
+    'de post staat VOORAAN, zoals Funkwhale zijn trackpagina zet');
+  assert.equal(a.url[1].mediaType, 'audio/mpeg', 'het bestand komt daarna');
+});
+
+test('via een playlist gevonden', () => {
+  assert.equal(audioVoor('t-pl').context, 'https://test.example/ap/notes/p-pl');
+});
+
+test('via een albumnaam gevonden', () => {
+  assert.equal(audioVoor('t-alb').context, 'https://test.example/ap/notes/p-alb');
+});
+
+test('een track die nergens is ingesloten krijgt GEEN context', () => {
+  const a = audioVoor('t-wees');
+  assert.equal(a.context, undefined, 'niets verzinnen als er geen post is');
+  assert.equal(a.url.length, 1, 'dan ook geen text/html-link');
+  assert.equal(a.url[0].mediaType, 'audio/mpeg');
+});
+
+test('rechtstreeks wint van playlist wint van album', () => {
+  // t-pl zit in playlist "plaat"; die playlist is ook een album met dezelfde
+  // naam. Sluit een NIEUWERE post hem rechtstreeks in, dan wint die -- de
+  // specifiekste insluiting, niet de laatste.
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+              VALUES ('p-direct','s1','u1','direct-post','Direct','Deze: [[track:t-pl]]','published','2025-01-01T00:00:00Z')`).run();
+  assert.equal(audioVoor('t-pl').context, 'https://test.example/ap/notes/p-direct',
+    'ouder maar specifieker gaat voor');
+  db.prepare("DELETE FROM posts WHERE id = 'p-direct'").run();
+});
+
+test('een concept telt niet als host', () => {
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status)
+              VALUES ('p-concept','s1','u1','concept','Concept','[[track:t-wees]]','draft')`).run();
+  assert.equal(audioVoor('t-wees').context, undefined, 'alleen gepubliceerde posts hosten iets');
+  db.prepare("DELETE FROM posts WHERE id = 'p-concept'").run();
+});
+
+test('de collectie doet EEN zoekopdracht en levert dezelfde koppelingen', () => {
+  const col = AP.buildTrackCollection(BASE, site(), AP.siteOpenTracks('s1'));
+  const perNaam = Object.fromEntries(col.orderedItems.map((a) => [a.name, a.context]));
+  assert.equal(perNaam['Losse track'], 'https://test.example/ap/notes/p-los');
+  assert.equal(perNaam['Via playlist'], 'https://test.example/ap/notes/p-pl');
+  assert.equal(perNaam['Nergens ingesloten'], undefined);
+});
+
+test('en de outbox draagt de context ook', () => {
+  const ob = AP.buildOutbox(BASE, site(), [], AP.siteOpenTracks('s1'));
+  const c = ob.orderedItems.find((x) => (x.object || {}).name === 'Losse track');
+  assert.equal(c.object.context, 'https://test.example/ap/notes/p-los');
+});
