source: Klonkt/test/reactions-characterization.test.js@ c010b42

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

Eén schrijfpad voor reacties: setReaction (shaer-9e9 fase 1, stap 2)

Alle plekken die "ik heb hierop gereageerd" opschreven gaan nu door
setReaction(slug, uri, kind, on, opts): de tussentabel ap_my_reactions is de
waarheid, de vlaggen op ap_timeline zijn de afgeleide. Dat zijn de twee takken
van de C2S-outbox (Like/Announce en Undo) en de vier webhandlers (interact-like,
interact-boost, tijdlijn-like, tijdlijn-boost).

Twee dingen zitten er bewust in.

opts.note, omdat een boost geen vlag zet maar de post je tijdlijn IN trekt als je
de auteur niet volgt -- anders heeft de vlag geen rij om op te landen en
verschijnt de boost nergens, ook niet in de Cirkel. Dat is de valkuil die in het
plan met naam staat.

opts.flagUri, omdat de twee bronnen vandaag verschillend gesleuteld worden: de
tussentabel op de URI die de client stuurde, de vlag op de opgeloste object-URI.
Meestal gelijk, niet gegarandeerd. Die naad houdt deze stap gedragsbehoudend;
het samentrekken van de sleutels is fase 2, met datamigratie.

CORRECTIE OP HET PLAN: ik schreef dat deze stap "per definitie" geen
gedragswijziging geeft omdat lezen niet verandert. Dat klopt niet. Nu de
interact-route ook de kolom vult, verschijnt een like die eerder alleen in de
tussentabel stond voortaan ook in de Krant. Dat is de bedoelde convergentie,
maar het is wel zichtbaar -- en het hoort in de notitie te staan in plaats van
weggeredeneerd.

CORRECTIE OP DE TESTS: de karakteriseringstests zouden volgens hun eigen kop
gaan falen na deze stap. Dat gebeurde niet, en terecht: ze roepen de primitieven
aan en die zijn niet veranderd. De kop is bijgewerkt en er zijn vier tests bij
die wel bewaken wat deze stap oplevert -- dat setReaction beide bronnen vult,
dat een boost met note de rij aanmaakt en in de Cirkel belandt, dat de
gescheiden sleutels blijven werken, en dat rommelige invoer niets doet in plaats
van iets halfs.

Suite 426/426.

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

  • Property mode set to 100644
File size: 10.8 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});
Note: See TracBrowser for help on using the repository browser.