source: Klonkt/test/c2s-compose.test.js@ a9da2c0

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

De outbox vergat de attachments: waarom de Mac speelde en de iPhone niet

De doorbraak zat in koffieengaars eigen outbox: atts: 0, terwijl de content
wel de video-tag droeg. De outbox-SELECT (en die van featured en de
nieuwe-volger-backfill) haalde c2s_attachments niet op, dus buildNote had
niets te federeren. Wie de post via DELIVERY kreeg had het attachment wel
(daarom werkte de Mac); wie hem via de outbox trok (backfill, boiert.eu)
kreeg alleen de content-tag met zijn relatieve, overal dode src: de kapotte
iPhone-speler.

De kolom zit nu in alle drie de smalle SELECTs; de losse-note-route deed al
SELECT * en was al goed.

Changed files:
src/routes/activitypub.js

  • outbox- en featured-SELECT dragen c2s_attachments

src/services/ActivityPubService.js

  • de nieuwe-volger-backfill-SELECT ook

test/c2s-compose.test.js

  • het outbox-contract vastgepind: dezelfde smalle SELECT levert via buildNote het Video-attachment, met schone content

remarks: 342 tests groen. koffieengaar.nl moet hierna bijgewerkt worden,
anders blijft zijn outbox kaal.

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

  • Property mode set to 100644
