source: Klonkt/test/conversations.test.js@ 2365630

main
Last change on this file since 2365630 was 2365630, checked in by roboburr <roboburr@…>, 4 weeks ago

Een stempel in een vorm, anders sorteert een gesprek op schrijfwijze

Bart, 14-8: in Berichten stond een bericht van 00:30 boven een antwoord van
20:22 de avond ervoor -- op iOS en op Android allebei, en dat wees al naar hier
in plaats van naar de clients.

De unie achter een gesprek haalde haar stempel uit twee bronnen met een andere
VORM. SQLite's CURRENT_TIMESTAMP schrijft '2026-08-13 22:30:12', een binnenkomend
object draagt '2026-08-13T20:22:00Z', en soms met milliseconden erbij. Alle drie
tekst, dus vergeleken als tekst -- en op plek 10 staat een spatie tegen een T.
Een spatie is kleiner, dus sorteerde binnen dezelfde dag ALLES wat jij stuurde
vóór alles wat binnenkwam, hoe laat het ook was.

Barts eigen geval precies: 00:30 lokaal is 22:30 UTC op de 13e, en dat kwam
daarmee vóór een antwoord van 20:22Z.

strftime leest alle drie en geeft er een vorm voor terug, in UTC. Lukt dat niet,
dan blijft de rauwe waarde staan: dan is die ene rij verkeerd gesorteerd in
plaats van de hele lijst.

Dit ging ook de clients aan zonder dat iemand het zag. new Date('2026-08-13
22:30:12') leest in JavaScript als LOKALE tijd en de T-Z-vorm als UTC -- dezelfde
rij gaf dus een leeftijd die uren verschilde per vorm, en daar hangt in de hemel
de afstand tot het midden aan.

De toetsen hierboven gebruikten overal dezelfde ISO-vorm en konden hier dus niet
op falen. De drie nieuwe zetten de vormen door elkaar zoals de echte database
dat doet, op een eigen site zodat ze de tellingen van de andere niet verstoren.
Nagegaan dat ze ook echt kunnen falen: met de normalisering eruit vallen ze
alle drie om.

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

  • Property mode set to 100644
File size: 11.3 KB
Line 
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
9import { test } from 'node:test';
10import assert from 'node:assert/strict';
11
12process.env.DATABASE_PATH = ':memory:';
13process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
14
15const dbMod = await import('../src/config/database.js');
16const db = dbMod.default;
17dbMod.initializeDatabase();
18const AP = await import('../src/services/ActivityPubService.js');
19
20db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
21 .run('u1', 'u1', 'u1@t', 'x', 'god');
22db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'kind', 'Kind', 'u1');
23
24const TANTE = 'https://elders/u/tante';
25const OMA = 'https://elders/u/oma';
26const VREEMDE = 'https://elders/u/vreemde';
27
28const 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);
31const 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.
37for (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.
39arrived('https://elders/n/tante-1', TANTE, '2026-08-01T09:00:00Z');
40sent('mijn-1', TANTE, '2026-08-01T09:30:00Z');
41// Een PUBLIEK antwoord aan een vreemde is geen gesprek.
42sent('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.
61db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
62const BUURMAN = 'https://elders/u/buurman';
63const 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);
66const 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
71kwam('https://elders/n/buur-mid', '2026-08-13T14:17:45.000Z');
72ging('mijn-buur-1', '2026-08-13 16:16:00');
73kwam('https://elders/n/buur-avond', '2026-08-13T20:22:00Z');
74ging('mijn-buur-2', '2026-08-13 22:30:12');
75
76test('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
86test('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
96test('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
107test('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
113test('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
118test('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
129test('een publiek antwoord is geen gesprek en wordt geen gezicht', () => {
130 assert.ok(!AP.conversationHeads('kind').some((k) => k.other === VREEMDE));
131});
132
133test('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
140test('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 ────────────
163test('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});
Note: See TracBrowser for help on using the repository browser.