source: Klonkt/test/reactions-characterization.test.js@ 6bc5ebd

main
Last change on this file since 6bc5ebd was 6bc5ebd, checked in by Claude (agent) <aiclaude@…>, 5 weeks ago

Interact-pagina toonde geen reactie: twee sleutelruimtes samengetrokken

Robins melding: sound-fabrics.com/effortlesseffect is geliket EN geboost, maar
de knoppen op de interact-pagina lichtten niet op.

Een post heeft twee URI's. Zijn AP-object-id (.../ap/notes/<uuid>), die de Krant
en het C2S-pad spreken, en zijn leesbare permalink (.../effortlesseffect), waar
de interact-pagina mee binnenkomt. De tussentabel stond vol object-URI's -- alle
zestien rijen -- en de interact-pagina zocht op de permalink. De reactie bestond
dus wel; we keken op de verkeerde sleutel.

Dit is de naad die ik in fase 1 bewust open liet met de opmerking dat
samentrekken werk voor fase 2 was. Dat uitstel was fout: zodra het leespad de
tussentabel volgde, werd een bestaande reactie onzichtbaar in plaats van
inconsistent. canonicalReactionUri vertaalt nu bij zowel lezen als schrijven naar
de object-URI, met de permalink als ingang. Kennen we de post niet, dan blijft de
invoer de sleutel, zodat een reactie op iets buiten je tijdlijn blijft werken.

setReaction gebruikt daarmee EEN sleutel voor beide bronnen in plaats van de
gestuurde voor de tussentabel en de opgeloste voor de vlag.

Drie tests erbij, waaronder Robins geval bij naam: liken via de object-URI en
uitlezen via de permalink, en andersom.

LET OP BIJ UITROL ELDERS: op dev staan alle tussentabel-rijen al op de
object-URI, dus daar viel niets te migreren. Op een instance waar de
interact-pagina eerder is gebruikt kunnen rijen op de permalink staan; die worden
na deze wijziging niet meer gevonden. Meet dat vóór de uitrol:

SELECT COUNT(*) FROM ap_my_reactions r

WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t

WHERE t.slug=r.site_slug AND t.id=r.target_uri);

Suite 434/434.

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

  • Property mode set to 100644
