source: Klonkt/test/ap-playlist-discovery.test.js@ 39a9d21

main
Last change on this file since 39a9d21 was 39a9d21, checked in by Robin <roboburr@…>, 5 weeks ago

Onze tracks zijn eersterangs Audio-objecten (shaer-0nh, stap 3)

De spiegel van wat sinds bdcb3a3 inkomend werkt. Onze muziek zat alleen als
naamloze bijlage in een Note: iets dat bestaat zolang je het omhullende object
vasthoudt. Nu is een track een DING met een eigen id, los op te halen, en in
twee playlists hetzelfde ding.

DE ACTOR-COLLECTIE IS DE KANONIEKE PLEK, niet de playlist (Robins vraag). Een
playlist is een keuze uit wat de artiest heeft uitgebracht; de collectie is dat
geheel. Een track die in geen enkele playlist stond was tot nu toe onzichtbaar
voor de federatie -- die staat er nu wel in.

GET /ap/users/:slug/tracks alles wat opengezet is
GET /ap/users/:slug/tracks/:id een track, met eigen @context
streams tracks eerst, dan playlists

url wordt een Link-array: de mediaType hoort bij de link, niet bij het
object. Zo doet Funkwhale het, en zo leest onze eigen inbox het sinds bdcb3a3 --
een andere Klonkt kan onze tracks dus als track opnemen in plaats van als
bijlage.

GEEN text/html-link erin. Klonkt heeft geen trackpagina: een track wordt getoond
binnen een post, en een post over vijf nummers is niet de pagina van dit ene
nummer. Liever geen link dan een link die iets anders belooft.

De poortregel loopt door: een gesloten track geeft 404, dezelfde "afwezig, niet
leeg" als in de collectie, zodat het bestaan van een gated nummer niet uit een
ander antwoord valt af te leiden.

ONDERWEG OPGERUIMD. De mediaType-afleiding stond twee keer functie-lokaal, met
een commentaar dat ze "dezelfde afleiding" waren. Dat was niet zo: de ene kende
video, de andere alleen beeld. Nu een kaart, op moduleniveau, met de volledige
map -- gedragsgelijk voor buildNote en een superset voor de playlist.

7 tests.

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

  • Property mode set to 100644
