| 1 | // Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso).
|
|---|
| 2 | //
|
|---|
| 3 | // De oude lezing gaf 60 berichten over ALLE gesprekken samen. Deze toetsen
|
|---|
| 4 | // leggen vast wat daaraan mis was en niet meer mag terugkomen: dat een druk
|
|---|
| 5 | // gesprek de rest wegdrukt, dat iemand daardoor uit de hemel verdwijnt, en dat
|
|---|
| 6 | // jouw kant en hun kant apart afgekapt werden.
|
|---|
| 7 | //
|
|---|
| 8 | // In-memory SQLite. Run: npm test
|
|---|
| 9 | import { test } from 'node:test';
|
|---|
| 10 | import assert from 'node:assert/strict';
|
|---|
| 11 |
|
|---|
| 12 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 13 | process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
|
|---|
| 14 |
|
|---|
| 15 | const dbMod = await import('../src/config/database.js');
|
|---|
| 16 | const db = dbMod.default;
|
|---|
| 17 | dbMod.initializeDatabase();
|
|---|
| 18 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 19 |
|
|---|
| 20 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 21 | .run('u1', 'u1', 'u1@t', 'x', 'god');
|
|---|
| 22 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
|
|---|
| 23 |
|
|---|
| 24 | const TANTE = 'https://elders/u/tante';
|
|---|
| 25 | const OMA = 'https://elders/u/oma';
|
|---|
| 26 | const VREEMDE = 'https://elders/u/vreemde';
|
|---|
| 27 |
|
|---|
| 28 | const arrived = (uri, van, stamp) =>
|
|---|
| 29 | db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, content, published)
|
|---|
| 30 | VALUES ('kind', ?, ?, 'iemand', '<p>hoi</p>', ?)`).run(uri, van, stamp);
|
|---|
| 31 | const sent = (id, naar, stamp, visibility = 'direct') =>
|
|---|
| 32 | db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, to_actor, to_actors, content, visibility, created_at)
|
|---|
| 33 | VALUES (?, 'kind', 'p1', ?, ?, '<p>terug</p>', ?, ?)`)
|
|---|
| 34 | .run(id, naar, JSON.stringify([naar]), visibility, stamp);
|
|---|
| 35 |
|
|---|
| 36 | // Een DRUK gesprek met oma: genoeg om in de oude lezing de rest weg te drukken.
|
|---|
| 37 | for (let i = 0; i < 80; i++) arrived(`https://elders/n/oma-${i}`, OMA, `2026-08-09T10:${String(i % 60).padStart(2, '0')}:00Z`);
|
|---|
| 38 | // En een stil gesprek met tante, ouder dan al die drukte.
|
|---|
| 39 | arrived('https://elders/n/tante-1', TANTE, '2026-08-01T09:00:00Z');
|
|---|
| 40 | sent('mijn-1', TANTE, '2026-08-01T09:30:00Z');
|
|---|
| 41 | // Een PUBLIEK antwoord aan een vreemde is geen gesprek.
|
|---|
| 42 | sent('mijn-publiek', VREEMDE, '2026-08-09T12:00:00Z', 'public');
|
|---|
| 43 |
|
|---|
| 44 | // ── De vorm van een stempel ───────────────────────────────────────────────
|
|---|
| 45 | //
|
|---|
| 46 | // Barts melding (14-8): in Berichten stond een bericht van 00:30 boven een
|
|---|
| 47 | // antwoord van 20:22 de avond ervoor, op iOS en op Android. De oorzaak zat
|
|---|
| 48 | // niet in de clients maar hier: de twee poten van de unie leverden een ANDERE
|
|---|
| 49 | // VORM. SQLite's CURRENT_TIMESTAMP schrijft '2026-08-13 22:30:12', een object
|
|---|
| 50 | // draagt '2026-08-13T20:22:00Z', en soms met milliseconden erbij. Als tekst
|
|---|
| 51 | // vergeleken staat op plek 10 een spatie tegen een T, en een spatie is kleiner
|
|---|
| 52 | // -- dus stond binnen dezelfde dag alles wat jij stuurde vóór alles wat
|
|---|
| 53 | // binnenkwam.
|
|---|
| 54 | //
|
|---|
| 55 | // De toetsen hierboven gebruikten overal dezelfde ISO-vorm en konden hier dus
|
|---|
| 56 | // niet op falen. Deze zet ze door elkaar, precies zoals de echte database.
|
|---|
| 57 | //
|
|---|
| 58 | // Eigen site, want de toetsen hierboven tellen ALLE gesprekken van 'kind' en
|
|---|
| 59 | // een gezicht erbij zou ze laten vallen om een reden die er niets mee te maken
|
|---|
| 60 | // heeft.
|
|---|
| 61 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
|
|---|
| 62 | const BUURMAN = 'https://elders/u/buurman';
|
|---|
| 63 | const kwam = (uri, stamp) =>
|
|---|
| 64 | db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, content, published)
|
|---|
| 65 | VALUES ('buur', ?, ?, 'buurman', '<p>hoi</p>', ?)`).run(uri, BUURMAN, stamp);
|
|---|
| 66 | const ging = (id, stamp) =>
|
|---|
| 67 | db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, to_actor, to_actors, content, visibility, created_at)
|
|---|
| 68 | VALUES (?, 'buur', 'p1', ?, ?, '<p>terug</p>', 'direct', ?)`)
|
|---|
| 69 | .run(id, BUURMAN, JSON.stringify([BUURMAN]), stamp);
|
|---|
| 70 |
|
|---|
| 71 | kwam('https://elders/n/buur-mid', '2026-08-13T14:17:45.000Z');
|
|---|
| 72 | ging('mijn-buur-1', '2026-08-13 16:16:00');
|
|---|
| 73 | kwam('https://elders/n/buur-avond', '2026-08-13T20:22:00Z');
|
|---|
| 74 | ging('mijn-buur-2', '2026-08-13 22:30:12');
|
|---|
| 75 |
|
|---|
| 76 | test('vormen door elkaar sorteren op de KLOK, niet op hun schrijfwijze', () => {
|
|---|
| 77 | const talk = AP.conversationHistory('buur', BUURMAN, { limit: 60 });
|
|---|
| 78 | assert.deepEqual(talk.rows.map((r) => r.ref), [
|
|---|
| 79 | 'mijn-buur-2', // 22:30
|
|---|
| 80 | 'https://elders/n/buur-avond', // 20:22
|
|---|
| 81 | 'mijn-buur-1', // 16:16
|
|---|
| 82 | 'https://elders/n/buur-mid', // 14:17
|
|---|
| 83 | ], 'nieuwste eerst, om en om -- niet eerst al het uitgaande');
|
|---|
| 84 | });
|
|---|
| 85 |
|
|---|
| 86 | test('de stempel komt er in EEN vorm uit, want de client rekent ermee', () => {
|
|---|
| 87 | // new Date('2026-08-13 22:30:12') leest in JavaScript als LOKALE tijd en
|
|---|
| 88 | // '...T22:30:12Z' als UTC. Dezelfde rij gaf dus een leeftijd die per vorm
|
|---|
| 89 | // uren verschilde, en daar hangt in de hemel de afstand tot het midden aan.
|
|---|
| 90 | const talk = AP.conversationHistory('buur', BUURMAN, { limit: 60 });
|
|---|
| 91 | for (const r of talk.rows) {
|
|---|
| 92 | assert.match(r.stamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, `stempel ${r.stamp}`);
|
|---|
| 93 | }
|
|---|
| 94 | });
|
|---|
| 95 |
|
|---|
| 96 | test('de cursor blijft werken over de vormen heen', () => {
|
|---|
| 97 | const eerste = AP.conversationHistory('buur', BUURMAN, { limit: 2 });
|
|---|
| 98 | assert.equal(eerste.more, true);
|
|---|
| 99 | const tweede = AP.conversationHistory('buur', BUURMAN, { limit: 2, before: eerste.oldest });
|
|---|
| 100 | assert.deepEqual(
|
|---|
| 101 | [...eerste.rows, ...tweede.rows].map((r) => r.ref),
|
|---|
| 102 | ['mijn-buur-2', 'https://elders/n/buur-avond', 'mijn-buur-1', 'https://elders/n/buur-mid'],
|
|---|
| 103 | 'twee pagina\'s samen zijn hetzelfde gesprek',
|
|---|
| 104 | );
|
|---|
| 105 | });
|
|---|
| 106 |
|
|---|
| 107 | test('de oude lezing verliest tante achter een druk gesprek -- dat is de bug', () => {
|
|---|
| 108 | const oud = AP.getDirectMessages('kind', 60);
|
|---|
| 109 | assert.equal(oud.length, 60);
|
|---|
| 110 | assert.ok(!oud.some((m) => m.actor_uri === TANTE), 'tante valt buiten de 60');
|
|---|
| 111 | });
|
|---|
| 112 |
|
|---|
| 113 | test('een rij per tegenpartij, hoe druk de drukste ook is', () => {
|
|---|
| 114 | const heads = AP.conversationHeads('kind');
|
|---|
| 115 | assert.deepEqual([...new Set(heads.map((k) => k.other))], [OMA, TANTE], 'nieuwste gesprek eerst, allebei aanwezig');
|
|---|
| 116 | });
|
|---|
| 117 |
|
|---|
| 118 | test('is het nieuwste bericht van MIJ, dan gaat dat van hem ook mee', () => {
|
|---|
| 119 | // Anders draagt het kopje mijn byline en zoekt de hemel de naam en het
|
|---|
| 120 | // gezicht van de ander tevergeefs -- die viel dan terug op het staartje van
|
|---|
| 121 | // de actor-uri. Bij tante is mijn antwoord het nieuwste.
|
|---|
| 122 | const vanTante = AP.conversationHeads('kind').filter((k) => k.other === TANTE);
|
|---|
| 123 | assert.equal(vanTante.length, 2);
|
|---|
| 124 | assert.deepEqual(vanTante.map((k) => k.direction).sort(), ['in', 'out']);
|
|---|
| 125 | // Bij oma is het nieuwste al van haar: dan is een rij genoeg.
|
|---|
| 126 | assert.equal(AP.conversationHeads('kind').filter((k) => k.other === OMA).length, 1);
|
|---|
| 127 | });
|
|---|
| 128 |
|
|---|
| 129 | test('een publiek antwoord is geen gesprek en wordt geen gezicht', () => {
|
|---|
| 130 | assert.ok(!AP.conversationHeads('kind').some((k) => k.other === VREEMDE));
|
|---|
| 131 | });
|
|---|
| 132 |
|
|---|
| 133 | test('het gesprek draagt beide kanten onder EEN limiet', () => {
|
|---|
| 134 | const talk = AP.conversationHistory('kind', TANTE, { limit: 60 });
|
|---|
| 135 | assert.equal(talk.rows.length, 2);
|
|---|
| 136 | assert.deepEqual(talk.rows.map((r) => r.direction), ['out', 'in'], 'nieuwste eerst, mijn antwoord bovenaan');
|
|---|
| 137 | assert.equal(talk.more, false);
|
|---|
| 138 | });
|
|---|
| 139 |
|
|---|
| 140 | test('load more: er is een cursor en een eerlijk antwoord op "is er meer"', () => {
|
|---|
| 141 | // De 80 berichten van oma delen stempels (i % 60), en dat is met opzet:
|
|---|
| 142 | // twee berichten in dezelfde seconde is bij DM's een gesprek, geen randgeval.
|
|---|
| 143 | const first = AP.conversationHistory('kind', OMA, { limit: 30 });
|
|---|
| 144 | assert.equal(first.rows.length, 30);
|
|---|
| 145 | assert.equal(first.more, true, 'er is meer, en dat mag de client weten');
|
|---|
| 146 |
|
|---|
| 147 | const second = AP.conversationHistory('kind', OMA, { limit: 30, before: first.oldest });
|
|---|
| 148 | assert.equal(second.rows.length, 30);
|
|---|
| 149 | // Geen overlap en geen gat: de second pagina begint waar de first ophield.
|
|---|
| 150 | const ids = new Set(first.rows.map((r) => r.ref));
|
|---|
| 151 | assert.ok(second.rows.every((r) => !ids.has(r.ref)), 'geen dubbele');
|
|---|
| 152 |
|
|---|
| 153 | const third = AP.conversationHistory('kind', OMA, { limit: 30, before: second.oldest });
|
|---|
| 154 | assert.equal(third.rows.length, 20, 'de staart');
|
|---|
| 155 | assert.equal(third.more, false, 'en dan is het op');
|
|---|
| 156 | // Alles bij elkaar: precies 80, geen dubbele en niets overgeslagen. Met een
|
|---|
| 157 | // cursor op alleen de stempel zou de grensseconde hier stil wegvallen.
|
|---|
| 158 | const allRefs = new Set([...first.rows, ...second.rows, ...third.rows].map((r) => r.ref));
|
|---|
| 159 | assert.equal(allRefs.size, 80, 'elke berichtje precies een keer');
|
|---|
| 160 | });
|
|---|
| 161 |
|
|---|
| 162 | // ── Door de routes heen, want daar wordt de vorm beslist ────────────
|
|---|
| 163 | test('de gesprekslezingen: een rij per persoon, en een gesprek met next', async (t) => {
|
|---|
| 164 | const crypto = await import('crypto');
|
|---|
| 165 | const express = (await import('express')).default;
|
|---|
| 166 | const routes = (await import('../src/routes/activitypub.js')).default;
|
|---|
| 167 |
|
|---|
| 168 | const bearer = 'test-token-' + 'e'.repeat(24);
|
|---|
| 169 | db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
|
|---|
| 170 | .run(crypto.createHash('sha256').update(bearer).digest('base64url'), 'c', 'u1', 'kind', 'read write');
|
|---|
| 171 |
|
|---|
| 172 | const app = express();
|
|---|
| 173 | app.use(routes);
|
|---|
| 174 | const server = app.listen(0);
|
|---|
| 175 | t.after(() => server.close());
|
|---|
| 176 | await new Promise((r) => server.once('listening', r));
|
|---|
| 177 | const get = async (pad) => {
|
|---|
| 178 | const r = await fetch(`http://127.0.0.1:${server.address().port}${pad}`,
|
|---|
| 179 | { headers: { Authorization: `Bearer ${bearer}` } });
|
|---|
| 180 | return { status: r.status, body: await r.json() };
|
|---|
| 181 | };
|
|---|
| 182 | const slug = '/ap/users/kind';
|
|---|
| 183 |
|
|---|
| 184 | const list = await get(`${slug}/conversations`);
|
|---|
| 185 | assert.equal(list.status, 200);
|
|---|
| 186 | // Drie items voor twee mensen: bij tante gaat haar eigen bericht mee omdat
|
|---|
| 187 | // het nieuwste van mij is (anders mist de hemel haar naam en gezicht).
|
|---|
| 188 | assert.equal(list.body.totalItems, 3);
|
|---|
| 189 | assert.ok(!JSON.stringify(list.body).includes(VREEMDE), 'de publieke vreemde is geen gesprek');
|
|---|
| 190 | const vanTante = list.body.orderedItems.filter((i) => i.actor === TANTE);
|
|---|
| 191 | assert.equal(vanTante.length, 1, 'en dat bericht van tante draagt haar byline');
|
|---|
| 192 | assert.ok(vanTante[0].object.attributedTo.name);
|
|---|
| 193 | // De byline zit in de ingesloten actor, net als overal sinds shaer-nmw.
|
|---|
| 194 | assert.ok(list.body.orderedItems.every((i) => i.object.attributedTo));
|
|---|
| 195 |
|
|---|
| 196 | const first = await get(`${slug}/messages?with=${encodeURIComponent(OMA)}&limit=30`);
|
|---|
| 197 | assert.equal(first.body.orderedItems.length, 30);
|
|---|
| 198 | assert.ok(first.body.next, 'er is meer, en de standaardvorm zegt het');
|
|---|
| 199 | assert.ok(first.body.next.includes('limit=30'), 'de paginagrootte reist mee');
|
|---|
| 200 |
|
|---|
| 201 | // De next-link volgen doet wat hij belooft: geen dubbele, en uiteindelijk op.
|
|---|
| 202 | const path2 = first.body.next.replace(/^https?:\/\/[^/]+/, '');
|
|---|
| 203 | const second = await get(path2);
|
|---|
| 204 | const firstIds = new Set(first.body.orderedItems.map((i) => i.object.id));
|
|---|
| 205 | assert.ok(second.body.orderedItems.every((i) => !firstIds.has(i.object.id)), 'geen overlap');
|
|---|
| 206 | const third = await get(second.body.next.replace(/^https?:\/\/[^/]+/, ''));
|
|---|
| 207 | assert.equal(third.body.orderedItems.length, 20);
|
|---|
| 208 | assert.equal(third.body.next, undefined, 'op is op, en dat staat er ook');
|
|---|
| 209 |
|
|---|
| 210 | // Een gesprek draagt beide kanten; het eigen antwoord aan tante hoort erbij.
|
|---|
| 211 | const withAunt = await get(`${slug}/messages?with=${encodeURIComponent(TANTE)}`);
|
|---|
| 212 | assert.equal(withAunt.body.orderedItems.length, 2);
|
|---|
| 213 | assert.ok(withAunt.body.orderedItems.some((i) => i.actor === 'https://klonkt.test/ap/users/kind'), 'mijn eigen kant zit erin');
|
|---|
| 214 |
|
|---|
| 215 | const broken = await get(`${slug}/messages?with=nonsens`);
|
|---|
| 216 | assert.equal(broken.status, 400, 'geen actor-uri, geen gesprek');
|
|---|
| 217 | });
|
|---|