source: Klonkt/test/reactions-characterization.test.js@ 68a4d1b

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

De halve schrijfactie kan niet meer (shaer-9e9 fase 3, deel 1)

markLiked, unmarkLiked, markBoosted, unmarkBoosted, setMyReaction,
getMyReactions en getTimelineReaction staan niet langer op het default-object
van ActivityPubService. Routes importeren dat object, dus daarmee is de helft
schrijven vanaf een route onmogelijk geworden in plaats van afgeraden. De named
exports blijven bestaan voor intern gebruik en voor de tests. Een test bewaakt
het oppervlak, want een afspraak in een comment overleeft geen half jaar.

Meteen ook de laatste halve schrijfactie weg, en die was van mijzelf: in de
interact-boost stond nog een kale setMyReaction "zodat de knop klopt voordat de
resolve terug is". Dat is nu setReaction, dus ook dat korte moment schrijft
beide bronnen; de resolve werkt hem daarna bij met de note zodat de post in je
tijdlijn belandt.

De kolommen zijn gedocumenteerd als afgeleide, op de plek waar iemand ze
tegenkomt. Met het onderscheid erbij: ap_timeline.boosted verdient zijn bestaan
als index (Cirkel-WHERE, boostedCount), ap_timeline.liked niet.

WAT IK BEWUST NIET DOE: ap_timeline.liked weggooien. Het plan zegt dat het kan,
en dat klopt, maar zolang die kolom gevuld blijft is terugdraaien een
code-revert en geen dataherstel. Weggooien hoort pas als fase 2 een tijd goed
heeft gestaan -- niet in dezelfde sessie waarin het leespad is omgezet.

Suite 431/431.

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

  • Property mode set to 100644
File size: 14.1 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});
256
257// ── Fase 3: het publieke oppervlak is versmald ───────────────────────────
258
259test('de primitieven zijn niet meer bereikbaar via het service-object', async () => {
260 // Routes doen `import ActivityPubService from ...` en werken dus met het
261 // default-object. Zolang markLiked daar in staat, kan een aanroeper de helft
262 // schrijven -- en dat is niet hypothetisch: precies zo bleef shaer:liked
263 // maandenlang false. De named exports blijven bestaan voor intern gebruik en
264 // voor deze tests.
265 const svc = (await import('../src/services/ActivityPubService.js')).default;
266 for (const naam of ['markLiked', 'unmarkLiked', 'markBoosted', 'unmarkBoosted',
267 'setMyReaction', 'getMyReactions', 'getTimelineReaction']) {
268 assert.equal(svc[naam], undefined, `${naam} hoort niet op het publieke oppervlak te staan`);
269 }
270 // Wat er WEL hoort te staan: het ene schrijfpad en het ene leespad.
271 for (const naam of ['setReaction', 'getReaction', 'getReactionsFor']) {
272 assert.equal(typeof svc[naam], 'function', `${naam} hoort er wel te zijn`);
273 }
274});
Note: See TracBrowser for help on using the repository browser.