File size: 6.3 KB
Line 
1// Vindbaarheid van playlist-collecties (shaer-ayc, stap 2).
2//
3// Drie wegen ernaartoe, elk getest: de actor wijst via AS2 `streams` naar de
4// lijst; de lijst geeft kaal URI's en verrijkt stubs (FEP-9876), waarbij ook
5// een stub alleen het open deel telt; en een post die een playlist insluit
6// draagt een Link-tag naar de collectie -- maar nooit naar die van een andere
7// site, want playlist-ids zijn een globale primary key.
8import { test } from 'node:test';
9import assert from 'node:assert/strict';
10
11process.env.DATABASE_PATH = ':memory:';
12process.env.PUBLIC_BASE_URL = 'https://test.example';
13
14const dbMod = await import('../src/config/database.js');
15const db = dbMod.default;
16dbMod.initializeDatabase();
17const AP = (await import('../src/services/ActivityPubService.js')).default;
18const express = (await import('express')).default;
19const routes = (await import('../src/routes/activitypub.js')).default;
20
21db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
22 .run('u1', 'u1', 'u1@t', 'x', 'god');
23db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public) VALUES (?,?,?,?,1)')
24 .run('s1', 'band', 'De Band', 'u1');
25db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public) VALUES (?,?,?,?,1)')
26 .run('s2', 'ander', 'Andere Site', 'u1');
27
28const insMedia = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,1000)');
29const insTrack = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open) VALUES (?,?,?,?,?,?,?)');
30insMedia.run('m1', 's1', 'een.mp3', 'audio/een.mp3', 'audio/mpeg');
31insMedia.run('m2', 's1', 'twee.mp3', 'audio/twee.mp3', 'audio/mpeg');
32insTrack.run('t1', 's1', 'Open nummer', 'De Band', 200, 'm1', 1);
33insTrack.run('t2', 's1', 'Dicht nummer', 'De Band', 180, 'm2', 0);
34
35// Twee playlists op band, een op ander. Expliciete created_at: de lijst
36// sorteert erop, en twee inserts in dezelfde seconde maken die volgorde
37// anders een muntworp.
38db.prepare("INSERT INTO playlists (id, site_id, title, kind, created_at) VALUES ('eerste','s1','Eerste Plaat','album','2026-01-01 00:00:00')").run();
39db.prepare("INSERT INTO playlists (id, site_id, title, kind, created_at) VALUES ('tweede','s1','Tweede Plaat','album','2026-02-01 00:00:00')").run();
40db.prepare("INSERT INTO playlists (id, site_id, title, kind, created_at) VALUES ('vreemd','s2','Van De Ander','album','2026-03-01 00:00:00')").run();
41const insPT = db.prepare('INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
42insPT.run('eerste', 't1', 1);
43insPT.run('eerste', 't2', 2);
44
45const app = express();
46app.use(routes);
47const server = app.listen(0);
48await new Promise((r) => server.once('listening', r));
49const base = `http://127.0.0.1:${server.address().port}`;
50const getJson = async (path, headers = {}) => {
51 const r = await fetch(base + path, { headers: { Accept: 'application/activity+json', ...headers } });
52 return { status: r.status, body: r.status === 200 ? await r.json() : null, headers: r.headers };
53};
54
55test('de actor wijst via streams naar de playlist-lijst', async () => {
56 const { body } = await getJson('/ap/users/band');
57 // Sinds stap 3 staat de TRACK-collectie er ook in, en vooraan: die is de
58 // kanonieke plek voor onze muziek, de playlists zijn een keuze daaruit.
59 assert.deepEqual(body.streams, [
60 'https://test.example/ap/users/band/tracks',
61 'https://test.example/ap/users/band/playlists',
62 ]);
63});
64
65test('de lijst is kaal standaard: URI-per-playlist, alleen van deze site, in vaste volgorde', async () => {
66 const { body } = await getJson('/ap/users/band/playlists');
67 assert.equal(body.type, 'OrderedCollection');
68 assert.equal(body.totalItems, 2);
69 assert.deepEqual(body.orderedItems, [
70 'https://test.example/ap/users/band/playlists/eerste',
71 'https://test.example/ap/users/band/playlists/tweede',
72 ]);
73});
74
75test('verrijkt (FEP-9876) geeft stubs zonder tracks, en ook de stub telt alleen het open deel', async () => {
76 const { body, headers } = await getJson('/ap/users/band/playlists', { Prefer: 'return=representation' });
77 assert.equal(headers.get('preference-applied'), 'return=representation');
78 assert.match(headers.get('vary') || '', /Prefer/);
79 const stub = body.orderedItems[0];
80 assert.equal(stub.name, 'Eerste Plaat');
81 assert.equal(stub.totalItems, 1, 'een open en een gated track: de stub telt er een');
82 assert.equal(stub.orderedItems, undefined, 'een stub draagt geen tracks');
83 assert.equal(stub['@context'], undefined, 'genest object draagt de context van zijn omhulsel');
84 assert.ok(!JSON.stringify(body).includes('Dicht nummer'), 'de gated titel lekt ook hier niet');
85});
86
87test('een lege site heeft een lege lijst, geen 404 -- de lijst zelf is niet geheim', async () => {
88 const { status, body } = await getJson('/ap/users/ander/playlists');
89 assert.equal(status, 200);
90 assert.equal(body.totalItems, 1); // de eigen playlist van s2 staat er wel in
91});
92
93test('een post met [[playlist:id]] draagt een Link-tag naar de collectie', () => {
94 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
95 const post = { id: 'p1', slug: 'p1', title: 'Luister', content: 'Nieuw album! [[playlist:eerste]] en nogmaals [[playlist:eerste]]', status: 'published' };
96 const note = AP.buildNote('https://test.example', site, post);
97 const links = (note.tag || []).filter((t) => t.type === 'Link');
98 assert.equal(links.length, 1, 'dubbel insluiten geeft een tag');
99 assert.equal(links[0].href, 'https://test.example/ap/users/band/playlists/eerste');
100 assert.equal(links[0].name, 'Eerste Plaat');
101});
102
103test('een playlist van een ANDERE site levert geen tag -- de site-check is de tenancy-grens', () => {
104 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
105 const post = { id: 'p2', slug: 'p2', title: 'Fout', content: 'Kijk: [[playlist:vreemd]]', status: 'published' };
106 const note = AP.buildNote('https://test.example', site, post);
107 assert.equal((note.tag || []).filter((t) => t.type === 'Link').length, 0);
108});
109
110test('een post zonder playlist heeft geen Link-tags', () => {
111 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
112 const note = AP.buildNote('https://test.example', site, { id: 'p3', slug: 'p3', title: 'Los', content: 'Gewoon tekst met [[track:t1]]', status: 'published' });
113 assert.equal((note.tag || []).filter((t) => t.type === 'Link').length, 0);
114});
115
116test.after(() => server.close());
Note: See TracBrowser for help on using the repository browser.