using MongoDB.Entities; using PrivaPub.Domain.Statuses; using PrivaPub.Models.Post; using PrivaPub.Tests.Support; using System.Text.Json.Nodes; using static PrivaPub.Tests.Support.FederatedSeeds; namespace PrivaPub.Tests.Federation { // Inbox forwarding of replies (owner decision 2026-10-05): a reply to a persona's post reaches the persona's followers, // who are not among those its author's server sends it to [Trait("Category", "Integration")] public sealed class ReplyRelayTests : 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(); } // alice's post; bob replies from his server, fan follows alice from another, pal from bob's async Task<(PrivaPub.Federation.Actors.LocalActor Alice, Post Post, RemoteActor Bob, RemoteActor Fan, RemoteActor Pal)> Thread() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); var fan = new RemoteActor(_harness.Peer, "fan", _harness.Peer.B); var pal = new RemoteActor(_harness.Peer, "pal"); await _harness.FollowedBy(alice, fan); await _harness.FollowedBy(alice, pal); var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "what do you think?" }, token)).Post; return (alice, post, bob, fan, pal); } JsonObject Reply(RemoteActor bob, Post post, string visibility = "public") { var reply = PublicNote(bob, "

I think so

", post.ActorURI); reply["inReplyTo"] = post.ObjectURI; if (visibility == "followers") { reply["to"] = new JsonArray(bob.Id + "/followers"); reply["cc"] = new JsonArray(post.ActorURI); } return reply; } Task> At(RemoteActor follower) => _harness.Outgoing(follower.SharedInbox); [Fact] public async Task A_public_reply_to_a_personas_post_reaches_its_followers_as_it_came_and_not_its_authors_server() { var (_, post, bob, fan, pal) = await Thread(); var create = Create(bob, Reply(bob, post)); create["signature"] = new JsonObject { ["type"] = "RsaSignature2017", ["signatureValue"] = "kept as it came" }; await _harness.Deliver(bob, "/human-centipede", create); var relayed = Assert.Single(await At(fan), a => a["id"]!.GetValue() == IdOf(create)); Assert.Equal(bob.Id, relayed["actor"]!.GetValue()); Assert.Equal("kept as it came", relayed["signature"]!["signatureValue"]!.GetValue()); Assert.DoesNotContain(await At(pal), a => a["id"]!.GetValue() == IdOf(create)); } [Fact] public async Task Its_edit_and_deletion_follow_it_and_a_followers_only_reply_stays_where_it_was_sent() { var (_, post, bob, fan, _) = await Thread(); var reply = Reply(bob, post); await _harness.Deliver(bob, "/human-centipede", Create(bob, reply)); var edited = (JsonObject)reply.DeepClone(); edited["content"] = "

I think so, on second thought

"; edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O"); var update = Activity(bob, "Update", edited); await _harness.Deliver(bob, "/human-centipede", update); var delete = Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!); await _harness.Deliver(bob, "/human-centipede", delete); var relayed = (await At(fan)).Select(a => a["id"]!.GetValue()).ToList(); Assert.Contains(IdOf(update), relayed); Assert.Contains(IdOf(delete), relayed); var quiet = Create(bob, Reply(bob, post, "followers")); await _harness.Deliver(bob, "/human-centipede", quiet); Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue() == IdOf(quiet)); } [Fact] public async Task A_reply_that_reached_us_forwarded_is_not_passed_on_again() { var (_, post, bob, fan, _) = await Thread(); var other = new RemoteActor(_harness.Peer, "other", _harness.Peer.B); var reply = Reply(bob, post); _harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString()); var create = Create(bob, reply); var result = await _harness.Deliver(other, "/human-centipede", create); Assert.Equal("forwarded", result.Reason); Assert.True(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(reply)).ExecuteAnyAsync(TestContext.Current.CancellationToken)); Assert.DoesNotContain(await At(fan), a => a["id"]!.GetValue() == IdOf(create)); } } }