source: Klonkt/test/c2s-messages.test.js@ 08ab8ad

main
Last change on this file since 08ab8ad was 08ab8ad, checked in by Robin <roboburr@…>, 6 weeks ago

De app las alleen de tijdlijn, dus een zwaai kwam nooit aan

Robin vroeg waarom zwaai-acties niet als bericht in Shaer verschijnen. De app
bouwt Berichten, gesprekken en de hulpvraag-escalaties uit een bron: de
C2S-inboxlees plus de eigen outbox. Die lees serveerde alleen ap_timeline, en
een directe note staat in ap_mentions. Dus kwam er niets binnen: geen zwaai,
geen DM, en sinds gisteren ook geen hulpvraag meer.

Tot d9ad6c5 lekten directe notes toevallig in de tijdlijn: de insert vroeg
alleen of het een top-level post was van iemand die je volgt. Dat hebben we
dichtgezet om hulpvragen uit de Krant te houden, en daarmee viel de enige weg
weg waarlangs de app ze binnenkreeg. Je eigen antwoord zag je nog wel, want dat
komt uit je outbox, en daardoor leek het half te werken.

Nu serveert de inboxlees allebei: posts en de directe notes die aan jou gericht
zijn. In dezelfde vorm, dus de client heeft er een parser voor. Met to:[jij] en
een Mention-tag, want zonder die adressering groepeert de app het niet tot een
gesprek, en met shaer:wave en shaer:helpRequest zodat een zwaai er als een
zwaai uitziet.

Onderweg gevonden: SQLite schrijft CURRENT_TIMESTAMP in UTC zonder zone, en
Date.parse leest dat als lokale tijd. Twee uur verschil is genoeg om een
gesprek in de verkeerde volgorde te zetten, dus dat gaat nu door isoStamp.

Changed files:
src/services/ActivityPubService.js

  • getDirectMessages(): de mentions die niet ook een tijdlijnrij zijn, want een publieke mention van iemand die je volgt staat in allebei
  • isoStamp(): een opgeslagen stempel als echt moment
  • stripLeadingMentions op de default export, die had de route nodig

src/routes/activitypub.js

  • de inboxlees serveert posts en berichten in een collectie, nieuwste eerst
  • adressering, wave-vlag, byline en de gated shaer:embed op elk bericht

New file:
test/c2s-messages.test.js

  • de twee tabellen blijven gescheiden, de lees brengt ze samen
  • de zwaai zit erin, is gemarkeerd, en is aan jou gericht
  • een publieke mention komt een keer langs, als post

remarks: 333 tests groen. De clientkant zit in de app-repos.

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

  • Property mode set to 100644