File size: 16.3 KB
Line 
1// KARAKTERISERING van "heb ik hierop gereageerd", vóór de opschoning van
2// shaer-9e9. Deze tests beschrijven wat de code NU doet, niet wat ze zou moeten
3// doen. Ze staan hier om de aanstaande refactor hoorbaar te maken.
4//
5// Waarom dat nodig is: dit gebied faalt stil. Elke mark*-aanroep zit in een
6// try/catch die niets doet, en een UPDATE die geen rij raakt is geen fout. Zo
7// kon de shaer:liked-bug (Klonkt 04aca12) maanden bestaan. Een refactor kan hier
8// dus slagen, groen testen en tóch state kwijtraken -- tenzij het huidige gedrag
9// eerst is vastgeschreven.
10//
11// LET OP: de tests onder "de twee bronnen lopen uiteen" roepen de PRIMITIEVEN
12// aan (markLiked, setMyReaction). Die blijven bewust gescheiden -- het
13// samenvoegen zit in setReaction, dus in de aanroepers, niet in de primitieven.
14// Deze tests blijven daarom groen na fase 1 en beschrijven dan nog steeds iets
15// waars: wie markLiked los aanroept, raakt de tussentabel niet. Dat is precies
16// waarom setReaction bestaat en waarom die primitieven op termijn intern moeten
17// worden.
18//
19// De invariant die fase 1 wél bewaakt staat onderaan, bij setReaction.
20//
21// Wat hier NIET in kan: de routes zelf (die vragen HTTP + sessie) en het
22// C2S-pad voor Like/Announce (dat doet netwerk; zie de kop van
23// c2s-outbox.test.js). Dit dekt de servicelaag waar alle drie de ingangen op
24// uitkomen.
25//
26// Run: npm test
27
28import { test } from 'node:test';
29import assert from 'node:assert/strict';
30
31process.env.DATABASE_PATH = ':memory:';
32process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
33
34const dbMod = await import('../src/config/database.js');
35const db = dbMod.default;
36dbMod.initializeDatabase();
37const AP = await import('../src/services/ActivityPubService.js');
38
39db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
40 .run('u1', 'u1', 'u1@test', 'x', 'god');
41db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
42
43const uri = (n) => `https://r.test/ap/notes/${n}`;
44
45/** Een post van iemand anders in JOUW tijdlijn-cache. */
46function seedTimeline(id, author = 'https://r.test/users/anna') {
47 db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
48 VALUES (?,?,?,?,?,?)`).run(id, 'me', author, 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
49}
50
51// ── Ingang 1: de tijdlijn-route (posts.js ~1279-1304) ────────────────────
52// Schrijft de KOLOM en leest de kolom. De tussentabel blijft ongemoeid.
53
54test('tijdlijn-route: markLiked zet de kolom en getTimelineReaction leest hem', () => {
55 const u = uri('t1'); seedTimeline(u);
56 assert.deepEqual(AP.getTimelineReaction('me', u), { liked: false, boosted: false });
57 AP.markLiked('me', u);
58 assert.deepEqual(AP.getTimelineReaction('me', u), { liked: true, boosted: false });
59 AP.unmarkLiked('me', u);
60 assert.equal(AP.getTimelineReaction('me', u).liked, false);
61});
62
63test('tijdlijn-route: markBoosted zet de kolom en de post verschijnt in de Cirkel', () => {
64 const u = uri('t2'); seedTimeline(u);
65 assert.equal(AP.boostedCount('me'), 0);
66 AP.markBoosted('me', u);
67 assert.equal(AP.getTimelineReaction('me', u).boosted, true);
68 assert.equal(AP.boostedCount('me'), 1);
69 // De Cirkel is wat een gebruiker ziet: geboost = zichtbaar daar.
70 assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'geboost hoort in de Cirkel te staan');
71 AP.unmarkBoosted('me', u);
72 assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
73});
74
75test('tijdlijn-route: een vlag op een post die NIET in je tijdlijn staat gaat stil verloren', () => {
76 // Dit is het stille falen waar de refactor voor moet oppassen: geen fout,
77 // geen rij, geen spoor. Een UPDATE die niets raakt is voor SQLite in orde.
78 const u = uri('bestaat-niet');
79 AP.markLiked('me', u);
80 assert.deepEqual(AP.getTimelineReaction('me', u), { liked: false, boosted: false });
81});
82
83// ── Ingang 2: de interact-route (posts.js ~841-869) ──────────────────────
84// Schrijft de TUSSENTABEL en leest de tussentabel. De kolom blijft ongemoeid.
85
86test('interact-route: setMyReaction schrijft de tussentabel, ook zonder tijdlijnrij', () => {
87 const u = uri('i1'); // bewust NIET in ap_timeline
88 assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: false });
89 AP.setMyReaction('me', u, 'like', true);
90 assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: false });
91 AP.setMyReaction('me', u, 'boost', true);
92 assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: true });
93 AP.setMyReaction('me', u, 'like', false);
94 assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: true });
95});
96
97test('interact-route: tweemaal dezelfde reactie levert geen dubbele rij op', () => {
98 const u = uri('i2');
99 AP.setMyReaction('me', u, 'like', true);
100 AP.setMyReaction('me', u, 'like', true);
101 const n = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE site_slug=? AND target_uri=?').get('me', u).n;
102 assert.equal(n, 1, 'de UNIQUE hoort het dubbel opslaan te voorkomen');
103});
104
105// ── De valkuil met naam: upsertBoostedNote is een INSERT ─────────────────
106
107test('upsertBoostedNote trekt een post die je NIET volgt je tijdlijn in', () => {
108 // Dit is geen vlag zetten. Zonder deze insert bestaat de rij niet, kan de
109 // vlag nergens landen, en verschijnt de boost nergens. Wie dit in de refactor
110 // vervangt door "gewoon markBoosted" boost iets wat daarna onvindbaar is.
111 const u = uri('b1');
112 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 0);
113 AP.upsertBoostedNote('me', {
114 object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo',
115 content: '<p>geboost</p>', media: '[]',
116 });
117 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 1, 'de rij hoort aangemaakt te worden');
118 assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'en meteen als geboost gemarkeerd');
119 assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en dus zichtbaar in de Cirkel');
120});
121
122// ── De twee bronnen lopen uiteen ─────────────────────────────────────────
123// De primitieven raken elkaar niet, en dat blijft na fase 1 zo: het
124// samenvoegen zit in setReaction. Deze drie leggen vast waarom die functie
125// moet bestaan, en waarom markLiked en broers uiteindelijk intern horen te
126// worden -- zolang ze los aanroepbaar zijn, kan een aanroeper de helft doen.
127
128test('HUIDIG GEDRAG: de tijdlijn-route vult de tussentabel niet', () => {
129 const u = uri('d1'); seedTimeline(u);
130 AP.markLiked('me', u);
131 assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
132 assert.equal(AP.getMyReactions('me', u).liked, false,
133 'de primitief raakt de tussentabel niet -- daarvoor is setReaction');
134});
135
136test('HUIDIG GEDRAG: de interact-route vult de kolom niet', () => {
137 const u = uri('d2'); seedTimeline(u);
138 AP.setMyReaction('me', u, 'like', true);
139 assert.equal(AP.getMyReactions('me', u).liked, true, 'de tussentabel staat aan');
140 assert.equal(AP.getTimelineReaction('me', u).liked, false,
141 'de primitief raakt de kolom niet -- daarvoor is setReaction');
142});
143
144test('HUIDIG GEDRAG: alleen het C2S-pad schrijft allebei', () => {
145 // Wat ingestOutboxActivity sinds Klonkt 04aca12 doet, hier nagebootst zonder
146 // het netwerk: setMyReaction + markLiked. Dit is het enige pad dat de twee
147 // bronnen gelijk houdt, en precies daarom werkt un-liken vanuit een app wel.
148 const u = uri('d3'); seedTimeline(u);
149 AP.setMyReaction('me', u, 'like', true);
150 AP.markLiked('me', u);
151 assert.equal(AP.getMyReactions('me', u).liked, true);
152 assert.equal(AP.getTimelineReaction('me', u).liked, true);
153});
154
155// ── De scheefheid, meetbaar (fase 0 uit shaer-9e9 als test) ─────────────
156
157test('meetbaar: hoeveel rijen hebben een vlag zonder tegenhanger', () => {
158 // Dezelfde query als fase 0 in shaer-9e9, hier als test zodat de refactor
159 // hem kan gebruiken: na fase 1 hoort dit getal 0 te zijn en te blijven.
160 const scheef = db.prepare(`
161 SELECT COUNT(*) AS n FROM ap_timeline t
162 WHERE (t.liked = 1 OR t.boosted = 1)
163 AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
164 WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n;
165 assert.ok(scheef > 0, 'vandaag lopen ze uiteen; na fase 1 hoort deze assert omgedraaid te worden naar === 0');
166});
167
168// ── De invariant van fase 1: setReaction houdt de bronnen gelijk ─────────
169
170test('setReaction: een like landt in BEIDE bronnen', () => {
171 const u = uri('sr1'); seedTimeline(u);
172 AP.setReaction('me', u, 'like', true);
173 assert.equal(AP.getMyReactions('me', u).liked, true, 'tussentabel');
174 assert.equal(AP.getTimelineReaction('me', u).liked, true, 'afgeleide vlag');
175 AP.setReaction('me', u, 'like', false);
176 assert.equal(AP.getMyReactions('me', u).liked, false);
177 assert.equal(AP.getTimelineReaction('me', u).liked, false);
178});
179
180test('setReaction: een boost met note trekt de post je tijdlijn in EN vult beide', () => {
181 // De valkuil uit het plan: zonder de note bestaat de rij niet en landt de
182 // vlag nergens, dus zou de boost onvindbaar zijn.
183 const u = uri('sr2');
184 AP.setReaction('me', u, 'boost', true, {
185 note: { object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo', content: '<p>x</p>', media: '[]' },
186 });
187 assert.equal(AP.getMyReactions('me', u).boosted, true, 'tussentabel');
188 assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'afgeleide vlag');
189 assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en zichtbaar in de Cirkel');
190 AP.setReaction('me', u, 'boost', false);
191 assert.equal(AP.getMyReactions('me', u).boosted, false);
192 assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
193});
194
195test('setReaction: flagUri bepaalt de sleutel voor BEIDE bronnen', () => {
196 // Vroeger kreeg de tussentabel de URI die de client stuurde en de vlag de
197 // opgeloste. Nu is het er één, anders is dezelfde like onvindbaar vanaf een
198 // pagina die de andere URI kent.
199 const gestuurd = 'https://r.test/@anna/123';
200 const opgelost = uri('sr3');
201 seedTimeline(opgelost);
202 AP.setReaction('me', gestuurd, 'like', true, { flagUri: opgelost });
203 assert.equal(AP.getReaction('me', opgelost).liked, true, 'onder de opgeloste uri');
204 assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'en de vlag ook');
205 assert.equal(AP.getMyReactions('me', gestuurd).liked, false, 'niet meer onder de gestuurde');
206});
207
208test('setReaction: rommelige invoer doet niets in plaats van iets halfs', () => {
209 AP.setReaction('me', uri('sr4'), 'sterretje', true);
210 assert.equal(AP.getMyReactions('me', uri('sr4')).liked, false);
211 AP.setReaction('', uri('sr5'), 'like', true);
212 AP.setReaction('me', '', 'like', true);
213 assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
214});
215
216// ── Fase 2: lezen komt uit de tussentabel ────────────────────────────────
217
218test('getReaction leest de tussentabel, niet de kolom', () => {
219 const u = uri('g1'); seedTimeline(u);
220 AP.setReaction('me', u, 'like', true);
221 assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: false });
222 AP.setReaction('me', u, 'boost', true);
223 assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: true });
224});
225
226test('WAAROM DE BACKFILL EERST MOET: een kale kolomvlag is onzichtbaar voor getReaction', () => {
227 // Dit is de reden dat scripts/backfill-reactions.mjs tussen fase 1 en 2 hoort.
228 // Een reactie van vóór fase 1 staat alleen in de kolom; zodra de lezers de
229 // tussentabel volgen is die stil verdwenen -- geen fout, geen spoor.
230 const u = uri('g2'); seedTimeline(u);
231 AP.markLiked('me', u); // zoals de oude tijdlijn-route
232 assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
233 assert.equal(AP.getReaction('me', u).liked, false, 'maar het nieuwe leespad ziet hem niet');
234 // Wat de backfill doet:
235 AP.setMyReaction('me', u, 'like', true);
236 assert.equal(AP.getReaction('me', u).liked, true, 'na aanvullen wel');
237});
238
239test('getReactionsFor haalt een hele pagina in één keer op', () => {
240 const a = uri('g3'), b = uri('g4'), c = uri('g5');
241 seedTimeline(a); seedTimeline(b);
242 AP.setReaction('me', a, 'like', true);
243 AP.setReaction('me', b, 'boost', true);
244 const m = AP.getReactionsFor('me', [a, b, c]);
245 assert.equal(m.get(a).liked, true);
246 assert.equal(m.get(b).boosted, true);
247 assert.equal(m.get(c), undefined, 'wie niets heeft komt niet in de map; de aanroeper valt terug op false');
248 // Dezelfde uitkomst als per stuk vragen, zodat de batch geen eigen waarheid wordt.
249 for (const u of [a, b]) assert.deepEqual(m.get(u), AP.getReaction('me', u));
250});
251
252test('getReactionsFor: lege of rommelige invoer geeft een lege map', () => {
253 assert.equal(AP.getReactionsFor('me', []).size, 0);
254 assert.equal(AP.getReactionsFor('me', null).size, 0);
255 assert.equal(AP.getReactionsFor('', [uri('g3')]).size, 0);
256});
257
258// ── Fase 3: het publieke oppervlak is versmald ───────────────────────────
259
260test('de primitieven zijn niet meer bereikbaar via het service-object', async () => {
261 // Routes doen `import ActivityPubService from ...` en werken dus met het
262 // default-object. Zolang markLiked daar in staat, kan een aanroeper de helft
263 // schrijven -- en dat is niet hypothetisch: precies zo bleef shaer:liked
264 // maandenlang false. De named exports blijven bestaan voor intern gebruik en
265 // voor deze tests.
266 const svc = (await import('../src/services/ActivityPubService.js')).default;
267 for (const naam of ['markLiked', 'unmarkLiked', 'markBoosted', 'unmarkBoosted',
268 'setMyReaction', 'getMyReactions', 'getTimelineReaction']) {
269 assert.equal(svc[naam], undefined, `${naam} hoort niet op het publieke oppervlak te staan`);
270 }
271 // Wat er WEL hoort te staan: het ene schrijfpad en het ene leespad.
272 for (const naam of ['setReaction', 'getReaction', 'getReactionsFor']) {
273 assert.equal(typeof svc[naam], 'function', `${naam} hoort er wel te zijn`);
274 }
275});
276
277// ── De permalink en de object-URI zijn dezelfde post ─────────────────────
278
279test('een like uit de Krant is zichtbaar op de interact-pagina (Robins melding)', () => {
280 // De Krant kent een post als AP-object-URI, de interact-pagina als permalink.
281 // Zochten die twee in verschillende sleutelruimtes, dan toonde een geboost en
282 // geliket bericht daar geen enkele highlight -- terwijl de reactie bestond.
283 const obj = uri('perm1');
284 const permalink = 'https://sound-fabrics.com/effortlesseffect';
285 db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
286 VALUES (?,?,?,?,?,?,?)`)
287 .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
288
289 AP.setReaction('me', obj, 'like', true); // zoals de Krant het doet
290 AP.setReaction('me', obj, 'boost', true);
291 // en de interact-pagina vraagt het met de permalink:
292 assert.deepEqual(AP.getReaction('me', permalink), { liked: true, boosted: true });
293});
294
295test('andersom net zo: reageren via de permalink landt op de object-URI', () => {
296 const obj = uri('perm2');
297 const permalink = 'https://sound-fabrics.com/tweede';
298 db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
299 VALUES (?,?,?,?,?,?,?)`)
300 .run(obj, 'me', 'https://r.test/users/bo', 'Bo', '<p>y</p>', permalink, '2026-08-06 09:00:00');
301
302 AP.setReaction('me', permalink, 'like', true); // zoals de interact-pagina het doet
303 assert.equal(AP.getReaction('me', obj).liked, true, 'onder de object-uri opgeslagen');
304 assert.equal(AP.getTimelineReaction('me', obj).liked, true, 'dus de vlag landt ook goed');
305 assert.equal(AP.getReaction('me', permalink).liked, true, 'en blijft vindbaar via de permalink');
306});
307
308test('een reactie op iets buiten je tijdlijn blijft gewoon werken', () => {
309 // Kennen we de post niet, dan is er niets te canoniseren en blijft de invoer
310 // de sleutel. Een like op een vreemde post mag daar niet op stuklopen.
311 const onbekend = 'https://elders.test/notes/xyz';
312 AP.setReaction('me', onbekend, 'like', true);
313 assert.equal(AP.getReaction('me', onbekend).liked, true);
314});
Note: See TracBrowser for help on using the repository browser.