| 1 | // Berichten als gesprekken: antwoorden, mentions en je eigen verzonden
|
|---|
| 2 | // berichten vouwen samen tot draden, de rest van de stroom blijft ongemoeid.
|
|---|
| 3 | // Dit dekt de groepeerlogica zelf (zuiver, geen DB, geen netwerk); het
|
|---|
| 4 | // samenstellen van de stroom zit in getMessages en leunt op de database.
|
|---|
| 5 | //
|
|---|
| 6 | // Run: npm test
|
|---|
| 7 |
|
|---|
| 8 | import { test } from 'node:test';
|
|---|
| 9 | import assert from 'node:assert/strict';
|
|---|
| 10 |
|
|---|
| 11 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 12 | process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
|
|---|
| 13 |
|
|---|
| 14 | const dbMod = await import('../src/config/database.js');
|
|---|
| 15 | dbMod.initializeDatabase();
|
|---|
| 16 | const AP = await import('../src/services/ActivityPubService.js');
|
|---|
| 17 |
|
|---|
| 18 | const { threadKey, groupConversations } = AP;
|
|---|
| 19 |
|
|---|
| 20 | // Aflopend gesorteerd, zoals getMessages ze aanlevert.
|
|---|
| 21 | const ts = (n) => new Date(Date.UTC(2026, 0, 1, 12, n)).toISOString();
|
|---|
| 22 |
|
|---|
| 23 | test('threadKey: een post wint van een persoon', () => {
|
|---|
| 24 | assert.equal(threadKey({ type: 'reply', post_slug: 'hallo', handle: '@anna@a.test' }), 'post:hallo');
|
|---|
| 25 | assert.equal(threadKey({ type: 'sent', post_slug: 'hallo', to_handle: '@anna@a.test' }), 'post:hallo');
|
|---|
| 26 | // Zonder post loopt de draad per tegenpartij, hoofdletter- en @-ongevoelig.
|
|---|
| 27 | assert.equal(threadKey({ type: 'mention', handle: '@Anna@A.test' }), 'actor:anna@a.test');
|
|---|
| 28 | assert.equal(threadKey({ type: 'sent', to_handle: 'Anna@a.test' }), 'actor:anna@a.test');
|
|---|
| 29 | });
|
|---|
| 30 |
|
|---|
| 31 | test('threadKey: alles wat geen gesprek is krijgt geen draad', () => {
|
|---|
| 32 | for (const type of ['like', 'announce', 'follow', 'report', 'poll_done']) {
|
|---|
| 33 | assert.equal(threadKey({ type, post_slug: 'hallo' }), null, `${type} hoort geen draad te krijgen`);
|
|---|
| 34 | }
|
|---|
| 35 | assert.equal(threadKey(null), null);
|
|---|
| 36 | assert.equal(threadKey({ type: 'mention' }), null, 'een mention zonder afzender heeft geen sleutel');
|
|---|
| 37 | });
|
|---|
| 38 |
|
|---|
| 39 | test('ontvangen en verzonden op dezelfde post komen in EEN draad', () => {
|
|---|
| 40 | const out = groupConversations([
|
|---|
| 41 | { type: 'sent', post_slug: 'hallo', content: 'graag gedaan', created_at: ts(3) },
|
|---|
| 42 | { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', name: 'Anna', content: 'dank!', created_at: ts(2) },
|
|---|
| 43 | { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@bo@b.test', name: 'Bo', content: 'mooi', created_at: ts(1) },
|
|---|
| 44 | ]);
|
|---|
| 45 | assert.equal(out.length, 1, 'drie berichten, één draad');
|
|---|
| 46 | const t = out[0];
|
|---|
| 47 | assert.equal(t.type, 'thread');
|
|---|
| 48 | assert.equal(t.count, 3);
|
|---|
| 49 | // De context hoort bij de draad: dit gesprek gaat over een post.
|
|---|
| 50 | assert.deepEqual(t.post, { slug: 'hallo', title: 'Hallo fediverse' });
|
|---|
| 51 | // Een gesprek leest naar beneden: oud → nieuw.
|
|---|
| 52 | assert.deepEqual(t.messages.map((m) => m.content), ['mooi', 'dank!', 'graag gedaan']);
|
|---|
| 53 | // De draad staat op de tijd van zijn NIEUWSTE bericht, zodat een levend
|
|---|
| 54 | // gesprek bovenaan komt.
|
|---|
| 55 | assert.equal(t.created_at, ts(3));
|
|---|
| 56 | // Jij zit er niet bij als deelnemer, de anderen wel.
|
|---|
| 57 | assert.deepEqual(t.people.map((p) => p.name), ['Bo', 'Anna']);
|
|---|
| 58 | assert.equal(t.mine, true);
|
|---|
| 59 | });
|
|---|
| 60 |
|
|---|
| 61 | test('een mention zonder post loopt per persoon, niet per post', () => {
|
|---|
| 62 | const out = groupConversations([
|
|---|
| 63 | { type: 'sent', to_handle: '@cas@c.test', content: 'hoi terug', created_at: ts(2) },
|
|---|
| 64 | { type: 'mention', handle: '@cas@c.test', name: 'Cas', content: 'hoi!', created_at: ts(1) },
|
|---|
| 65 | ]);
|
|---|
| 66 | assert.equal(out.length, 1);
|
|---|
| 67 | assert.equal(out[0].key, 'actor:cas@c.test');
|
|---|
| 68 | assert.equal(out[0].post, null, 'zonder post is er geen context-link');
|
|---|
| 69 | assert.deepEqual(out[0].messages.map((m) => m.content), ['hoi!', 'hoi terug']);
|
|---|
| 70 | });
|
|---|
| 71 |
|
|---|
| 72 | test('twee posts blijven twee draden, op volgorde van hun nieuwste bericht', () => {
|
|---|
| 73 | const out = groupConversations([
|
|---|
| 74 | { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@anna@a.test', created_at: ts(4) },
|
|---|
| 75 | { type: 'reply', post_slug: 'een', post_title: 'Een', handle: '@bo@b.test', created_at: ts(3) },
|
|---|
| 76 | { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@bo@b.test', created_at: ts(2) },
|
|---|
| 77 | ]);
|
|---|
| 78 | assert.deepEqual(out.map((t) => t.key), ['post:twee', 'post:een']);
|
|---|
| 79 | assert.equal(out[0].count, 2);
|
|---|
| 80 | assert.equal(out[1].count, 1);
|
|---|
| 81 | });
|
|---|
| 82 |
|
|---|
| 83 | test('likes, boosts en follows stromen ongemoeid tussen de draden door', () => {
|
|---|
| 84 | const out = groupConversations([
|
|---|
| 85 | { type: 'like', post_slug: 'hallo', handle: '@anna@a.test', created_at: ts(4) },
|
|---|
| 86 | { type: 'reply', post_slug: 'hallo', post_title: 'Hallo', handle: '@bo@b.test', created_at: ts(3) },
|
|---|
| 87 | { type: 'follow', handle: '@cas@c.test', created_at: ts(2) },
|
|---|
| 88 | { type: 'report', handle: '@dee@d.test', created_at: ts(1) },
|
|---|
| 89 | ]);
|
|---|
| 90 | assert.deepEqual(out.map((i) => i.type), ['like', 'thread', 'follow', 'report']);
|
|---|
| 91 | // Een like op dezelfde post trekt die post NIET de draad in: het is geen
|
|---|
| 92 | // gesprek, en Activiteit blijft zijn eigen chip houden.
|
|---|
| 93 | assert.equal(out[0].post_slug, 'hallo');
|
|---|
| 94 | });
|
|---|
| 95 |
|
|---|
| 96 | test('de titel komt van welk bericht in de draad hem ook maar kent', () => {
|
|---|
| 97 | // Een verzonden antwoord kent alleen de slug; een ontvangen antwoord de titel.
|
|---|
| 98 | const out = groupConversations([
|
|---|
| 99 | { type: 'sent', post_slug: 'hallo', created_at: ts(2) },
|
|---|
| 100 | { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', created_at: ts(1) },
|
|---|
| 101 | ]);
|
|---|
| 102 | assert.deepEqual(out[0].post, { slug: 'hallo', title: 'Hallo fediverse' });
|
|---|
| 103 | });
|
|---|
| 104 |
|
|---|
| 105 | test('lege en rommelige invoer levert geen kapotte draden op', () => {
|
|---|
| 106 | assert.deepEqual(groupConversations([]), []);
|
|---|
| 107 | assert.deepEqual(groupConversations(null), []);
|
|---|
| 108 | // Een gesprekssoort zonder sleutel blijft een losse regel in plaats van te
|
|---|
| 109 | // verdwijnen: nooit een bericht kwijtraken in de groepering.
|
|---|
| 110 | const out = groupConversations([{ type: 'sent', content: 'aan niemand', created_at: ts(1) }]);
|
|---|
| 111 | assert.equal(out.length, 1);
|
|---|
| 112 | assert.equal(out[0].type, 'sent');
|
|---|
| 113 | });
|
|---|
| 114 |
|
|---|
| 115 | test('een direct bericht zonder to_handle valt terug op to_actors', () => {
|
|---|
| 116 | // Precies het geval waarin het het onlogischst is dat er geen draad ontstaat:
|
|---|
| 117 | // een gesprek dat JIJ begon. Zonder deze terugval bleef het een losse regel.
|
|---|
| 118 | const key = threadKey({
|
|---|
| 119 | type: 'sent',
|
|---|
| 120 | to_actors: JSON.stringify(['https://a.test/users/anna', 'https://b.test/users/bo']),
|
|---|
| 121 | created_at: ts(1),
|
|---|
| 122 | });
|
|---|
| 123 | assert.equal(key, 'actor:anna@a.test');
|
|---|
| 124 | // Rommel in de kolom mag niets omgooien.
|
|---|
| 125 | assert.equal(threadKey({ type: 'sent', to_actors: 'geen json' }), null);
|
|---|
| 126 | assert.equal(threadKey({ type: 'sent', to_actors: '[]' }), null);
|
|---|
| 127 | });
|
|---|
| 128 |
|
|---|
| 129 | test('to_handle wint van to_actors, zodat een draad niet splitst', () => {
|
|---|
| 130 | const a = threadKey({ type: 'sent', to_handle: '@anna@a.test' });
|
|---|
| 131 | const b = threadKey({ type: 'sent', to_handle: '@anna@a.test', to_actors: JSON.stringify(['https://b.test/users/bo']) });
|
|---|
| 132 | assert.equal(a, b, 'dezelfde tegenpartij hoort dezelfde sleutel te geven');
|
|---|
| 133 | });
|
|---|
| 134 |
|
|---|
| 135 | test('een eigen bericht met media rendert als een post, niet als kale tekst', async () => {
|
|---|
| 136 | // note-body (gedeeld met de Krant) leest media_json met een `type`; ap_outbox
|
|---|
| 137 | // bewaart attachments met een `mediaType`. Zonder vertaling viel een foto die
|
|---|
| 138 | // JIJ meestuurde weg.
|
|---|
| 139 | const db = (await import('../src/config/database.js')).default;
|
|---|
| 140 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 141 | .run('mu', 'mu', 'mu@test', 'x', 'god');
|
|---|
| 142 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('ms', 'media', 'Media', 'mu');
|
|---|
| 143 | db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, to_handle, content, attachments, created_at)
|
|---|
| 144 | VALUES ('om1','media','pm1','een-post','@anna@a.test','<p>kijk</p>',?,'2026-08-06 09:00:00')`)
|
|---|
| 145 | .run(JSON.stringify([{ url: '/media/foto.png', mediaType: 'image/png', name: 'foto' }]));
|
|---|
| 146 |
|
|---|
| 147 | const t = AP.getMessages('media', 50, 0).find((i) => i.type === 'thread');
|
|---|
| 148 | const sent = t.messages.find((m) => m.type === 'sent');
|
|---|
| 149 | assert.deepEqual(JSON.parse(sent.media_json), [{ url: '/media/foto.png', type: 'image/png', name: 'foto' }]);
|
|---|
| 150 | });
|
|---|
| 151 |
|
|---|
| 152 | test('een eigen bericht zonder of met kapotte media houdt gewoon zijn tekst', async () => {
|
|---|
| 153 | const db = (await import('../src/config/database.js')).default;
|
|---|
| 154 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('ms2', 'media2', 'M2', 'mu');
|
|---|
| 155 | db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, to_handle, content, attachments, created_at)
|
|---|
| 156 | VALUES ('om2','media2','pm2','p','@anna@a.test','<p>a</p>','geen json','2026-08-06 09:00:00'),
|
|---|
| 157 | ('om3','media2','pm3','p','@bo@b.test','<p>b</p>',NULL,'2026-08-06 09:01:00')`).run();
|
|---|
| 158 | const threads = AP.getMessages('media2', 50, 0).filter((i) => i.type === 'thread');
|
|---|
| 159 | const alle = threads.flatMap((t) => t.messages);
|
|---|
| 160 | assert.equal(alle.length, 2, 'geen bericht mag verdwijnen door kapotte media');
|
|---|
| 161 | for (const m of alle) assert.equal(m.media_json, null);
|
|---|
| 162 | });
|
|---|