Files
SocialPub/PrivaPub.Tests/Federation/ObjectShapesTests.cs
T
thepraandClaude Opus 5.5 31d1d21d1e Pixelfed places: a remote post's own place, shown and never federated again
A Note's `location` that is a Place with coordinates (Pixelfed sends
them as strings) is kept in the new Post.Place and shown as
Status.privapub.place {name, latitude, longitude, country}; an edit
replaces it. An Event's location stays its own `places`, and nothing
renders a place back out. Closes the INTEROP P2 Pixelfed location gap
and the ROADMAP long-tail item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 10:07:06 +02:00

300 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_pixelfed_place_whose_coordinates_are_strings()
{
var note = Parse("""
{
"id": "https://pixel.example/p/ann/1", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "<p>view</p>",
"location": { "type": "Place", "name": "Tokyo", "longitude": "139.6917", "latitude": "35.6895", "country": "Japan" },
"commentsEnabled": true
}
""");
Assert.Equal(("Tokyo", 35.6895, 139.6917, "Japan"), (note.Place.Name, note.Place.Latitude, note.Place.Longitude, note.Place.Country));
Assert.Null(note.Event);
}
[Theory]
[InlineData("null")]
[InlineData("""{ "type": "Place", "name": "Nowhere" }""")]
[InlineData("""{ "type": "Place", "name": "Off the map", "latitude": 123, "longitude": 9 }""")]
[InlineData("""{ "type": "Place", "latitude": "north", "longitude": "9.19" }""")]
[InlineData("""{ "type": "VirtualLocation", "url": "https://meet.example/a", "latitude": 45, "longitude": 9 }""")]
public void Has_no_place_without_a_place_with_coordinates(string location)
{
var note = Parse($$"""
{ "id": "https://pixel.example/p/ann/2", "type": "Note", "attributedTo": "https://pixel.example/users/ann", "content": "<p>x</p>", "location": {{location}} }
""");
Assert.Null(note.Place);
}
[Fact]
public void An_events_location_stays_the_events_own()
{
var note = Parse("""
{
"id": "https://events.example/events/10", "type": "Event", "name": "Picnic", "startTime": "2026-10-03T10:00:00+02:00",
"location": { "type": "Place", "name": "Park", "latitude": 45.07, "longitude": 7.68 },
"attributedTo": "https://events.example/@club"
}
""");
Assert.Null(note.Place);
Assert.Equal("Park", Assert.Single(note.Event.Places).Name);
}
[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 &amp; 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_akkoma_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" }] }
""");
var akkoma = Parse("""
{ "id": "https://a.example/objects/1", "type": "Question", "attributedTo": "https://a.example/users/u", "content": "?", "closed": "2099-01-01T00:00:00.000000Z",
"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);
Assert.Null(akkoma.Poll.ClosedAt);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), akkoma.Poll.ExpiresAt);
}
[Fact]
public void Finds_playable_files_inside_an_hls_only_peertube_video_and_picks_one_with_sound()
{
var note = Parse("""
{
"id": "https://tube.example/videos/watch/2", "type": "Video", "name": "HLS only", "attributedTo": "https://tube.example/accounts/ann",
"url": [
{ "type": "Link", "mediaType": "text/html", "href": "https://tube.example/w/def" },
{ "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tube.example/master.m3u8", "tag": [
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/1080-fragmented.mp4", "height": 1080,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/720-fragmented.mp4", "height": 720,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }] },
{ "type": "Link", "mediaType": "video/mp4", "href": "https://tube.example/480-fragmented.mp4", "height": 480,
"attachment": [{ "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }] }
] }
]
}
""");
Assert.Equal(3, note.Video.Variants.Count);
Assert.Equal("https://tube.example/480-fragmented.mp4", MastodonMapper.BestVideo(note.Video.Variants).Url);
}
[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));
}
}