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

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

De post is de uitgave: losse tracks worden een collectie (shaer-38y)

Losse tracks gingen tot nu toe los de deur uit -- Audio-objecten die een lezer
nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
dat is wat /ap/users/<slug>/posts/<id>/tracks nu zegt. De post wijst er langs
dezelfde weg naartoe als naar een playlist: een Link-tag in de Note, want zonder
die link bestaat de collectie wel maar vindt niemand hem.

PUNT 1, DE VOLGORDE. De tracks staan in de volgorde van de POST en niet die van
de tabel. Zoals iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.

PUNT 3, DE METADATA. Een playlist heeft een titel en soms een hoes; een post
heeft een titel, een tekst, een hoes, tags en een datum. Voor audio-gebaseerde
inhoud is de post de uitgave -- dat is waar iemand hem heeft uitgebracht en waar
het verhaal erbij staat. Zowel de collectie van losse tracks als een
playlist-collectie leent nu van de post die haar uitbrengt.

De posttekst wordt content en niet summary: in AS2 is summary de korte
samenvatting en content het lijf. Artiest en jaar blijven dus in summary staan,
want dat IS een samenvatting en de posttekst is dat niet. De eigen naam van de
playlist gaat niet verloren maar verhuist naar alsoKnownAs.

WIE LEENT ER UIT: alleen een post die er EEN muzikale eenheid van maakt. Staan
er twee collecties in, dan is de post niet meer de drager van een identiteit en
houdt de playlist de zijne. Dezelfde regel als in de afleiding, hier alleen
toegepast.

Een test die eerder vastlegde dat losse tracks GEEN Link-tag kregen is
omgedraaid, met de reden erbij: dat klopte zolang alleen een playlist een
collectie had.

Punt 2 van de bead (type afleiden) stond al in postMusicType.

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

  • Property mode set to 100644
File size: 7.5 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('losse tracks in een post krijgen sinds shaer-38y wel een Link-tag', () => {
111 // Dit testte eerder dat er GEEN Link-tag was. Dat klopte zolang alleen een
112 // playlist een collectie had; nu zijn losse tracks in een post ook een
113 // uitgave, met een eigen collectie. Zonder deze link bestaat die wel maar
114 // vindt niemand hem -- dus de verwachting is omgedraaid, niet het gedrag
115 // stukgegaan.
116 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
117 const note = AP.buildNote('https://test.example', site, { id: 'p3', slug: 'p3', title: 'Los', content: 'Gewoon tekst met [[track:t1]]', status: 'published' });
118 const links = (note.tag || []).filter((t) => t.type === 'Link');
119 assert.equal(links.length, 1);
120 assert.equal(links[0].href, 'https://test.example/ap/users/band/posts/p3/tracks');
121});
122
123test('en een post met alleen tekst nog steeds geen enkele Link-tag', () => {
124 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
125 const note = AP.buildNote('https://test.example', site, { id: 'p4', slug: 'p4', title: 'Niets', content: 'Gewoon tekst', status: 'published' });
126 assert.equal((note.tag || []).filter((t) => t.type === 'Link').length, 0);
127});
128
129test('een post met alleen een DICHTE track ook niet -- geen lege uitgave', () => {
130 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
131 const note = AP.buildNote('https://test.example', site, { id: 'p5', slug: 'p5', title: 'Dicht', content: 'Alleen [[track:t2]]', status: 'published' });
132 assert.equal((note.tag || []).filter((t) => t.type === 'Link').length, 0);
133});
134
135test.after(() => server.close());
Note: See TracBrowser for help on using the repository browser.