source: Klonkt/test/soft-groups.test.js@ 6e9bc70

main
Last change on this file since 6e9bc70 was 6e9bc70, checked in by Bart <bart@…>, 2 days ago

Zachte groepen: een gesprek is de set deelnemers, niet één tegenpartij

Een direct bericht aan Anna en Ben bestond al als groep -- to_actors droeg de
hele set -- maar elke lezing sleutelde op één other. Een uitgaand bericht
aan twee mensen viel in twee gesprekken uiteen, en een binnenkomend bericht
van Anna aan jou en Ben kwam alleen bij Anna terecht: Ben viel eruit, want de
adressering van een binnenkomer werd nergens bewaard.

Nu is er een sleutel, één keer berekend bij het schrijven
(conversation-key.js): voor één ander zijn kale URI, precies zoals het altijd
was, dus bestaande gesprekken, leesmarkeringen en ?with=-URL's blijven geldig
zonder migratie; voor een groep de gesorteerde set, gescheiden door een
spatie. Koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal
via dezelfde unie, en werken daarmee per gesprek zonder dat er in die queries
iets hoefde te veranderen. Oude rijen hebben geen sleutel en lezen zoals ze
altijd lazen.

Alleen directe berichten groeperen: een openbare post aan jou en Ben is geen
besloten gesprek met z'n drieën.

/messages neemt meer dan één with, en elk item draagt shaer:conversation en
shaer:participants. Uitgaande directe notes krijgen een FEP-7888 context --
die van de ouder als het een antwoord is, anders een nieuwe -- die bewaard en
uitgestuurd wordt, maar geen bubbels samenvoegt: een bubbel die Carla toont
terwijl je antwoord haar niet bereikt, zou liegen over wie meeleest.

shaer-mbji
Co-Authored-By: Claude Opus 5.5 <claude@…>

  • Property mode set to 100644