File size: 8.3 KB
Line 
1// The app's composer posts over C2S. A top-level Note with media used to lose
2// it silently: c2sCreatePost read only the content, so a photo post arrived
3// naked while the very same attachments worked fine on replies and DMs.
4import { test } from 'node:test';
5import assert from 'node:assert/strict';
6
7process.env.DATABASE_PATH = ':memory:';
8process.env.PUBLIC_BASE_URL = 'https://test.example';
9
10const dbMod = await import('../src/config/database.js');
11const db = dbMod.default;
12dbMod.initializeDatabase();
13const AP = (await import('../src/services/ActivityPubService.js')).default;
14
15db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
16 .run('u1', 'robin', 'u1@t', 'x', 'god');
17db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
18const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('s1' ? 'kid' : 'kid');
19const user = db.prepare('SELECT * FROM users WHERE id = ?').get('u1');
20
21test('a C2S post carries its media: into the web content and out as AS2 attachments', async () => {
22 const r = await AP.ingestOutboxActivity(site, user, {
23 type: 'Create',
24 object: {
25 type: 'Note',
26 content: '<p>kijk dan</p>',
27 source: { content: 'kijk dan', mediaType: 'text/plain' },
28 to: ['https://test.example/ap/users/kid/followers'],
29 cc: ['https://www.w3.org/ns/activitystreams#Public'],
30 attachment: [
31 { type: 'Image', url: '/media/reply-media/foto.jpg', mediaType: 'image/jpeg', name: 'ons plein' },
32 { type: 'Audio', url: '/media/reply-media/opname.m4a', mediaType: 'audio/mp4' },
33 // Not ours: a remote URL must never be laundered into our media.
34 { type: 'Image', url: 'https://evil.test/x.jpg', mediaType: 'image/jpeg' },
35 ],
36 },
37 });
38 assert.equal(r.status, 201, 'the post is created');
39
40 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
41 assert.match(post.content, /<img src="\/media\/reply-media\/foto\.jpg" alt="ons plein">/, 'the web shows the photo');
42 assert.match(post.content, /<audio controls[^>]+src="\/media\/reply-media\/opname\.m4a">/, 'and plays the recording');
43 assert.ok(!post.content.includes('evil.test'), 'the stranger stays out');
44
45 const note = AP.buildNote('https://test.example', site, post);
46 // The FEDERATED content carries no media tags: they ride as attachments,
47 // and the tags' relative /media srcs are dead everywhere but our own web.
48 // Leaving them in showed every remote reader a broken player above the
49 // working one (Robins schermafdruk, 30-7).
50 assert.ok(!/<(video|audio|img)\b/i.test(note.content), 'the note content is clean of media tags');
51 const att = note.attachment || [];
52 const img = att.find((a) => a.url.endsWith('/media/reply-media/foto.jpg'));
53 const aud = att.find((a) => a.url.endsWith('/media/reply-media/opname.m4a'));
54 assert.ok(img && img.type === 'Image', 'the photo federates as an Image');
55 assert.equal(img.url, 'https://test.example/media/reply-media/foto.jpg', 'absolute, so any server can fetch it');
56 assert.equal(img.name, 'ons plein', 'alt text rides along');
57 assert.ok(aud, 'the recording federates too');
58 assert.equal(aud.type, 'Audio', 'as an Audio, not an Image: the stored mediaType wins over the extension map');
59 assert.equal(att.filter((a) => a.url.endsWith('foto.jpg')).length, 1, 'inline img + stored row dedupe to one');
60});
61
62test("a video's poster frame rides the tag, the store and the federated attachment", async () => {
63 // The upload leg writes <name>.poster.jpg next to a video when ffmpeg is
64 // around (shaer-zowq). From there it must reach three places: the poster=
65 // on the folded tag (web), the stored entry, and the AS2 icon on the
66 // federated attachment (apps and other servers).
67 const os = await import('os');
68 const fsm = await import('fs');
69 const pathm = await import('path');
70 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
71 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
72 fsm.writeFileSync(pathm.join(root, 'reply-media', 'film.mp4.poster.jpg'), 'x');
73 const prev = process.env.MEDIA_PATH;
74 process.env.MEDIA_PATH = root;
75 try {
76 const r = await AP.ingestOutboxActivity(site, user, {
77 type: 'Create',
78 object: {
79 type: 'Note', content: '<p>filmpje</p>',
80 to: ['https://test.example/ap/users/kid/followers'],
81 attachment: [{ type: 'Video', url: '/media/reply-media/film.mp4', mediaType: 'video/mp4' }],
82 },
83 });
84 assert.equal(r.status, 201);
85 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
86 assert.match(post.content, /poster="\/media\/reply-media\/film\.mp4\.poster\.jpg"/, 'the web tag shows the still');
87 const note = AP.buildNote('https://test.example', site, post);
88 const vid = (note.attachment || []).find((a) => a.url.endsWith('film.mp4'));
89 assert.ok(vid && vid.type === 'Video');
90 assert.equal(vid.icon && vid.icon.url, 'https://test.example/media/reply-media/film.mp4.poster.jpg',
91 'the poster federates as the attachment icon');
92 // NO cover (Robins besluit): a cover next to the content showed the same
93 // video twice on the post page. The tiles derive their picture from the
94 // content (post-tile/post-card), so the post model stays single-source.
95 assert.equal(post.cover_video_url, null);
96 assert.equal(post.cover_image_url, null);
97 assert.equal((note.attachment || []).filter((a) => a.url.endsWith('film.mp4')).length, 1,
98 'and the video federates exactly once');
99 } finally {
100 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
101 }
102});
103
104test('a video without a poster simply has none: no guessed icon', async () => {
105 const r = await AP.ingestOutboxActivity(site, user, {
106 type: 'Create',
107 object: {
108 type: 'Note', content: '<p>kaal</p>',
109 to: ['https://test.example/ap/users/kid/followers'],
110 attachment: [{ type: 'Video', url: '/media/reply-media/zonder.mp4', mediaType: 'video/mp4' }],
111 },
112 });
113 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
114 assert.ok(!post.content.includes('poster='), 'no poster attr without a poster file');
115 const vid = (AP.buildNote('https://test.example', site, post).attachment || []).find((a) => a.url.endsWith('zonder.mp4'));
116 assert.equal(vid.icon, undefined);
117});
118
119test('the OUTBOX serves the attachment too, not only the delivered Create', async () => {
120 // Root of the broken iPhone player (Robins schermafdrukken, 30-7): the
121 // outbox SELECT did not include c2s_attachments, so a note pulled via the
122 // outbox (backfill, boiert.eu) had NO Video attachment and readers fell
123 // back to the content tag with its relative, dead src. The delivered copy
124 // was fine, which is why one device worked and the other did not. This
125 // locks the outbox contract: the same narrow SELECT, through buildNote,
126 // must carry the attachment.
127 const r = await AP.ingestOutboxActivity(site, user, {
128 type: 'Create',
129 object: {
130 type: 'Note', content: '<p>buiten</p>',
131 to: ['https://test.example/ap/users/kid/followers'],
132 cc: ['https://www.w3.org/ns/activitystreams#Public'],
133 attachment: [{ type: 'Video', url: '/media/reply-media/buiten.mp4', mediaType: 'video/mp4' }],
134 },
135 });
136 const row = db.prepare(
137 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
138 FROM posts WHERE id = ?`).get(r.id);
139 const note = AP.buildNote('https://test.example', site, row);
140 const vid = (note.attachment || []).find((a) => a.url.endsWith('buiten.mp4'));
141 assert.ok(vid, 'the outbox-shaped row still yields the Video attachment');
142 assert.equal(vid.type, 'Video');
143 assert.ok(!/<video\b/i.test(note.content), 'and the content stays clean');
144});
145
146test('a media-only post is a post, not an empty-note error', async () => {
147 const r = await AP.ingestOutboxActivity(site, user, {
148 type: 'Create',
149 object: {
150 type: 'Note', content: '',
151 to: ['https://test.example/ap/users/kid/followers'],
152 attachment: [{ type: 'Image', url: '/media/reply-media/alleen.jpg', mediaType: 'image/png' }],
153 },
154 });
155 assert.equal(r.status, 201, 'a picture can be the whole message');
156 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
157 assert.equal(post.cover_image_url, null, 'no cover: the tile reads the photo from the content');
158});
Note: See TracBrowser for help on using the repository browser.