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

main
Last change on this file was 279ca0b, checked in by roboburr <roboburr@…>, 5 weeks ago

De derde reactiebron ook naar ap_my_reactions (shaer-ipb)

ap_interactions.acted_like/acted_boost -- wat JIJ deed met een reactie onder je
eigen post -- was de derde bron van 'heb ik hierop gereageerd'. shaer-9e9 trok de
andere twee samen en liet deze staan omdat hij op het interactie-rij-id
gesleuteld was en de tussentabel op de object-URI.

De betekeniskeuze die de bead eerst wilde beantwoorden: dezelfde note, geliket
als comment en als post, wordt EEN feit. Er gaat immers ook maar een Like de
fediverse in; twee knoppen die los van elkaar aan konden staan voor hetzelfde
object was eerder een bug dan een feature. Mechanisch blijkt dat al veilig:
markLiked en markBoosted zijn kale UPDATEs op ap_timeline die nul rijen raken als
de URI daar niet staat, en upsertBoostedNote draait alleen met opts.note mee.

Volgorde zoals bij shaer-9e9, en om dezelfde reden -- een leespad dat naar de
tussentabel wijst terwijl de rijen nog in de kolommen staan laat reacties STIL
verdwijnen:

schrijven fedi-react roept setReaction aan; acted_* loopt nog als afgeleide

mee, hetzelfde vangnet dat ap_timeline.liked na shaer-9e9 is. Ook de
toggle leest nu uit de tussentabel: las de knop de ene bron en de
richting de andere, dan stuurt een divergentie een Undo voor iets dat
nooit verstuurd is.

migreren REACTIONS_MIGRATION_VERSION 1 -> 2, derde stap erbij. Een bump laat

alle stappen opnieuw lopen; ze zijn alle drie idempotent. Rijen
zonder object_uri vallen af, en dat verliest per constructie niets:
fedi-react eist een object_uri voordat acted_* gezet kan worden.

lezen getInteractions doet een batch-lookup langs dezelfde sleutel als de

schrijfkant, dus geen N+1 in een drukke thread.

Het backfill-script zou anders vals alarm slaan: een reactie op een COMMENT hoort
geen tijdlijnrij te hebben, dus die telt niet meer als wees. Er is een controle
bij voor acted_* zonder tegenhanger.

Gemeten op een kopie van dev: scheefActed 1 -> 0, tussentabel 19 -> 20, wees
onveranderd. Suite 472/472. Gecontroleerd dat de tests bijten: laat het leespad
weer uit de kolom lezen en vier van de zes vallen om.

De karakteriseringstest zette de versievlag hard op 1 en zou bij elke bump
omvallen zonder dat er iets stuk is; die toetst nu het gedrag (ouder = opnieuw,
gelijk of hoger = overslaan) in plaats van het getal.

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

  • Property mode set to 100644