File size: 5.5 KB
Line 
1// Zachte groepen in Berichten (shaer-mbji): een gesprek is de SET deelnemers,
2// niet één tegenpartij.
3import { test } from 'node:test';
4import assert from 'node:assert/strict';
5
6process.env.DATABASE_PATH = ':memory:';
7process.env.PUBLIC_BASE_URL = 'https://test.example';
8
9const dbMod = await import('../src/config/database.js');
10const db = dbMod.default;
11dbMod.initializeDatabase();
12const K = await import('../src/services/conversation-key.js');
13const T = await import('../src/services/ap-timeline.js');
14
15const ME = 'https://test.example/ap/users/me';
16const ANNA = 'https://a.test/users/anna';
17const BEN = 'https://b.test/users/ben';
18const CARLA = 'https://c.test/users/carla';
19const PUB = 'https://www.w3.org/ns/activitystreams#Public';
20
21// ── De sleutel ────────────────────────────────────────────────────
22
23test('één ander is zijn kale URI: bestaande gesprekken veranderen niet', () => {
24 assert.equal(K.conversationKey([ANNA]), ANNA);
25});
26
27test('een groep is gesorteerd, dus {Anna, Ben} en {Ben, Anna} zijn één gesprek', () => {
28 assert.equal(K.conversationKey([BEN, ANNA]), K.conversationKey([ANNA, BEN]));
29 assert.equal(K.conversationKey([ANNA, BEN]), `${ANNA} ${BEN}`);
30 assert.equal(K.conversationKey([ANNA, ANNA, BEN]), `${ANNA} ${BEN}`, 'dubbelen tellen een keer');
31});
32
33test('een publiek of een volgerslijst is geen deelnemer', () => {
34 assert.equal(K.conversationKey([ANNA, PUB, `${ANNA}/followers`]), ANNA);
35});
36
37test('de sleutel valt terug in zijn deelnemers', () => {
38 assert.deepEqual(K.participantsOfKey(`${ANNA} ${BEN}`), [ANNA, BEN]);
39 assert.deepEqual(K.participantsOfKey(ANNA), [ANNA]);
40 assert.equal(K.isGroupKey(`${ANNA} ${BEN}`), true);
41 assert.equal(K.isGroupKey(ANNA), false);
42});
43
44test('de deelnemers van een binnenkomer: afzender plus geadresseerden, zonder mij', () => {
45 const note = { to: [ME, BEN], cc: [PUB] };
46 assert.deepEqual(K.participantsOf(note, ANNA, ME), [ANNA, BEN].sort());
47 // Een gewoon één-op-één-bericht: alleen de afzender blijft over.
48 assert.deepEqual(K.participantsOf({ to: [ME] }, ANNA, ME), [ANNA]);
49 // AS2 staat ook objecten toe in to/cc.
50 assert.deepEqual(K.participantsOf({ to: [{ id: ME }, { id: BEN }] }, ANNA, ME), [ANNA, BEN].sort());
51});
52
53test('context (FEP-7888) is een string of een object met een id', () => {
54 assert.equal(K.contextOf({ context: 'https://a.test/ctx/1' }), 'https://a.test/ctx/1');
55 assert.equal(K.contextOf({ context: { id: 'https://a.test/ctx/2' } }), 'https://a.test/ctx/2');
56 assert.equal(K.contextOf({ context: 'javascript:alert(1)' }), null, 'alleen http(s)');
57 assert.equal(K.contextOf({}), null);
58});
59
60// ── De lezing ─────────────────────────────────────────────────────
61
62let n = 0;
63function binnen(van, conv, stamp) {
64 n += 1;
65 db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, content, published, conv, participants)
66 VALUES ('me', ?, ?, '<p>x</p>', ?, ?, ?)`)
67 .run(`${van}/notes/${n}`, van, stamp, conv, conv ? JSON.stringify(K.participantsOfKey(conv)) : null);
68 return `${van}/notes/${n}`;
69}
70function uit(ontvangers, { conv = K.conversationKey(ontvangers), stamp }) {
71 n += 1;
72 const id = `out-${n}`;
73 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, content, visibility, to_actor, to_actors, conv, created_at)
74 VALUES (?, 'me', '', '<p>x</p>', 'direct', ?, ?, ?, ?)`)
75 .run(id, ontvangers[0], JSON.stringify(ontvangers), conv, stamp);
76 return id;
77}
78
79test('een bericht aan Anna en Ben is één gesprek, niet twee', () => {
80 uit([ANNA, BEN], { stamp: '2026-10-01 10:00:00' });
81 binnen(ANNA, K.conversationKey([ANNA, BEN]), '2026-10-01T10:05:00Z');
82 binnen(ANNA, ANNA, '2026-10-01T09:00:00Z'); // het één-op-één-gesprek bestaat ernaast
83
84 const koppen = T.conversationHeads('me').map((h) => h.other);
85 const groep = K.conversationKey([ANNA, BEN]);
86 assert.ok(koppen.includes(groep), 'de groep heeft een kop');
87 assert.ok(koppen.includes(ANNA), 'het gesprek met alleen Anna blijft bestaan');
88 assert.ok(!koppen.includes(BEN), 'Ben heeft geen los gesprek gekregen van een groepsbericht');
89
90 const draad = T.conversationHistory('me', groep).rows;
91 assert.equal(draad.length, 2, 'mijn bericht en Anna\'s antwoord staan in dezelfde draad');
92 assert.deepEqual(new Set(draad.map((r) => r.direction)), new Set(['in', 'out']));
93});
94
95test('oude uitgaande rijen zonder sleutel lezen zoals ze altijd lazen', () => {
96 // Geen conv: per ontvanger, zoals voor deze wijziging. Geen migratie die de
97 // geschiedenis herschikt.
98 uit([CARLA, BEN], { conv: null, stamp: '2026-09-01 10:00:00' });
99 const koppen = T.conversationHeads('me').map((h) => h.other);
100 assert.ok(koppen.includes(CARLA));
101 assert.ok(koppen.includes(BEN));
102 assert.ok(!koppen.includes(K.conversationKey([BEN, CARLA])), 'een oude rij wordt niet alsnog een groep');
103});
104
105test('ongelezen en gelezen gaan per gesprek, ook voor een groep', () => {
106 const groep = K.conversationKey([ANNA, CARLA]);
107 const eerste = binnen(ANNA, groep, '2026-10-02T10:00:00Z');
108 binnen(CARLA, groep, '2026-10-02T10:01:00Z');
109
110 assert.equal(T.unreadPerConversation('me').get(groep).n, 2);
111 T.markRead('me', eerste);
112 assert.equal(T.unreadPerConversation('me').get(groep).n, 1, 'gelezen tot en met het eerste');
113 // Het één-op-één-gesprek met Anna merkt daar niets van.
114 const losAnna = T.unreadPerConversation('me').get(ANNA);
115 assert.ok(!losAnna || losAnna.n >= 1);
116});
Note: See TracBrowser for help on using the repository browser.