source: Klonkt/test/thread.test.js@ c4b24b3

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

De thread van een vreemde post: ook de next-pagina lezen

Barts verduidelijking (8-8) wees de fout aan: het ging om reacties op een
VREEMDE post, niet een eigen. Mijn Waydroid-test draaide op een eigen
post, en die gaat door de lokale kortsluiting -- orderedItems meteen vol,
niets te wandelen. Vandaar dat de fout niet opviel.

Mastodon serveert de replies-collectie met een first die een INLINE
pagina is met lege items en een next waar de antwoorden echt staan.
Wie alleen de eerste pagina leest ziet op elke Mastodon-post een leeg
gesprek. getThread wandelt nu tot drie pagina's, met behoud van inline
objecten (die hoeven niet opnieuw opgehaald) -- dezelfde kneep die
collectReplyItems voor de eigen-post-crawler al kende.

Test erbij die de Mastodon-vorm exact nabootst; alle 624 groen.

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

  • Property mode set to 100644
File size: 6.4 KB
Line 
1// De thread onder een post (shaer-tqz): ophalen, normaliseren, niet bewaren.
2//
3// De vier dingen die hier stil kunnen breken en die een gebruiker allemaal
4// anders voelt: een antwoord dat niet doorkomt (te streng), een vreemde die
5// bij een ward doorkomt (te los), HTML die ongeschoond doorreist (gevaarlijk),
6// en een geblokkeerde die meegeteld wordt in shaer:hidden (een blokkade hoort
7// onzichtbaar te zijn, ook als getal).
8//
9// In-memory SQLite, gestubde fetch op TEST-NET-3. Run: npm test
10
11import { test } from 'node:test';
12import assert from 'node:assert/strict';
13
14process.env.DATABASE_PATH = ':memory:';
15process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
16
17const dbMod = await import('../src/config/database.js');
18const db = dbMod.default;
19dbMod.initializeDatabase();
20const AP = await import('../src/services/ActivityPubService.js');
21
22db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
23 .run('u1', 'u1', 'u1@test', 'x', 'god');
24db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
25
26const BRON = 'https://203.0.113.10/notes/post-1';
27const TANTE = 'https://203.0.113.20/users/tante';
28const VREEMDE = 'https://203.0.113.30/users/vreemde';
29const GEBLOKT = 'https://203.0.113.40/users/naar';
30
31// De tante is gevolgd (accepted): zij zit in de kring van de guardians.
32db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('kind', ?, 'accepted')").run(TANTE);
33db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('kind', 'actor', ?)").run(GEBLOKT);
34
35const actorDoc = (id, naam) => ({ id, type: 'Person', preferredUsername: naam, name: naam, inbox: `${id}/inbox` });
36const reply = (n, actor, content, published) => ({
37 id: `${BRON}/replies/${n}`, type: 'Note', attributedTo: actor,
38 inReplyTo: BRON, content, published,
39});
40
41const antwoorden = [
42 reply(1, VREEMDE, '<p>Hoi! <script>alert(1)</script></p>', '2026-08-01T10:00:00Z'),
43 reply(2, TANTE, '<p>Dag lieverd</p>', '2026-08-01T09:00:00Z'),
44 reply(3, GEBLOKT, '<p>naar bericht</p>', '2026-08-01T11:00:00Z'),
45];
46
47globalThis.fetch = async (url) => {
48 const u = String(url);
49 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
50 if (u === BRON) return json({ id: BRON, type: 'Note', attributedTo: TANTE, content: '<p>de post</p>', replies: `${BRON}/replies` });
51 if (u === `${BRON}/replies`) return json({ id: `${BRON}/replies`, type: 'Collection', first: `${BRON}/replies?page=1` });
52 if (u === `${BRON}/replies?page=1`) return json({ type: 'CollectionPage', orderedItems: antwoorden });
53 if (u === TANTE) return json(actorDoc(TANTE, 'tante'));
54 if (u === VREEMDE) return json(actorDoc(VREEMDE, 'vreemde'));
55 if (u === GEBLOKT) return json(actorDoc(GEBLOKT, 'naar'));
56 return new Response('not found', { status: 404 });
57};
58
59test('een gewone lezer krijgt alles behalve de geblokkeerde, oudste eerst, geschoond', async () => {
60 const uit = await AP.getThread('kind', BRON, { isWard: false });
61 assert.equal(uit.found, true);
62 assert.equal(uit.notes.length, 2);
63 // Oudste eerst: een gesprek lees je van boven naar beneden.
64 assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
65 assert.equal(uit.notes[1]['shaer:author'].name, 'vreemde');
66 // De sanitizer heeft het script eruit gehaald, de tekst mag blijven.
67 assert.ok(!uit.notes[1].content.includes('<script'), 'script weggeschoond');
68 assert.ok(uit.notes[1].content.includes('Hoi!'));
69 // Een blokkade is onzichtbaar: niet in de lijst en NIET in de telling.
70 assert.equal(uit.hidden, 0);
71 assert.ok(uit.notes.every((n) => n.attributedTo !== GEBLOKT));
72});
73
74test('een ward ziet alleen de kring van de guardians, de rest wordt geteld', async () => {
75 // Eigen cache-sleutel per (slug, uri) zou hier de vorige uitkomst hergeven;
76 // een andere slug dwingt een verse opbouw af zonder aan de cache te morrelen.
77 db.prepare("INSERT INTO sites (id, slug, title, owner_id) VALUES ('s2', 'pupil', 'Pupil', 'u1')").run();
78 db.prepare("INSERT INTO ap_following (slug, actor_uri, status) VALUES ('pupil', ?, 'accepted')").run(TANTE);
79 db.prepare("INSERT INTO ap_blocks (slug, kind, target) VALUES ('pupil', 'actor', ?)").run(GEBLOKT);
80 const uit = await AP.getThread('pupil', BRON, { isWard: true });
81 assert.equal(uit.notes.length, 1);
82 assert.equal(uit.notes[0]['shaer:author'].name, 'tante');
83 // De vreemde is er, en dat mag gezegd: geteld, niet stil weggelaten.
84 assert.equal(uit.hidden, 1);
85});
86
87test('een onbereikbare note zegt dat, in plaats van een lege thread te veinzen', async () => {
88 const uit = await AP.getThread('kind', 'https://203.0.113.99/weg', { isWard: false });
89 assert.equal(uit.found, false);
90 assert.equal(uit.notes.length, 0);
91});
92
93test('de Mastodon-vorm: first is een lege inline-pagina, de antwoorden staan op next', async () => {
94 // Precies wat Mastodon serveert en wat Barts melding (8-8) verklaarde: wie
95 // alleen de eerste pagina leest, ziet op elke Mastodon-post een leeg gesprek.
96 const MPOST = 'https://203.0.113.50/notes/masto-1';
97 const vorige = globalThis.fetch;
98 globalThis.fetch = async (url) => {
99 const u = String(url);
100 const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
101 if (u === MPOST) return json({
102 id: MPOST, type: 'Note', attributedTo: TANTE, content: '<p>toot</p>',
103 replies: {
104 id: `${MPOST}/replies`, type: 'Collection',
105 first: { type: 'CollectionPage', items: [], next: `${MPOST}/replies?page=true` },
106 },
107 });
108 if (u === `${MPOST}/replies?page=true`) return json({
109 type: 'CollectionPage',
110 items: [{ id: `${MPOST}/r1`, type: 'Note', attributedTo: TANTE, inReplyTo: MPOST, content: '<p>eerste echte antwoord</p>', published: '2026-08-02T10:00:00Z' }],
111 });
112 return vorige(url);
113 };
114 const uit = await AP.getThread('kind', MPOST, { isWard: false });
115 globalThis.fetch = vorige;
116 assert.equal(uit.notes.length, 1, 'het antwoord op de next-pagina is gevonden');
117 assert.ok(uit.notes[0].content.includes('eerste echte antwoord'));
118});
119
120test('de tweede lezing komt uit het geheugen, niet van het netwerk', async () => {
121 let calls = 0;
122 const vorige = globalThis.fetch;
123 globalThis.fetch = async (...a) => { calls += 1; return vorige(...a); };
124 const uit = await AP.getThread('kind', BRON, { isWard: false });
125 assert.equal(uit.notes.length, 2);
126 assert.equal(calls, 0, 'alles uit de cache, nul fetches');
127 globalThis.fetch = vorige;
128});
Note: See TracBrowser for help on using the repository browser.