File size: 5.6 KB
Line 
1// What the app reads is what the app can show.
2//
3// Shaer builds Berichten, gesprekken and the help-escalation list from one
4// source: the C2S inbox read. That read served only the timeline, and a direct
5// note (a DM, a guardian's wave, a ward's 🛟) is not in the timeline, because a
6// note addressed to named people is a message and not a post.
7//
8// The result was an app that showed your own replies and nothing that was said
9// to you. These tests hold the two tables apart in the database and together in
10// the read.
11import { test } from 'node:test';
12import assert from 'node:assert/strict';
13
14process.env.DATABASE_PATH = ':memory:';
15process.env.PUBLIC_BASE_URL = 'https://test.example';
16
17const dbMod = await import('../src/config/database.js');
18const db = dbMod.default;
19dbMod.initializeDatabase();
20const AP = (await import('../src/services/ActivityPubService.js')).default;
21
22db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
23 .run('u1', 'u1', 'u1@t', 'x', 'god');
24db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
25
26const mention = (uri, { wave = 0, help = 0, content = '<p>hoi</p>', published = null } = {}) =>
27 db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, actor_handle, content, published, wave, help_request)
28 VALUES ('kid', ?, 'https://oma.test/u/oma', 'Oma', '@oma@oma.test', ?, ?, ?, ?)`)
29 .run(uri, content, published, wave, help);
30
31mention('https://oma.test/n/1', { wave: 1, content: '<p><a href="x">@kid@test.example</a> 👋</p>' });
32mention('https://oma.test/n/2', { help: 1 });
33mention('https://oma.test/n/3');
34// A public mention from someone you follow is stored in BOTH tables. It is a
35// post, and it must not turn up twice.
36mention('https://vriend.test/n/9');
37db.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES (?, 'kid', 'https://vriend.test/u/v', '<p>publiek</p>')`)
38 .run('https://vriend.test/n/9');
39
40test('a direct note is not a timeline row', () => {
41 // The premise. If this ever flips, the app gets its messages back by
42 // accident and the Krant fills up with DMs again (d9ad6c5).
43 assert.equal(AP.getTimeline('kid', 50).length, 1, 'only the public post is a post');
44});
45
46test('the direct notes come out of the message read', () => {
47 const msgs = AP.getDirectMessages('kid', 60);
48 const ids = msgs.map((m) => m.object_uri).sort();
49 assert.deepEqual(ids, ['https://oma.test/n/1', 'https://oma.test/n/2', 'https://oma.test/n/3'],
50 'the three messages, and NOT the note that is already a post');
51 assert.equal(msgs.find((m) => m.object_uri.endsWith('/1')).wave, 1, 'the wave is marked as one');
52 assert.equal(msgs.find((m) => m.object_uri.endsWith('/2')).help_request, 1, 'and the buoy as a buoy');
53});
54
55test('a stored stamp becomes an instant, not a two-hour lie', () => {
56 // SQLite writes CURRENT_TIMESTAMP as 'YYYY-MM-DD HH:MM:SS' in UTC, which
57 // Date.parse reads as local time. On this server that is off by an hour or
58 // two, which is enough to scramble the order of a conversation.
59 assert.equal(AP.isoStamp('2026-07-29 08:15:00'), '2026-07-29T08:15:00Z');
60 assert.equal(AP.isoStamp('2026-07-29T08:15:00.000Z'), '2026-07-29T08:15:00.000Z');
61 assert.equal(AP.isoStamp(null), undefined);
62 assert.equal(AP.isoStamp('nonsense'), undefined);
63});
64
65test('the inbox read serves posts and messages in one collection', async (t) => {
66 // Straight through the route, because the mapping is where the app-facing
67 // shape is decided: the addressing that turns a note into a conversation and
68 // the flag that turns one into a wave.
69 const crypto = await import('crypto');
70 const express = (await import('express')).default;
71 const routes = (await import('../src/routes/activitypub.js')).default;
72
73 const bearer = 'test-token-' + 'a'.repeat(24);
74 const hash = crypto.createHash('sha256').update(bearer).digest('base64url');
75 db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
76 .run(hash, 'test-client', 'u1', 'kid', 'read write');
77
78 const app = express();
79 app.use(routes);
80 const server = app.listen(0);
81 t.after(() => server.close());
82 await new Promise((r) => server.once('listening', r));
83 const url = `http://127.0.0.1:${server.address().port}/ap/users/kid/inbox`;
84 const doc = await (await fetch(url, { headers: { Authorization: `Bearer ${bearer}` } })).json();
85
86 const byId = Object.fromEntries(doc.orderedItems.map((i) => [i.object.id, i.object]));
87 assert.equal(doc.orderedItems.length, 4, 'one post and three messages, the double counted once');
88
89 const wave = byId['https://oma.test/n/1'];
90 assert.ok(wave, 'the wave is in the read at all (this is the whole bug)');
91 assert.equal(wave['shaer:wave'], true, 'and it says it is a wave');
92 assert.deepEqual(wave.to, ['https://test.example/ap/users/kid'],
93 'addressed to me, which is what makes it a conversation instead of a loose note');
94 assert.ok((wave.tag || []).some((x) => x.type === 'Mention' && x.href === 'https://test.example/ap/users/kid'),
95 'with a Mention the client recognises itself in');
96 assert.equal(wave['shaer:author'].name, 'Oma', 'and a byline to show');
97 assert.ok(!/@kid@test\.example/.test(wave.content), 'the leading @mention is stripped, like Berichten on the web');
98
99 assert.equal(byId['https://oma.test/n/2']['shaer:helpRequest'], true, 'the buoy stays a buoy');
100 assert.equal(byId['https://oma.test/n/3']['shaer:wave'], undefined, 'an ordinary DM claims to be neither');
101 assert.equal(byId['https://vriend.test/n/9']['shaer:wave'], undefined, 'and the public post is served once, as a post');
102});
Note: See TracBrowser for help on using the repository browser.