Outbound documents speak Mastodon's dialect under PrivaPub's route names
Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.
Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
bold first line, a content warning without its own text is summarised
by the title or "Content warning"; Mention and Hashtag tags, contentMap,
updated, and to/cc by visibility.
Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
e9529310fe
commit
e54e17708f
9 files changed
+590
-74
No files matched your search
@@ -0,0 +1,103 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class RendererTests
|
||||
{
|
||||
static readonly LocalActor Alice = new()
|
||||
{
|
||||
Id = "a1",
|
||||
Kind = LocalActorKind.Person,
|
||||
UserName = "alice",
|
||||
Name = "Alice",
|
||||
Summary = "I **like** <b>cats</b>",
|
||||
PublicKeyPem = Keys.NewKeyPair().PublicKeyPem,
|
||||
Published = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc),
|
||||
Fields = new Dictionary<string, string> { ["site"] = "https://alice.example", ["mood"] = "<i>ok</i>" },
|
||||
BaseAddress = "https://privapub.test"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void An_actor_looks_like_a_mastodon_actor_with_our_route_names()
|
||||
{
|
||||
var actor = ActivityPubRenderer.Actor(Alice);
|
||||
|
||||
Assert.Equal("https://privapub.test/peasants/alice", actor["id"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/@alice", actor["url"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/mouth", actor["inbox"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/anus", actor["outbox"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/groupies", actor["followers"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/stalking", actor["following"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
|
||||
Assert.Equal("2026-09-30T00:00:00Z", actor["published"]!.GetValue<string>());
|
||||
Assert.Equal("alice@privapub.test", actor["webfinger"]!.GetValue<string>());
|
||||
Assert.False(actor["indexable"]!.GetValue<bool>());
|
||||
Assert.Equal("<p>I <strong>like</strong> <b>cats</b></p>", actor["summary"]!.GetValue<string>());
|
||||
var fields = actor["attachment"]!.AsArray();
|
||||
Assert.Contains("rel=\"nofollow noopener noreferrer me\"", fields[0]!["value"]!.GetValue<string>());
|
||||
Assert.Equal("<i>ok</i>", fields[1]!["value"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/peasants/alice#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
|
||||
Assert.Equal(actor["id"]!.GetValue<string>(), actor["publicKey"]!["owner"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_titled_post_with_a_warning_keeps_the_title_visible_on_mastodon()
|
||||
{
|
||||
var post = new Post
|
||||
{
|
||||
ID = "p1",
|
||||
Title = "On <cats>",
|
||||
ContentHtml = "<p>body</p>",
|
||||
HasContentWarning = true,
|
||||
Language = "en",
|
||||
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
|
||||
Tags = new() { "cats" },
|
||||
CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
|
||||
|
||||
Assert.Equal("https://privapub.test/peasants/alice/scribbles/p1", note["id"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/@alice/p1", note["url"]!.GetValue<string>());
|
||||
Assert.Equal("<p><strong>On <cats></strong></p><p>body</p>", note["content"]!.GetValue<string>());
|
||||
Assert.Equal("On <cats>", note["summary"]!.GetValue<string>());
|
||||
Assert.True(note["sensitive"]!.GetValue<bool>());
|
||||
Assert.Equal("2026-09-30T17:45:12Z", note["published"]!.GetValue<string>());
|
||||
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||
var tags = note["tag"]!.AsArray();
|
||||
Assert.Equal("@bob@m.example", tags[0]!["name"]!.GetValue<string>());
|
||||
Assert.Equal("https://privapub.test/tags/cats", tags[1]!["href"]!.GetValue<string>());
|
||||
Assert.Equal("https://m.example/users/bob/statuses/1", note["inReplyTo"]!.GetValue<string>());
|
||||
Assert.NotNull(note["contentMap"]!["en"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_warning_without_a_title_or_spoiler_gets_a_generic_summary()
|
||||
{
|
||||
var note = ActivityPubRenderer.Note(new Post { ID = "p2", ContentHtml = "<p>x</p>", HasContentWarning = true }, Alice, default, default);
|
||||
|
||||
Assert.Equal(ActivityPubRenderer.ContentWarning, note["summary"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_direct_note_is_addressed_to_its_recipients_only()
|
||||
{
|
||||
var post = new Post { ID = "p3", ContentHtml = "<p>psst</p>", Visibility = PostVisibility.Direct };
|
||||
|
||||
var note = ActivityPubRenderer.DirectNote(post, Alice, new[] { ("https://m.example/users/bob", "bob@m.example") },
|
||||
Alice.ConversationUri("c1"));
|
||||
|
||||
Assert.Equal(new[] { "https://m.example/users/bob" }, note["to"]!.AsArray().Select(n => n!.GetValue<string>()));
|
||||
Assert.Empty(note["cc"]!.AsArray());
|
||||
Assert.Equal("https://privapub.test/peasants/alice/whispers/c1", note["context"]!.GetValue<string>());
|
||||
Assert.StartsWith("<p><span class=\"h-card\"", note["content"]!.GetValue<string>());
|
||||
Assert.Equal("Mention", note["tag"]![0]!["type"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user