- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in Status.emojis and Account.emojis. - Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and image descriptions (a locked GoToSocial account no longer shows as open). - Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed; our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST /api/v1/polls/:id/votes. - An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision. Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
225 lines
11 KiB
C#
225 lines
11 KiB
C#
using PrivaPub.Api.Mastodon.Mappers;
|
||
using PrivaPub.Federation.Objects;
|
||
using PrivaPub.Models.Post;
|
||
|
||
using System.Text.Json.Nodes;
|
||
|
||
namespace PrivaPub.Tests.Federation
|
||
{
|
||
public class ObjectShapesTests
|
||
{
|
||
static NoteDocument Parse(string json) => NoteParser.Parse(JsonNode.Parse(json));
|
||
|
||
[Fact]
|
||
public void Keeps_a_peertube_video_with_its_files_poster_captions_and_markdown_description()
|
||
{
|
||
var note = Parse("""
|
||
{
|
||
"id": "https://tube.example/videos/watch/1", "type": "Video", "name": "A *talk*", "duration": "PT6299S",
|
||
"mediaType": "text/markdown", "content": "**Bold** words", "sensitive": false, "views": 14771, "state": 1,
|
||
"isLiveBroadcast": false, "commentsPolicy": 2, "downloadEnabled": true,
|
||
"licence": { "identifier": "5", "name": "Attribution - Share Alike" }, "category": { "identifier": "2", "name": "Films" },
|
||
"attributedTo": [{ "type": "Person", "id": "https://tube.example/accounts/ann" }, { "type": "Group", "id": "https://tube.example/video-channels/films" }],
|
||
"icon": [
|
||
{ "type": "Image", "url": "https://tube.example/thumb-280.jpg", "mediaType": "image/jpeg", "width": 280, "height": 157 },
|
||
{ "type": "Image", "url": "https://tube.example/thumb-1920.jpg", "mediaType": "image/jpeg", "width": 1920, "height": 1080 },
|
||
{ "type": "Image", "url": "https://tube.example/thumb-850.jpg", "mediaType": "image/jpeg", "width": 850, "height": 480 }
|
||
],
|
||
"subtitleLanguage": [{ "identifier": "en", "url": [{ "mediaType": "text/vtt", "href": "https://tube.example/en.vtt" }] }],
|
||
"url": [
|
||
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/abc" },
|
||
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720.mp4", "height": 720, "width": 1280, "fps": 24, "size": 942586378,
|
||
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
|
||
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8" },
|
||
{ "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xt=urn:btih:abc" }
|
||
]
|
||
}
|
||
""");
|
||
|
||
Assert.Equal("https://tube.example/w/abc", note.Url);
|
||
Assert.Equal("A *talk*", note.Title);
|
||
Assert.Contains("<strong>Bold</strong>", note.ContentHtml);
|
||
Assert.Equal("https://tube.example/thumb-850.jpg", note.CoverURL);
|
||
Assert.Equal(6299, note.Video.DurationSeconds);
|
||
var file = Assert.Single(note.Video.Variants);
|
||
Assert.Equal(new[] { "audio", "video" }, file.Streams);
|
||
Assert.Equal(942586378, file.Size);
|
||
Assert.Equal("https://tube.example/master.m3u8", note.Video.PlaylistURL);
|
||
Assert.Equal(2, note.Video.CommentsPolicy);
|
||
Assert.Equal("Attribution - Share Alike", note.Video.Licence);
|
||
Assert.Equal("https://tube.example/video-channels/films", note.Video.ChannelURI);
|
||
Assert.Equal("https://tube.example/en.vtt", Assert.Single(note.Video.Captions).Url);
|
||
Assert.Equal("https://tube.example/accounts/ann", note.AttributedTo);
|
||
}
|
||
|
||
[Fact]
|
||
public void Keeps_a_mobilizon_event_with_its_time_place_and_online_link()
|
||
{
|
||
var note = Parse("""
|
||
{
|
||
"id": "https://events.example/events/9", "type": "Event", "name": "Repair café",
|
||
"summary": "<p>Saturday at the library</p>", "content": "<p>Bring broken things.</p>",
|
||
"startTime": "2026-10-03T10:00:00+02:00", "endTime": "2026-10-03T13:00:00+02:00", "timezone": "Europe/Rome",
|
||
"joinMode": "free", "participantCount": 12, "maximumAttendeeCapacity": 40, "ical:status": "CONFIRMED",
|
||
"location": { "type": "Place", "name": "Biblioteca", "latitude": 45.07, "longitude": 7.68,
|
||
"address": { "type": "PostalAddress", "streetAddress": "Via Roma 1", "addressLocality": "Torino", "addressCountry": "IT" } },
|
||
"attachment": [{ "type": "Link", "name": "Website", "href": "https://meet.example/repair" }],
|
||
"attributedTo": "https://events.example/@club"
|
||
}
|
||
""");
|
||
|
||
Assert.Null(note.SpoilerText);
|
||
Assert.False(note.Sensitive);
|
||
Assert.Equal("Saturday at the library", note.Excerpt);
|
||
Assert.Equal(new DateTime(2026, 10, 3, 8, 0, 0, DateTimeKind.Utc), note.Event.Start);
|
||
Assert.Equal("Via Roma 1, Torino, IT", Assert.Single(note.Event.Places).Address);
|
||
Assert.Equal("https://meet.example/repair", note.Event.OnlineURL);
|
||
Assert.Equal(40, note.Event.Capacity);
|
||
Assert.Equal("CONFIRMED", note.Event.Status);
|
||
Assert.Equal("2026-10-03 10:00 – 13:00 (Europe/Rome) · Biblioteca", MastodonMapper.EventLine(note.Event));
|
||
}
|
||
|
||
[Fact]
|
||
public void Keeps_a_lemmy_link_post_as_a_link_with_its_thumbnail()
|
||
{
|
||
var note = Parse("""
|
||
{
|
||
"id": "https://lemmy.example/post/5", "type": "Page", "name": "A good read", "attributedTo": "https://lemmy.example/u/bo",
|
||
"attachment": [{ "type": "Link", "href": "https://news.example/story", "mediaType": "text/html" }],
|
||
"image": { "type": "Image", "url": "https://lemmy.example/pictrs/thumb.jpg" },
|
||
"language": { "identifier": "en", "name": "English" }
|
||
}
|
||
""");
|
||
|
||
Assert.Equal("https://news.example/story", note.Link.Href);
|
||
Assert.Equal("https://lemmy.example/pictrs/thumb.jpg", note.Link.ImageURL);
|
||
Assert.False(note.Link.FromPublisher);
|
||
Assert.Empty(note.Attachments);
|
||
}
|
||
|
||
[Fact]
|
||
public void Takes_a_publisher_supplied_preview_from_a_link_attachment()
|
||
{
|
||
var note = Parse("""
|
||
{
|
||
"id": "https://m.example/statuses/1", "type": "Note", "attributedTo": "https://m.example/users/a", "content": "<p>look</p>",
|
||
"attachment": [{ "type": "Link", "href": "https://news.example/a",
|
||
"preview": { "type": "Article", "name": "Headline & more", "summary": "<p>What happened</p>", "image": { "type": "Image", "url": "https://news.example/a.jpg" } } }]
|
||
}
|
||
""");
|
||
|
||
Assert.Equal("Headline & more", note.Link.Title);
|
||
Assert.Equal("What happened", note.Link.Description);
|
||
Assert.Equal("https://news.example/a.jpg", note.Link.ImageURL);
|
||
Assert.True(note.Link.FromPublisher);
|
||
}
|
||
|
||
[Fact]
|
||
public void Reads_media_in_every_shape_and_infers_what_is_missing()
|
||
{
|
||
var note = Parse("""
|
||
{
|
||
"id": "https://bsky.brid.gy/convert/ap/at://did:plc:x/post/1", "type": "Note", "attributedTo": "https://bsky.brid.gy/ap/did:plc:x",
|
||
"content": "<p>pics</p>",
|
||
"@context": ["https://www.w3.org/ns/activitystreams", { "@language": "de" }],
|
||
"attachment": [
|
||
{ "type": "Image", "url": "https://cdn.example/a.jpeg", "summary": "alt from summary" },
|
||
{ "type": "Document", "url": [{ "type": "Link", "href": "https://cdn.example/clip", "mediaType": "video/mp4" }],
|
||
"icon": { "type": "Image", "url": "https://cdn.example/clip.jpg" }, "duration": "PT12.4S", "sensitive": true },
|
||
{ "type": "Image", "url": "https://cdn.example/nameless" }
|
||
]
|
||
}
|
||
""");
|
||
|
||
Assert.Equal("de", note.Language);
|
||
Assert.Equal("image/jpeg", note.Attachments[0].ContentType);
|
||
Assert.Equal("alt from summary", note.Attachments[0].Description);
|
||
Assert.Equal("https://cdn.example/clip", note.Attachments[1].RemoteURL);
|
||
Assert.Equal("video/mp4", note.Attachments[1].ContentType);
|
||
Assert.Equal("https://cdn.example/clip.jpg", note.Attachments[1].RemotePreviewURL);
|
||
Assert.Equal(12.4, note.Attachments[1].DurationSeconds);
|
||
Assert.True(note.Attachments[1].Sensitive);
|
||
Assert.Equal("image/*", note.Attachments[2].ContentType);
|
||
}
|
||
|
||
[Fact]
|
||
public void Keeps_a_funkwhale_track_and_a_loops_poster()
|
||
{
|
||
var track = Parse("""
|
||
{
|
||
"id": "https://music.example/federation/music/uploads/1", "type": "Audio", "name": "Song", "attributedTo": "https://music.example/@band",
|
||
"summary": "#rock #live", "duration": 241, "position": 3, "album": "https://music.example/albums/2", "license": "https://creativecommons.org/licenses/by/4.0/",
|
||
"url": [{ "type": "Link", "mediaType": "text/html", "href": "https://music.example/library/tracks/1" },
|
||
{ "type": "Link", "mediaType": "audio/mpeg", "href": "https://music.example/listen/1.mp3", "bitrate": 320000, "size": 9640000 }]
|
||
}
|
||
""");
|
||
var loop = Parse("""
|
||
{
|
||
"id": "https://loops.example/v/1", "type": "Note", "attributedTo": "https://loops.example/@dancer", "content": "",
|
||
"preview": { "type": "Image", "url": { "href": "https://loops.example/poster.jpg", "mediaType": "image/jpeg" } },
|
||
"attachment": [{ "type": "Document", "mediaType": "video/mp4", "url": "https://loops.example/v/1.mp4", "width": 720, "height": 1280 }]
|
||
}
|
||
""");
|
||
|
||
Assert.Equal("#rock #live", track.Excerpt);
|
||
Assert.Null(track.SpoilerText);
|
||
Assert.Equal(241, track.Audio.DurationSeconds);
|
||
Assert.Equal(3, track.Audio.Position);
|
||
Assert.Equal("https://music.example/listen/1.mp3", Assert.Single(track.Audio.Variants).Url);
|
||
Assert.Equal("https://loops.example/poster.jpg", loop.CoverURL);
|
||
}
|
||
|
||
[Fact]
|
||
public void Keeps_custom_emoji_and_drops_ones_that_could_not_render()
|
||
{
|
||
var note = Parse("""
|
||
{
|
||
"id": "https://k.example/notes/1", "type": "Note", "attributedTo": "https://k.example/users/1", "content": ":blobcat: hi :bad:",
|
||
"tag": [
|
||
{ "type": "Emoji", "name": ":blobcat:", "updated": "2026-01-01T00:00:00Z", "icon": { "type": "Image", "mediaType": "image/png", "url": "https://k.example/emoji/blobcat.png" } },
|
||
{ "type": "Emoji", "name": ":blobcat:", "icon": { "type": "Image", "url": "https://k.example/emoji/duplicate.png" } },
|
||
{ "type": "Emoji", "name": ":bad name:", "icon": { "type": "Image", "url": "https://k.example/emoji/x.png" } },
|
||
{ "type": "Emoji", "name": ":nourl:", "icon": { "type": "Image", "url": "data:image/png;base64,AAAA" } }
|
||
]
|
||
}
|
||
""");
|
||
|
||
var emoji = Assert.Single(note.Emojis);
|
||
Assert.Equal("blobcat", emoji.Shortcode);
|
||
Assert.Equal("https://k.example/emoji/blobcat.png", emoji.URL);
|
||
}
|
||
|
||
[Fact]
|
||
public void Reads_polls_from_mastodon_misskey_and_closed_ones()
|
||
{
|
||
var mastodon = Parse("""
|
||
{ "id": "https://m.example/q/1", "type": "Question", "attributedTo": "https://m.example/u", "content": "?", "endTime": "2026-10-02T00:00:00Z", "votersCount": 7,
|
||
"oneOf": [{ "type": "Note", "name": "a", "replies": { "type": "Collection", "totalItems": 4 } }, { "type": "Note", "name": "b", "replies": { "type": "Collection", "totalItems": 3 } }] }
|
||
""");
|
||
var misskey = Parse("""
|
||
{ "id": "https://k.example/q/1", "type": "Question", "attributedTo": "https://k.example/u", "_misskey_content": "?",
|
||
"anyOf": [{ "type": "Note", "name": "x", "_misskey_votes": 2 }, { "type": "Note", "name": "y", "_misskey_votes": 5 }] }
|
||
""");
|
||
var closed = Parse("""
|
||
{ "id": "https://g.example/q/1", "type": "Question", "attributedTo": "https://g.example/u", "content": "?", "closed": "2026-09-30T12:00:00Z",
|
||
"oneOf": [{ "type": "Note", "name": "p" }, { "type": "Note", "name": "q" }] }
|
||
""");
|
||
|
||
Assert.False(mastodon.Poll.Multiple);
|
||
Assert.Equal(new[] { 4, 3 }, mastodon.Poll.Options.Select(o => o.Votes));
|
||
Assert.Equal(7, mastodon.Poll.VotersCount);
|
||
Assert.True(misskey.Poll.Multiple);
|
||
Assert.Equal(new[] { 2, 5 }, misskey.Poll.Options.Select(o => o.Votes));
|
||
Assert.Null(misskey.Poll.VotersCount);
|
||
Assert.Equal(new DateTime(2026, 9, 30, 12, 0, 0, DateTimeKind.Utc), closed.Poll.ClosedAt);
|
||
}
|
||
|
||
[Fact]
|
||
public void Normalises_hashtags_like_mastodon() =>
|
||
Assert.Equal("fedi".Normalize(System.Text.NormalizationForm.FormKC).ToLowerInvariant(), Assert.Single(Parse("""
|
||
{ "id": "https://x.example/1", "type": "Note", "attributedTo": "https://x.example/u", "content": "",
|
||
"tag": [{ "type": "Hashtag", "name": "#FEDI" }] }
|
||
""").Tags));
|
||
}
|
||
}
|