P6: custom emoji, fuller remote profiles, and polls both ways
- 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
This commit is contained in:
1 parent
81de470f64
commit
7be6749a23
33 files changed
+937
-28
No files matched your search
@@ -0,0 +1,41 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class ActorProfileTests
|
||||
{
|
||||
[Fact]
|
||||
public void Reads_a_mastodon_profile_with_its_header_fields_emoji_and_lock()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"id": "https://m.example/users/ann", "type": "Person", "preferredUsername": "ann", "name": "Ann :sparkle:",
|
||||
"inbox": "https://m.example/users/ann/inbox", "manuallyApprovesFollowers": true, "indexable": true, "memorial": false,
|
||||
"published": "2023-04-01T00:00:00Z", "movedTo": "https://n.example/users/ann",
|
||||
"icon": { "type": "Image", "url": "https://m.example/a.png", "summary": "a cat" },
|
||||
"image": { "type": "Image", "url": "https://m.example/h.png", "name": "mountains" },
|
||||
"tag": [{ "type": "Emoji", "name": ":sparkle:", "icon": { "type": "Image", "url": "https://m.example/emoji/sparkle.png" } }],
|
||||
"attachment": [
|
||||
{ "type": "PropertyValue", "name": "Site", "value": "<a href=\"https://ann.example\" rel=\"me\">ann.example</a><script>x</script>" },
|
||||
{ "type": "PropertyValue", "name": "Pronouns", "value": "she/her" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var actor = ActorDocument.Parse(json.RootElement);
|
||||
|
||||
Assert.True(actor.Locked);
|
||||
Assert.True(actor.Indexable);
|
||||
Assert.Equal("https://m.example/h.png", actor.Header);
|
||||
Assert.Equal("a cat", actor.IconDescription);
|
||||
Assert.Equal("mountains", actor.HeaderDescription);
|
||||
Assert.Equal("https://n.example/users/ann", actor.MovedTo);
|
||||
Assert.Equal(new DateTime(2023, 4, 1, 0, 0, 0, DateTimeKind.Utc), actor.Published);
|
||||
Assert.Equal("sparkle", Assert.Single(actor.Emojis).Shortcode);
|
||||
Assert.Equal(new[] { "Site", "Pronouns" }, actor.Fields.Keys);
|
||||
Assert.DoesNotContain("<script>", actor.Fields["Site"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox.Handlers;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
@@ -54,7 +55,7 @@ namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
new FollowHandler(db, _local, remote, delivery),
|
||||
new UndoHandler(db, _local),
|
||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue)), new GroupDistributor(delivery), new ObjectRecords(queue)),
|
||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue)), new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue)),
|
||||
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
|
||||
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue))
|
||||
}, NullLogger<InboxProcessor>.Instance);
|
||||
|
||||
@@ -169,6 +169,51 @@ namespace PrivaPub.Tests.Federation
|
||||
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("""
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class PollTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
|
||||
|
||||
async Task Follows(RemoteActor follower, string target) =>
|
||||
await _harness.Deliver(follower, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target
|
||||
});
|
||||
|
||||
JsonObject Vote(RemoteActor voter, string question, string owner, string choice) => new()
|
||||
{
|
||||
["id"] = NewId(voter, "activities"), ["type"] = "Create", ["actor"] = voter.Id, ["to"] = new JsonArray(owner),
|
||||
["object"] = new JsonObject
|
||||
{
|
||||
["id"] = NewId(voter, "votes"), ["type"] = "Note", ["name"] = choice, ["attributedTo"] = voter.Id,
|
||||
["inReplyTo"] = question, ["to"] = new JsonArray(owner)
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task A_local_poll_goes_out_as_a_question_and_counts_each_remote_voter_once()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
await Follows(mallory, alice.Uri);
|
||||
|
||||
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "which?", Poll = new PollDraft(new[] { "tea", "coffee" }, 3600, false, false) }, token);
|
||||
Assert.True(outcome.Ok);
|
||||
var question = (await _harness.Outgoing(mallory.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
|
||||
Assert.Equal("Question", question["type"]!.GetValue<string>());
|
||||
Assert.Equal(new[] { "tea", "coffee" }, question["oneOf"]!.AsArray().Select(o => o!["name"]!.GetValue<string>()));
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "coffee"));
|
||||
await _harness.Deliver(mallory, "/human-centipede", Vote(mallory, outcome.Post.ObjectURI, alice.Uri, "tea"));
|
||||
|
||||
var poll = (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll;
|
||||
Assert.Equal(new[] { 0, 1 }, poll.Options.Select(o => o.Votes));
|
||||
Assert.Equal(1, poll.VotersCount);
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.InReplyToURI == outcome.Post.ObjectURI).ExecuteAnyAsync(token));
|
||||
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollRefresh && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
|
||||
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_persona_votes_on_a_remote_poll_and_a_count_refresh_is_not_an_edit()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
var questionId = NewId(mallory, "questions");
|
||||
JsonObject Question(int first, int second) => new()
|
||||
{
|
||||
["id"] = questionId, ["type"] = "Question", ["attributedTo"] = mallory.Id, ["content"] = "<p>pick</p>",
|
||||
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
|
||||
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }),
|
||||
["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O"),
|
||||
["anyOf"] = new JsonArray(
|
||||
new JsonObject { ["type"] = "Note", ["name"] = "red", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = first } },
|
||||
new JsonObject { ["type"] = "Note", ["name"] = "blue", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = second } })
|
||||
};
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(mallory, "activities"), ["type"] = "Create", ["actor"] = mallory.Id, ["object"] = Question(3, 1)
|
||||
});
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == questionId).ExecuteFirstAsync(token);
|
||||
Assert.True(post.Poll.Multiple);
|
||||
|
||||
Assert.True((await _harness.Polls.Vote(alice, post, new[] { 0, 1 }, token)).Ok);
|
||||
Assert.False((await _harness.Polls.Vote(alice, post, new[] { 0 }, token)).Ok);
|
||||
|
||||
var votes = (await _harness.Outgoing(mallory.Id + "/inbox")).Where(a => a["type"]!.GetValue<string>() == "Create").Select(a => a["object"]!).ToList();
|
||||
Assert.Equal(new[] { "red", "blue" }, votes.Select(v => v["name"]!.GetValue<string>()).Order().Reverse());
|
||||
Assert.All(votes, v => Assert.Equal(questionId, v["inReplyTo"]!.GetValue<string>()));
|
||||
Assert.All(votes, v => Assert.Null(v["published"]));
|
||||
Assert.Equal(new[] { 0, 1 }, (await _harness.Polls.OwnVotes(post.ID, alice.Id, token)).Order());
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = NewId(mallory, "activities"), ["type"] = "Update", ["actor"] = mallory.Id, ["object"] = Question(9, 5)
|
||||
});
|
||||
var refreshed = await DB.Default.Find<Post>().OneAsync(post.ID, token);
|
||||
Assert.Equal(new[] { 9, 5 }, refreshed.Poll.Options.Select(o => o.Votes));
|
||||
Assert.Empty(refreshed.Revisions);
|
||||
Assert.Null(refreshed.EditedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closing_a_poll_tells_its_voters_and_its_author()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var (_, bob) = await _harness.Persona("bob");
|
||||
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "quick", Poll = new PollDraft(new[] { "yes", "no" }, 300, false, false) }, token);
|
||||
Assert.True((await _harness.Polls.Vote(bob, outcome.Post, new[] { 0 }, token)).Ok);
|
||||
Assert.False((await _harness.Polls.Vote(alice, outcome.Post, new[] { 0 }, token)).Ok);
|
||||
|
||||
var job = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.PollClose && j.Payload == outcome.Post.ID).ExecuteFirstAsync(token);
|
||||
await new PollCloseJob(_harness.Db, _harness.Local, _harness.Outbox).Handle(job, token);
|
||||
|
||||
Assert.NotNull((await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).Poll.ClosedAt);
|
||||
var told = await DB.Default.Find<Notification>().Match(n => n.Type == NotificationType.Poll && n.PostId == outcome.Post.ID).ExecuteAsync(token);
|
||||
Assert.Equal(new[] { alice.Id, bob.Id }.Order(), told.Select(n => n.AvatarId).Order());
|
||||
Assert.False((await _harness.Polls.Vote(bob, await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token), new[] { 1 }, token)).Ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user