File size: 22.5 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, maar de Cirkel volgt de tussentabel', () => {
64 // BIJGEWERKT toen de weergave werd gelijkgetrokken. Vroeger trok een kale
65 // kolomvlag de post de Cirkel in; nu selecteert de Cirkel op de tussentabel,
66 // dus de primitief alleen is niet meer genoeg. Dat is het doel: tekenen en
67 // beslissen leunen op dezelfde bron.
68 const u = uri('t2'); seedTimeline(u);
69 AP.markBoosted('me', u);
70 assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'de kolom staat aan');
71 assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
72 'maar dat brengt hem niet in de Cirkel');
73 // Via het echte schrijfpad wel.
74 AP.setReaction('me', u, 'boost', true);
75 assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'via setReaction wel');
76 AP.setReaction('me', u, 'boost', false);
77 assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
78});
79
80test('tijdlijn-route: een vlag op een post die NIET in je tijdlijn staat gaat stil verloren', () => {
81 // Dit is het stille falen waar de refactor voor moet oppassen: geen fout,
82 // geen rij, geen spoor. Een UPDATE die niets raakt is voor SQLite in orde.
83 const u = uri('bestaat-niet');
84 AP.markLiked('me', u);
85 assert.deepEqual(AP.getTimelineReaction('me', u), { liked: false, boosted: false });
86});
87
88// ── Ingang 2: de interact-route (posts.js ~841-869) ──────────────────────
89// Schrijft de TUSSENTABEL en leest de tussentabel. De kolom blijft ongemoeid.
90
91test('interact-route: setMyReaction schrijft de tussentabel, ook zonder tijdlijnrij', () => {
92 const u = uri('i1'); // bewust NIET in ap_timeline
93 assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: false });
94 AP.setMyReaction('me', u, 'like', true);
95 assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: false });
96 AP.setMyReaction('me', u, 'boost', true);
97 assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: true });
98 AP.setMyReaction('me', u, 'like', false);
99 assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: true });
100});
101
102test('interact-route: tweemaal dezelfde reactie levert geen dubbele rij op', () => {
103 const u = uri('i2');
104 AP.setMyReaction('me', u, 'like', true);
105 AP.setMyReaction('me', u, 'like', true);
106 const n = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE site_slug=? AND target_uri=?').get('me', u).n;
107 assert.equal(n, 1, 'de UNIQUE hoort het dubbel opslaan te voorkomen');
108});
109
110// ── De valkuil met naam: upsertBoostedNote is een INSERT ─────────────────
111
112test('upsertBoostedNote trekt een post die je NIET volgt je tijdlijn in', () => {
113 // Dit is geen vlag zetten. Zonder deze insert bestaat de rij niet, kan de
114 // vlag nergens landen, en verschijnt de boost nergens. Wie dit in de refactor
115 // vervangt door "gewoon markBoosted" boost iets wat daarna onvindbaar is.
116 const u = uri('b1');
117 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 0);
118 AP.upsertBoostedNote('me', {
119 object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo',
120 content: '<p>geboost</p>', media: '[]',
121 });
122 assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 1, 'de rij hoort aangemaakt te worden');
123 assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'en meteen als geboost gemarkeerd');
124 // BIJGEWERKT: de Cirkel volgt sinds het gelijktrekken van de weergave de
125 // tussentabel, en die vult alleen setReaction. upsertBoostedNote blijft
126 // verantwoordelijk voor de RIJ -- zonder die insert heeft de reactie nergens
127 // een post om bij te horen.
128 assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false, 'de rij alleen is niet genoeg');
129 AP.setMyReaction('me', u, 'boost', true);
130 assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'met de tussentabel erbij wel');
131});
132
133// ── De twee bronnen lopen uiteen ─────────────────────────────────────────
134// De primitieven raken elkaar niet, en dat blijft na fase 1 zo: het
135// samenvoegen zit in setReaction. Deze drie leggen vast waarom die functie
136// moet bestaan, en waarom markLiked en broers uiteindelijk intern horen te
137// worden -- zolang ze los aanroepbaar zijn, kan een aanroeper de helft doen.
138
139test('HUIDIG GEDRAG: de tijdlijn-route vult de tussentabel niet', () => {
140 const u = uri('d1'); seedTimeline(u);
141 AP.markLiked('me', u);
142 assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
143 assert.equal(AP.getMyReactions('me', u).liked, false,
144 'de primitief raakt de tussentabel niet -- daarvoor is setReaction');
145});
146
147test('HUIDIG GEDRAG: de interact-route vult de kolom niet', () => {
148 const u = uri('d2'); seedTimeline(u);
149 AP.setMyReaction('me', u, 'like', true);
150 assert.equal(AP.getMyReactions('me', u).liked, true, 'de tussentabel staat aan');
151 assert.equal(AP.getTimelineReaction('me', u).liked, false,
152 'de primitief raakt de kolom niet -- daarvoor is setReaction');
153});
154
155test('HUIDIG GEDRAG: alleen het C2S-pad schrijft allebei', () => {
156 // Wat ingestOutboxActivity sinds Klonkt 04aca12 doet, hier nagebootst zonder
157 // het netwerk: setMyReaction + markLiked. Dit is het enige pad dat de twee
158 // bronnen gelijk houdt, en precies daarom werkt un-liken vanuit een app wel.
159 const u = uri('d3'); seedTimeline(u);
160 AP.setMyReaction('me', u, 'like', true);
161 AP.markLiked('me', u);
162 assert.equal(AP.getMyReactions('me', u).liked, true);
163 assert.equal(AP.getTimelineReaction('me', u).liked, true);
164});
165
166// ── De scheefheid, meetbaar (fase 0 uit shaer-9e9 als test) ─────────────
167
168test('meetbaar: hoeveel rijen hebben een vlag zonder tegenhanger', () => {
169 // Dezelfde query als fase 0 in shaer-9e9, hier als test zodat de refactor
170 // hem kan gebruiken: na fase 1 hoort dit getal 0 te zijn en te blijven.
171 const scheef = db.prepare(`
172 SELECT COUNT(*) AS n FROM ap_timeline t
173 WHERE (t.liked = 1 OR t.boosted = 1)
174 AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
175 WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n;
176 assert.ok(scheef > 0, 'vandaag lopen ze uiteen; na fase 1 hoort deze assert omgedraaid te worden naar === 0');
177});
178
179// ── De invariant van fase 1: setReaction houdt de bronnen gelijk ─────────
180
181test('setReaction: een like landt in BEIDE bronnen', () => {
182 const u = uri('sr1'); seedTimeline(u);
183 AP.setReaction('me', u, 'like', true);
184 assert.equal(AP.getMyReactions('me', u).liked, true, 'tussentabel');
185 assert.equal(AP.getTimelineReaction('me', u).liked, true, 'afgeleide vlag');
186 AP.setReaction('me', u, 'like', false);
187 assert.equal(AP.getMyReactions('me', u).liked, false);
188 assert.equal(AP.getTimelineReaction('me', u).liked, false);
189});
190
191test('setReaction: een boost met note trekt de post je tijdlijn in EN vult beide', () => {
192 // De valkuil uit het plan: zonder de note bestaat de rij niet en landt de
193 // vlag nergens, dus zou de boost onvindbaar zijn.
194 const u = uri('sr2');
195 AP.setReaction('me', u, 'boost', true, {
196 note: { object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo', content: '<p>x</p>', media: '[]' },
197 });
198 assert.equal(AP.getMyReactions('me', u).boosted, true, 'tussentabel');
199 assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'afgeleide vlag');
200 assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en zichtbaar in de Cirkel');
201 AP.setReaction('me', u, 'boost', false);
202 assert.equal(AP.getMyReactions('me', u).boosted, false);
203 assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
204});
205
206test('setReaction: flagUri bepaalt de sleutel voor BEIDE bronnen', () => {
207 // Vroeger kreeg de tussentabel de URI die de client stuurde en de vlag de
208 // opgeloste. Nu is het er één, anders is dezelfde like onvindbaar vanaf een
209 // pagina die de andere URI kent.
210 const gestuurd = 'https://r.test/@anna/123';
211 const opgelost = uri('sr3');
212 seedTimeline(opgelost);
213 AP.setReaction('me', gestuurd, 'like', true, { flagUri: opgelost });
214 assert.equal(AP.getReaction('me', opgelost).liked, true, 'onder de opgeloste uri');
215 assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'en de vlag ook');
216 assert.equal(AP.getMyReactions('me', gestuurd).liked, false, 'niet meer onder de gestuurde');
217});
218
219test('setReaction: rommelige invoer doet niets in plaats van iets halfs', () => {
220 AP.setReaction('me', uri('sr4'), 'sterretje', true);
221 assert.equal(AP.getMyReactions('me', uri('sr4')).liked, false);
222 AP.setReaction('', uri('sr5'), 'like', true);
223 AP.setReaction('me', '', 'like', true);
224 assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
225});
226
227// ── Fase 2: lezen komt uit de tussentabel ────────────────────────────────
228
229test('getReaction leest de tussentabel, niet de kolom', () => {
230 const u = uri('g1'); seedTimeline(u);
231 AP.setReaction('me', u, 'like', true);
232 assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: false });
233 AP.setReaction('me', u, 'boost', true);
234 assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: true });
235});
236
237test('WAAROM DE BACKFILL EERST MOET: een kale kolomvlag is onzichtbaar voor getReaction', () => {
238 // Dit is de reden dat scripts/backfill-reactions.mjs tussen fase 1 en 2 hoort.
239 // Een reactie van vóór fase 1 staat alleen in de kolom; zodra de lezers de
240 // tussentabel volgen is die stil verdwenen -- geen fout, geen spoor.
241 const u = uri('g2'); seedTimeline(u);
242 AP.markLiked('me', u); // zoals de oude tijdlijn-route
243 assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
244 assert.equal(AP.getReaction('me', u).liked, false, 'maar het nieuwe leespad ziet hem niet');
245 // Wat de backfill doet:
246 AP.setMyReaction('me', u, 'like', true);
247 assert.equal(AP.getReaction('me', u).liked, true, 'na aanvullen wel');
248});
249
250test('getReactionsFor haalt een hele pagina in één keer op', () => {
251 const a = uri('g3'), b = uri('g4'), c = uri('g5');
252 seedTimeline(a); seedTimeline(b);
253 AP.setReaction('me', a, 'like', true);
254 AP.setReaction('me', b, 'boost', true);
255 const m = AP.getReactionsFor('me', [a, b, c]);
256 assert.equal(m.get(a).liked, true);
257 assert.equal(m.get(b).boosted, true);
258 assert.equal(m.get(c), undefined, 'wie niets heeft komt niet in de map; de aanroeper valt terug op false');
259 // Dezelfde uitkomst als per stuk vragen, zodat de batch geen eigen waarheid wordt.
260 for (const u of [a, b]) assert.deepEqual(m.get(u), AP.getReaction('me', u));
261});
262
263test('getReactionsFor: lege of rommelige invoer geeft een lege map', () => {
264 assert.equal(AP.getReactionsFor('me', []).size, 0);
265 assert.equal(AP.getReactionsFor('me', null).size, 0);
266 assert.equal(AP.getReactionsFor('', [uri('g3')]).size, 0);
267});
268
269// ── Fase 3: het publieke oppervlak is versmald ───────────────────────────
270
271test('de primitieven zijn niet meer bereikbaar via het service-object', async () => {
272 // Routes doen `import ActivityPubService from ...` en werken dus met het
273 // default-object. Zolang markLiked daar in staat, kan een aanroeper de helft
274 // schrijven -- en dat is niet hypothetisch: precies zo bleef shaer:liked
275 // maandenlang false. De named exports blijven bestaan voor intern gebruik en
276 // voor deze tests.
277 const svc = (await import('../src/services/ActivityPubService.js')).default;
278 for (const naam of ['markLiked', 'unmarkLiked', 'markBoosted', 'unmarkBoosted',
279 'setMyReaction', 'getMyReactions', 'getTimelineReaction']) {
280 assert.equal(svc[naam], undefined, `${naam} hoort niet op het publieke oppervlak te staan`);
281 }
282 // Wat er WEL hoort te staan: het ene schrijfpad en het ene leespad.
283 for (const naam of ['setReaction', 'getReaction', 'getReactionsFor']) {
284 assert.equal(typeof svc[naam], 'function', `${naam} hoort er wel te zijn`);
285 }
286});
287
288// ── De permalink en de object-URI zijn dezelfde post ─────────────────────
289
290test('een like uit de Krant is zichtbaar op de interact-pagina (Robins melding)', () => {
291 // De Krant kent een post als AP-object-URI, de interact-pagina als permalink.
292 // Zochten die twee in verschillende sleutelruimtes, dan toonde een geboost en
293 // geliket bericht daar geen enkele highlight -- terwijl de reactie bestond.
294 const obj = uri('perm1');
295 const permalink = 'https://sound-fabrics.com/effortlesseffect';
296 db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
297 VALUES (?,?,?,?,?,?,?)`)
298 .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
299
300 AP.setReaction('me', obj, 'like', true); // zoals de Krant het doet
301 AP.setReaction('me', obj, 'boost', true);
302 // en de interact-pagina vraagt het met de permalink:
303 assert.deepEqual(AP.getReaction('me', permalink), { liked: true, boosted: true });
304});
305
306test('andersom net zo: reageren via de permalink landt op de object-URI', () => {
307 const obj = uri('perm2');
308 const permalink = 'https://sound-fabrics.com/tweede';
309 db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
310 VALUES (?,?,?,?,?,?,?)`)
311 .run(obj, 'me', 'https://r.test/users/bo', 'Bo', '<p>y</p>', permalink, '2026-08-06 09:00:00');
312
313 AP.setReaction('me', permalink, 'like', true); // zoals de interact-pagina het doet
314 assert.equal(AP.getReaction('me', obj).liked, true, 'onder de object-uri opgeslagen');
315 assert.equal(AP.getTimelineReaction('me', obj).liked, true, 'dus de vlag landt ook goed');
316 assert.equal(AP.getReaction('me', permalink).liked, true, 'en blijft vindbaar via de permalink');
317});
318
319test('een reactie op iets buiten je tijdlijn blijft gewoon werken', () => {
320 // Kennen we de post niet, dan is er niets te canoniseren en blijft de invoer
321 // de sleutel. Een like op een vreemde post mag daar niet op stuklopen.
322 const onbekend = 'https://elders.test/notes/xyz';
323 AP.setReaction('me', onbekend, 'like', true);
324 assert.equal(AP.getReaction('me', onbekend).liked, true);
325});
326
327// ── De migratie bij boot ─────────────────────────────────────────────────
328
329test('migrateReactions hersleutelt een permalink-rij naar de object-URI', () => {
330 // De oude interact-route bewaarde de URI waarmee je binnenkwam, en de
331 // bookmarklet geeft de permalink door. Zonder hersleutelen zijn die rijen
332 // wees zodra er op de object-URI gezocht wordt.
333 const obj = uri('mig1');
334 const permalink = 'https://sound-fabrics.com/oud-artikel';
335 db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
336 VALUES (?,?,?,?,?,?,?)`)
337 .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
338 db.prepare("INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at) VALUES (?,?,?,?)")
339 .run('me', permalink, 'like', '2026-07-01 12:00:00');
340
341 const uitkomst = AP.migrateReactions({ force: true });
342 assert.ok(uitkomst.hersleuteld >= 1);
343 assert.equal(AP.getReaction('me', obj).liked, true, 'nu vindbaar op de object-uri');
344 const rij = db.prepare('SELECT created_at FROM ap_my_reactions WHERE site_slug=? AND target_uri=? AND kind=?').get('me', obj, 'like');
345 assert.equal(rij.created_at, '2026-07-01 12:00:00', 'bij hersleutelen blijft de oorspronkelijke datum staan');
346 const oud = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE target_uri=?').get(permalink).n;
347 assert.equal(oud, 0, 'de permalink-rij is opgeruimd');
348});
349
350test('migrateReactions vult een kale kolomvlag aan', () => {
351 const u = uri('mig2'); seedTimeline(u);
352 AP.markLiked('me', u); // zoals de oude Krant-route
353 assert.equal(AP.getReaction('me', u).liked, false, 'vooraf onzichtbaar');
354 AP.migrateReactions({ force: true });
355 assert.equal(AP.getReaction('me', u).liked, true, 'daarna zichtbaar');
356});
357
358test('migrateReactions is idempotent en respecteert de versievlag', () => {
359 const eerste = AP.migrateReactions({ force: true });
360 const tweede = AP.migrateReactions({ force: true });
361 assert.equal(tweede.hersleuteld, 0, 'niets meer te hersleutelen');
362 assert.equal(tweede.aangevuld, 0, 'niets meer aan te vullen');
363 assert.ok(eerste.hersleuteld >= 0);
364 // De vlag is VERSIEgebonden, en niet aan één vast getal: een oudere vlag laat
365 // de migratie opnieuw lopen -- zo doet een bump als v2 zijn werk -- en een
366 // gelijke of hogere slaat hem over. Een hardgecodeerd versienummer hier zou bij
367 // elke bump omvallen zonder dat er iets stuk is.
368 const vlag = (v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?,?)').run('reactions_migration_version', v);
369 vlag('1');
370 assert.equal(AP.migrateReactions().overgeslagen, false, 'een oudere vlag laat hem opnieuw lopen');
371 vlag('999');
372 assert.equal(AP.migrateReactions().overgeslagen, true, 'een gelijke of hogere vlag slaat over');
373});
374
375test('migrateReactions --dry-run schrijft niets', () => {
376 const u = uri('mig3'); seedTimeline(u);
377 AP.markLiked('me', u);
378 const telling = AP.migrateReactions({ force: true, dryRun: true });
379 assert.ok(telling.aangevuld >= 1, 'hij ziet wel wat er te doen is');
380 assert.equal(AP.getReaction('me', u).liked, false, 'maar heeft niets geschreven');
381});
382
383// ── De weergave leest dezelfde bron als de knop ──────────────────────────
384
385test('getTimeline tekent liked/boosted uit de tussentabel, niet uit de kolom', () => {
386 // De Krant tekende zijn knoppen op ap_timeline.liked/boosted terwijl de
387 // toggle al uit getReaction besliste. Tekenen en beslissen leunden dus op
388 // verschillende bronnen -- ze waren het alleen eens zolang iets ze gelijk
389 // hield. Hier wordt de kolom expres tegengesteld gezet.
390 const u = uri('tl1'); seedTimeline(u);
391 AP.setReaction('me', u, 'like', true);
392 db.prepare('UPDATE ap_timeline SET liked = 0, boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
393
394 const rij = AP.getTimeline('me', 50, 0).find((r) => r.id === u);
395 assert.equal(rij.liked, true, 'de tussentabel zegt geliket, dus de knop hoort aan');
396 assert.equal(rij.boosted, false, 'en de kolom die iets anders beweert telt niet');
397});
398
399test('de Cirkel selecteert op de tussentabel', () => {
400 const u = uri('tl2'); seedTimeline(u);
401 // Kolom aan, tussentabel leeg: hoort NIET in de Cirkel te staan.
402 db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
403 assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
404 'een kale kolomvlag trekt niets meer de Cirkel in');
405 // En andersom: tussentabel aan, kolom uit.
406 db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?').run('me', u);
407 AP.setMyReaction('me', u, 'boost', true);
408 const post = AP.getCirkelPosts('me', 60, 0).find((p) => p.id === u);
409 assert.ok(post, 'de tussentabel bepaalt wat er in de Cirkel staat');
410 assert.ok(post.boosted, 'en het veld dat de view tekent komt daar ook vandaan');
411});
412
413test('boostedCount telt de tussentabel, en alleen wat in je tijdlijn staat', () => {
414 const inTl = uri('tl3'), buiten = 'https://elders.test/notes/qqq';
415 seedTimeline(inTl);
416 const voor = AP.boostedCount('me');
417 AP.setMyReaction('me', inTl, 'boost', true);
418 AP.setMyReaction('me', buiten, 'boost', true);
419 assert.equal(AP.boostedCount('me'), voor + 1, 'alleen de post die in je tijdlijn staat telt mee');
420});
Note: See TracBrowser for help on using the repository browser.