source: Klonkt/test/rich-reply.test.js@ 5190152

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

Feature: rich editor on the edit forms too (klonkt-demo-c7f, last slice)

Editing a sent reply (Messages + the interact page's manage list) now opens the
same shared editor as new replies, instead of a bare textarea. Prutter stays
plain on purpose (Robin: not part of this).

  • reply-editor partial: initialHtml/initialText prefill for edit mode, and a noAttach flag that omits the media UI (an edit never touches attachments). JS grew canAttach guards so the component runs without the attach elements; pasted files are still swallowed there rather than becoming base64 blobs.
  • deliverOutboxUpdate(site, id, text, {html, language}): rich path with the same sanitize + enrichment + mention-first-paragraph logic as deliverReply; language updated via COALESCE (bogus codes keep the old one); attachments survive untouched. Plain path unchanged.
  • /fediverse/:id/edit passes content + language; listOutbox and the Messages sent-projection carry language so the select preselects correctly.
  • The interact page's edit-toggle focuses the rich editor when present.

2 new tests (93 green). Browser-verified on /messages: prefilled editor
(mention-stripped HTML + plain fallback + language preselected, no paperclip),
edit saved -> content replaced with markup, mention re-attached inline,
language nl->en, no console errors.

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

  • Property mode set to 100644
File size: 6.9 KB
Line 
1// Rich replies (klonkt-demo-c7f fase 2): deliverReply accepts editor HTML,
2// sanitizes it, injects the parent mention into the first paragraph, stores the
3// reply language, and buildReplyNote carries contentMap. In-memory DB; the
4// parent actor lives on an unresolvable host, so delivery just queues.
5
6import { test } from 'node:test';
7import assert from 'node:assert/strict';
8
9process.env.DATABASE_PATH = ':memory:';
10process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
11
12const dbMod = await import('../src/config/database.js');
13const db = dbMod.default;
14dbMod.initializeDatabase();
15const AP = await import('../src/services/ActivityPubService.js');
16
17db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
18 .run('u1', 'robin', 'r@test', 'x', 'god');
19db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
20db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, created_at, updated_at)
21 VALUES ('p1','s1','hallo','u1','Hallo','<p>x</p>','published',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`).run();
22
23const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('me');
24const parent = {
25 id: 1, post_id: 'p1', actor_uri: 'https://unresolvable.invalid/u/alice',
26 actor_url: 'https://unresolvable.invalid/@alice', actor_handle: '@alice@unresolvable.invalid',
27 object_uri: 'https://unresolvable.invalid/notes/1',
28};
29
30test('rich html is sanitized, mention lands in the first paragraph, language stored', async () => {
31 const r = await AP.deliverReply(site, {
32 postId: 'p1', postSlug: 'hallo', parent,
33 text: '', html: '<p>Dag <strong>Alice</strong>!</p><script>evil()</script>',
34 language: 'nl',
35 });
36 assert.ok(r && r.id, 'reply stored');
37 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
38 assert.match(row.content, /<strong>Alice<\/strong>/);
39 assert.doesNotMatch(row.content, /<script/i);
40 assert.match(row.content, /^<p><a [^>]*class="u-url mention"/); // mention in first <p>
41 assert.equal(row.language, 'nl');
42
43 const note = AP.buildReplyNote('https://klonkt.test', site, row);
44 assert.equal(note.type, 'Note');
45 assert.deepEqual(Object.keys(note.contentMap), ['nl']);
46 assert.equal(note.contentMap.nl, note.content);
47});
48
49test('rich html without a leading <p> gets the mention as its own paragraph', async () => {
50 const r = await AP.deliverReply(site, {
51 postId: 'p1', postSlug: 'hallo', parent,
52 text: '', html: '<blockquote>quote</blockquote>', language: '',
53 });
54 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
55 assert.match(row.content, /^<p><a .*<\/p><blockquote>/s);
56 assert.equal(row.language, null);
57 assert.equal(AP.buildReplyNote('https://klonkt.test', site, row).contentMap, undefined);
58});
59
60test('the plain-text path is unchanged (escaped, br for newlines)', async () => {
61 const r = await AP.deliverReply(site, {
62 postId: 'p1', postSlug: 'hallo', parent, text: 'plain <b>niet</b>\ntweede',
63 });
64 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
65 assert.match(row.content, /plain &lt;b&gt;niet&lt;\/b&gt;<br>tweede/);
66});
67
68test('empty rich html (only tags/whitespace) is rejected like empty text', async () => {
69 const r = await AP.deliverReply(site, {
70 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '<p> </p><script>x()</script>',
71 });
72 assert.equal(r, null);
73});
74
75test('a bogus language code is dropped, not stored', async () => {
76 const r = await AP.deliverReply(site, {
77 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '<p>taalcheck</p>', language: 'not a lang!',
78 });
79 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
80 assert.equal(row.language, null);
81});
82
83test('attachments: own /media/ urls stored, note carries typed absolute attachments', async () => {
84 const r = await AP.deliverReply(site, {
85 postId: 'p1', postSlug: 'hallo', parent, text: 'met media', html: '',
86 attachments: [
87 { url: '/media/reply-media/a.webp', mediaType: 'image/webp', name: 'foto' },
88 { url: '/media/reply-media/b.mp3', mediaType: 'audio/mpeg', name: 'liedje' },
89 { url: 'https://evil.example/x.png', mediaType: 'image/png', name: 'remote' }, // rejected: not ours
90 { url: '/media/reply-media/c.pdf', mediaType: 'application/pdf', name: 'doc' }, // rejected: type
91 ],
92 });
93 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
94 const stored = JSON.parse(row.attachments);
95 assert.equal(stored.length, 2);
96 assert.deepEqual(stored.map((a) => a.url), ['/media/reply-media/a.webp', '/media/reply-media/b.mp3']);
97
98 const note = AP.buildReplyNote('https://klonkt.test', site, row);
99 assert.equal(note.attachment.length, 2);
100 assert.deepEqual(note.attachment.map((a) => a.type), ['Image', 'Audio']);
101 assert.equal(note.attachment[0].url, 'https://klonkt.test/media/reply-media/a.webp');
102});
103
104test('a media-only reply (no text) is delivered', async () => {
105 const r = await AP.deliverReply(site, {
106 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '',
107 attachments: [{ url: '/media/reply-media/solo.webp', mediaType: 'image/webp', name: '' }],
108 });
109 assert.ok(r && r.id);
110 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
111 assert.match(row.content, /^<p><a /); // just the mention paragraph
112 assert.equal(JSON.parse(row.attachments).length, 1);
113});
114
115test('only foreign/invalid attachments and no text -> rejected', async () => {
116 const r = await AP.deliverReply(site, {
117 postId: 'p1', postSlug: 'hallo', parent, text: '', html: '',
118 attachments: [{ url: 'https://evil.example/x.png', mediaType: 'image/png' }],
119 });
120 assert.equal(r, null);
121});
122
123test('rich edit: content replaced, language updated, attachments survive', async () => {
124 const r = await AP.deliverReply(site, {
125 postId: 'p1', postSlug: 'hallo', parent, text: 'origineel', html: '',
126 attachments: [{ url: '/media/reply-media/keep.webp', mediaType: 'image/webp', name: 'blijft' }],
127 language: 'nl',
128 });
129 const upd = await AP.deliverOutboxUpdate(site, r.id, '', { html: '<p>bewerkt met <em>nadruk</em></p>', language: 'en' });
130 assert.ok(upd && upd.ok);
131 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
132 assert.match(row.content, /bewerkt met <em>nadruk<\/em>/);
133 assert.match(row.content, /^<p><a [^>]*class="u-url mention"/); // mention re-attached inline
134 assert.equal(row.language, 'en');
135 assert.equal(JSON.parse(row.attachments)[0].url, '/media/reply-media/keep.webp');
136});
137
138test('plain edit path unchanged; bogus language keeps the old one', async () => {
139 const r = await AP.deliverReply(site, { postId: 'p1', postSlug: 'hallo', parent, text: 'plain start', language: 'nl' });
140 const upd = await AP.deliverOutboxUpdate(site, r.id, 'plain bewerkt', { language: '???' });
141 assert.ok(upd && upd.ok);
142 const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get(r.id);
143 assert.match(row.content, /plain bewerkt/);
144 assert.equal(row.language, 'nl');
145});
Note: See TracBrowser for help on using the repository browser.