| 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 |
|
|---|
| 28 | import { test } from 'node:test';
|
|---|
| 29 | import assert from 'node:assert/strict';
|
|---|
| 30 |
|
|---|
| 31 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 32 | process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
|
|---|
| 33 |
|
|---|
| 34 | const dbMod = await import('../src/config/database.js');
|
|---|
| 35 | const db = dbMod.default;
|
|---|
| 36 | dbMod.initializeDatabase();
|
|---|
| 37 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 38 |
|
|---|
| 39 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 40 | .run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 41 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
|
|---|
| 42 |
|
|---|
| 43 | const uri = (n) => `https://r.test/ap/notes/${n}`;
|
|---|
| 44 |
|
|---|
| 45 | /** Een post van iemand anders in JOUW tijdlijn-cache. */
|
|---|
| 46 | function 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 |
|
|---|
| 54 | test('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 |
|
|---|
| 63 | test('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 |
|
|---|
| 80 | test('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 |
|
|---|
| 91 | test('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 |
|
|---|
| 102 | test('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 |
|
|---|
| 112 | test('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 |
|
|---|
| 139 | test('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 |
|
|---|
| 147 | test('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 |
|
|---|
| 155 | test('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 |
|
|---|
| 168 | test('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 |
|
|---|
| 181 | test('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 |
|
|---|
| 191 | test('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 |
|
|---|
| 206 | test('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 |
|
|---|
| 219 | test('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 |
|
|---|
| 229 | test('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 |
|
|---|
| 237 | test('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 |
|
|---|
| 250 | test('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 |
|
|---|
| 263 | test('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 |
|
|---|
| 271 | test('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 |
|
|---|
| 290 | test('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 |
|
|---|
| 306 | test('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 |
|
|---|
| 319 | test('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 |
|
|---|
| 329 | test('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 |
|
|---|
| 350 | test('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 |
|
|---|
| 358 | test('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 |
|
|---|
| 375 | test('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 |
|
|---|
| 385 | test('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 |
|
|---|
| 399 | test('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 |
|
|---|
| 413 | test('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 | });
|
|---|