source: Klonkt/test/c2s-compose.test.js@ 15f1cb5

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

De tegel speelt de video als zijn eigen thumbnail

Robins aanwijzing (30-7) bij de (untitled)-gradient: een videopost uit de app
mag in het Klonkt-grid de video zelf als thumbnail afspelen. De machinerie
bestond al (animated covers renderen als muted-loop video in de tegel);
C2S-posts zetten alleen nooit een cover.

Nu promoveert de eerste video van een C2S-post tot cover_video_url, met
bewust GEEN cover-image ernaast: de image-tak van de tegel zou winnen en de
tegel bevriezen. Een fotopost krijgt zijn eerste foto als cover, dus ook die
verliest de (untitled)-gradient.

In buildNote schuiven de C2S-entries voor de cover-push: de URL-dedupe houdt
de eerste, en dat moet de entry zijn die zijn mediaType en poster kent,
anders verloor de federatie het icon zodra de video ook cover werd.

Changed files:
src/services/ActivityPubService.js

  • c2sCreatePost: eerste video wordt cover-video, eerste foto cover-image
  • buildNote: C2S-entries voor de covers, zodat de rijke entry wint

test/c2s-compose.test.js

  • de tegelbelofte: cover-video gezet, geen cover-image ernaast, en de dedupe houdt de entry met de poster

remarks: 338 tests groen.

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

  • Property mode set to 100644
File size: 6.6 KB
RevLine 
[6089c53]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 const att = note.attachment || [];
47 const img = att.find((a) => a.url.endsWith('/media/reply-media/foto.jpg'));
48 const aud = att.find((a) => a.url.endsWith('/media/reply-media/opname.m4a'));
49 assert.ok(img && img.type === 'Image', 'the photo federates as an Image');
50 assert.equal(img.url, 'https://test.example/media/reply-media/foto.jpg', 'absolute, so any server can fetch it');
51 assert.equal(img.name, 'ons plein', 'alt text rides along');
52 assert.ok(aud, 'the recording federates too');
53 assert.equal(aud.type, 'Audio', 'as an Audio, not an Image: the stored mediaType wins over the extension map');
54 assert.equal(att.filter((a) => a.url.endsWith('foto.jpg')).length, 1, 'inline img + stored row dedupe to one');
55});
56
[7d01696]57test("a video's poster frame rides the tag, the store and the federated attachment", async () => {
58 // The upload leg writes <name>.poster.jpg next to a video when ffmpeg is
59 // around (shaer-zowq). From there it must reach three places: the poster=
60 // on the folded tag (web), the stored entry, and the AS2 icon on the
61 // federated attachment (apps and other servers).
62 const os = await import('os');
63 const fsm = await import('fs');
64 const pathm = await import('path');
65 const root = fsm.mkdtempSync(pathm.join(os.tmpdir(), 'klonkt-media-'));
66 fsm.mkdirSync(pathm.join(root, 'reply-media'), { recursive: true });
67 fsm.writeFileSync(pathm.join(root, 'reply-media', 'film.mp4.poster.jpg'), 'x');
68 const prev = process.env.MEDIA_PATH;
69 process.env.MEDIA_PATH = root;
70 try {
71 const r = await AP.ingestOutboxActivity(site, user, {
72 type: 'Create',
73 object: {
74 type: 'Note', content: '<p>filmpje</p>',
75 to: ['https://test.example/ap/users/kid/followers'],
76 attachment: [{ type: 'Video', url: '/media/reply-media/film.mp4', mediaType: 'video/mp4' }],
77 },
78 });
79 assert.equal(r.status, 201);
80 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
81 assert.match(post.content, /poster="\/media\/reply-media\/film\.mp4\.poster\.jpg"/, 'the web tag shows the still');
82 const note = AP.buildNote('https://test.example', site, post);
83 const vid = (note.attachment || []).find((a) => a.url.endsWith('film.mp4'));
84 assert.ok(vid && vid.type === 'Video');
85 assert.equal(vid.icon && vid.icon.url, 'https://test.example/media/reply-media/film.mp4.poster.jpg',
86 'the poster federates as the attachment icon');
[15f1cb5]87 // The web tile plays the video as its own thumbnail (Robins aanwijzing):
88 // the first video becomes the cover video, with NO cover image beside it,
89 // because the tile's image branch would win and freeze the tile.
90 assert.equal(post.cover_video_url, '/media/reply-media/film.mp4');
91 assert.equal(post.cover_image_url, null);
92 assert.equal((note.attachment || []).filter((a) => a.url.endsWith('film.mp4')).length, 1,
93 'cover video + attachment dedupe to the one entry that knows its poster');
[7d01696]94 } finally {
95 if (prev === undefined) delete process.env.MEDIA_PATH; else process.env.MEDIA_PATH = prev;
96 }
97});
98
99test('a video without a poster simply has none: no guessed icon', async () => {
100 const r = await AP.ingestOutboxActivity(site, user, {
101 type: 'Create',
102 object: {
103 type: 'Note', content: '<p>kaal</p>',
104 to: ['https://test.example/ap/users/kid/followers'],
105 attachment: [{ type: 'Video', url: '/media/reply-media/zonder.mp4', mediaType: 'video/mp4' }],
106 },
107 });
108 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
109 assert.ok(!post.content.includes('poster='), 'no poster attr without a poster file');
110 const vid = (AP.buildNote('https://test.example', site, post).attachment || []).find((a) => a.url.endsWith('zonder.mp4'));
111 assert.equal(vid.icon, undefined);
112});
113
[6089c53]114test('a media-only post is a post, not an empty-note error', async () => {
115 const r = await AP.ingestOutboxActivity(site, user, {
116 type: 'Create',
117 object: {
118 type: 'Note', content: '',
119 to: ['https://test.example/ap/users/kid/followers'],
120 attachment: [{ type: 'Image', url: '/media/reply-media/alleen.jpg', mediaType: 'image/png' }],
121 },
122 });
123 assert.equal(r.status, 201, 'a picture can be the whole message');
[15f1cb5]124 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(r.id);
125 assert.equal(post.cover_image_url, '/media/reply-media/alleen.jpg',
126 'a photo post fronts its photo instead of the (untitled) gradient');
[6089c53]127});
Note: See TracBrowser for help on using the repository browser.