source: Klonkt/test/reactions-characterization.test.js@ 14f7cb2

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

Eén leespad voor reacties (shaer-9e9 fase 2)

De tussentabel is nu ook voor LEZEN de bron. getReaction(slug, uri) vervangt
getMyReactions en getTimelineReaction, die dezelfde vraag beantwoordden uit twee
verschillende bronnen -- de reden dat een like in de Krant en dezelfde like op de
interact-pagina een andere knopstand konden tonen.

Vijf lezers omgezet: de interact-pagina en zijn twee toggles, en de twee toggles
in de Krant. Plus de C2S-tijdlijn, waar shaer:liked en shaer:boosted niet meer
uit de afgeleide kolom komen. Die laatste gaat via getReactionsFor: één query
voor de hele pagina, want per rij vragen zou een consistentiebug hebben geruild
voor een N+1.

Mag alleen ná scripts/backfill-reactions.mjs. Een reactie van vóór fase 1 staat
alleen in de kolom en is voor getReaction onzichtbaar -- stil, want een
ontbrekende rij is geen fout. Er staat nu een test die precies dat vastlegt, zodat
de volgorde-eis uitvoerbaar is en niet alleen opgeschreven.

Robin heeft bevestigd dat de likes van 09:01 via de KRANT gingen, dus het
schrijven van fase 1 is op de webroute bewezen -- dat was de poort voor deze
stap, want anders zou de knop daar nu stuk gaan in plaats van scheef te lopen.

Suite 430/430.

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

  • Property mode set to 100644
File size: 13.0 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: gescheiden sleutels blijven werken zolang ze bestaan', () => {
196 // De tussentabel wordt gesleuteld op wat de client stuurde, de vlag op de
197 // opgeloste object-URI. Meestal gelijk, niet gegarandeerd. flagUri houdt dat
198 // uit elkaar tot fase 2 ze samentrekt.
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.getMyReactions('me', gestuurd).liked, true, 'op de gestuurde uri');
204 assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'op de opgeloste uri');
205});
206
207test('setReaction: rommelige invoer doet niets in plaats van iets halfs', () => {
208 AP.setReaction('me', uri('sr4'), 'sterretje', true);
209 assert.equal(AP.getMyReactions('me', uri('sr4')).liked, false);
210 AP.setReaction('', uri('sr5'), 'like', true);
211 AP.setReaction('me', '', 'like', true);
212 assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
213});
214
215// ── Fase 2: lezen komt uit de tussentabel ────────────────────────────────
216
217test('getReaction leest de tussentabel, niet de kolom', () => {
218 const u = uri('g1'); seedTimeline(u);
219 AP.setReaction('me', u, 'like', true);
220 assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: false });
221 AP.setReaction('me', u, 'boost', true);
222 assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: true });
223});
224
225test('WAAROM DE BACKFILL EERST MOET: een kale kolomvlag is onzichtbaar voor getReaction', () => {
226 // Dit is de reden dat scripts/backfill-reactions.mjs tussen fase 1 en 2 hoort.
227 // Een reactie van vóór fase 1 staat alleen in de kolom; zodra de lezers de
228 // tussentabel volgen is die stil verdwenen -- geen fout, geen spoor.
229 const u = uri('g2'); seedTimeline(u);
230 AP.markLiked('me', u); // zoals de oude tijdlijn-route
231 assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
232 assert.equal(AP.getReaction('me', u).liked, false, 'maar het nieuwe leespad ziet hem niet');
233 // Wat de backfill doet:
234 AP.setMyReaction('me', u, 'like', true);
235 assert.equal(AP.getReaction('me', u).liked, true, 'na aanvullen wel');
236});
237
238test('getReactionsFor haalt een hele pagina in één keer op', () => {
239 const a = uri('g3'), b = uri('g4'), c = uri('g5');
240 seedTimeline(a); seedTimeline(b);
241 AP.setReaction('me', a, 'like', true);
242 AP.setReaction('me', b, 'boost', true);
243 const m = AP.getReactionsFor('me', [a, b, c]);
244 assert.equal(m.get(a).liked, true);
245 assert.equal(m.get(b).boosted, true);
246 assert.equal(m.get(c), undefined, 'wie niets heeft komt niet in de map; de aanroeper valt terug op false');
247 // Dezelfde uitkomst als per stuk vragen, zodat de batch geen eigen waarheid wordt.
248 for (const u of [a, b]) assert.deepEqual(m.get(u), AP.getReaction('me', u));
249});
250
251test('getReactionsFor: lege of rommelige invoer geeft een lege map', () => {
252 assert.equal(AP.getReactionsFor('me', []).size, 0);
253 assert.equal(AP.getReactionsFor('me', null).size, 0);
254 assert.equal(AP.getReactionsFor('', [uri('g3')]).size, 0);
255});
Note: See TracBrowser for help on using the repository browser.