Deleting a remote post now removes its timeline entries and the reblogs of it and lowers its parent's reply count; deleting a remote actor also drops the follows pointing at it and its entries in home timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
163 lines
7.4 KiB
C#
163 lines
7.4 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.Post;
|
|
using PrivaPub.ClientModels.Social;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Domain
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class TimelineTests : 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();
|
|
}
|
|
|
|
async Task<List<ViewPost>> Home(string root, string avatarId) =>
|
|
(List<ViewPost>)(await _harness.Timelines.Home(root, avatarId, default, 40, TestContext.Current.CancellationToken)).Data;
|
|
|
|
async Task Accepted(string root, string avatarId, RemoteActor target)
|
|
{
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = avatarId, Target = target.Id }, TestContext.Current.CancellationToken);
|
|
await DB.Default.Update<Following>().Match(f => f.AvatarId == avatarId && f.TargetActorURI == target.Id)
|
|
.Modify(f => f.State, FollowState.Accepted).ExecuteAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
static JsonObject Create(RemoteActor author, string inReplyTo = default, string text = "hello", params (string Uri, string Name)[] mentions)
|
|
{
|
|
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
|
|
var note = new JsonObject
|
|
{
|
|
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
|
|
["type"] = "Note",
|
|
["attributedTo"] = author.Id,
|
|
["content"] = $"<p>{text}</p>",
|
|
["to"] = new JsonArray(Addressing.Public),
|
|
["cc"] = new JsonArray(mentions.Select(m => (JsonNode)m.Uri).Prepend(author.Id + "/followers").ToArray()),
|
|
["tag"] = new JsonArray(mentions.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.Uri, ["name"] = m.Name }).ToArray())
|
|
};
|
|
if (inReplyTo != default)
|
|
note["inReplyTo"] = inReplyTo;
|
|
return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_local_post_reaches_the_author_and_local_followers_only()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (aliceRoot, alice) = await _harness.Persona("alice");
|
|
var (bobRoot, bob) = await _harness.Persona("bob");
|
|
var (carolRoot, carol) = await _harness.Persona("carol");
|
|
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token);
|
|
|
|
await _harness.Posts.InsertPost(aliceRoot, new InsertPostForm { AvatarId = alice.Id, Text = "for my followers" }, token);
|
|
|
|
Assert.Single(await Home(aliceRoot, alice.Id));
|
|
Assert.Equal("<p>for my followers</p>", Assert.Single(await Home(bobRoot, bob.Id)).ContentHtml);
|
|
Assert.Empty(await Home(carolRoot, carol.Id));
|
|
Assert.Equal(404, (await _harness.Timelines.Home(carolRoot, bob.Id, default, 20, token)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_followed_remote_account_is_kept_and_its_replies_follow_mastodons_rule()
|
|
{
|
|
var (bobRoot, bob) = await _harness.Persona("bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
var dave = new RemoteActor(_harness.Peer, "dave");
|
|
await Accepted(bobRoot, bob.Id, carol);
|
|
|
|
var top = Create(carol, text: "top");
|
|
await _harness.Deliver(carol, "/human-centipede", top);
|
|
await _harness.Deliver(carol, "/human-centipede", Create(carol, top["object"]!["id"]!.GetValue<string>(), "self reply"));
|
|
await _harness.Deliver(dave, "/human-centipede", Create(dave, text: "dave alone"));
|
|
var daveRoot = Create(dave, text: "unfollowed root");
|
|
await _harness.Deliver(carol, "/human-centipede", Create(carol, $"{new Uri(dave.Id).GetLeftPart(UriPartial.Authority)}/notes/unknown", "reply to a stranger"));
|
|
|
|
var home = (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml).ToList();
|
|
Assert.Contains("<p>top</p>", home);
|
|
Assert.Contains("<p>self reply</p>", home);
|
|
Assert.DoesNotContain("<p>dave alone</p>", home);
|
|
Assert.DoesNotContain("<p>reply to a stranger</p>", home);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reply_brings_its_public_parent_and_the_grandparent_is_queued()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (bobRoot, bob) = await _harness.Persona("bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
var dave = new RemoteActor(_harness.Peer, "dave");
|
|
await Accepted(bobRoot, bob.Id, carol);
|
|
var origin = new Uri(dave.Id).GetLeftPart(UriPartial.Authority);
|
|
var grandparentId = $"{origin}/notes/{Guid.NewGuid():N}";
|
|
var parentId = $"{origin}/notes/{Guid.NewGuid():N}";
|
|
_harness.Peer.Serve(new Uri(parentId).AbsolutePath, new JsonObject
|
|
{
|
|
["id"] = parentId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>parent</p>",
|
|
["to"] = new JsonArray(Addressing.Public), ["inReplyTo"] = grandparentId
|
|
}.ToJsonString());
|
|
|
|
await _harness.Deliver(carol, "/human-centipede", Create(carol, parentId, "reply to dave"));
|
|
|
|
var parent = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.ObjectURI == parentId).ExecuteSingleAsync(token);
|
|
var reply = await DB.Default.Find<PrivaPub.Models.Post.Post>().Match(p => p.InReplyToURI == parentId).ExecuteSingleAsync(token);
|
|
Assert.Equal(parent.ID, reply.AnsweringToPostId);
|
|
Assert.Equal(parent.AuthorAccountId, reply.InReplyToAccountId);
|
|
Assert.True(await DB.Default.Find<PrivaPub.Models.Jobs.Job>().Match(j => j.DedupeKey == "ancestors|" + parent.ID).ExecuteAnyAsync(token));
|
|
Assert.DoesNotContain("<p>reply to dave</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
|
|
Assert.DoesNotContain("<p>parent</p>", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_remote_delete_takes_the_post_out_of_home()
|
|
{
|
|
var (bobRoot, bob) = await _harness.Persona("bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
await Accepted(bobRoot, bob.Id, carol);
|
|
var create = Create(carol, text: "soon gone");
|
|
await _harness.Deliver(carol, "/human-centipede", create);
|
|
Assert.Single(await Home(bobRoot, bob.Id));
|
|
|
|
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = $"{new Uri(carol.Id).GetLeftPart(UriPartial.Authority)}/deletes/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = carol.Id,
|
|
["object"] = new JsonObject { ["id"] = create["object"]!["id"]!.GetValue<string>(), ["type"] = "Tombstone" }
|
|
});
|
|
|
|
Assert.Empty(await Home(bobRoot, bob.Id));
|
|
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == bob.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_mention_notifies_the_persona()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (bobRoot, bob) = await _harness.Persona("bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
|
|
await _harness.Deliver(carol, "/human-centipede", Create(carol, mentions: (bob.Uri, "@" + bob.Handle)));
|
|
|
|
var notifications = (List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data;
|
|
var mention = Assert.Single(notifications);
|
|
Assert.Equal("Mention", mention.Type);
|
|
Assert.Equal(carol.Id, mention.FromActorURI);
|
|
await _harness.Timelines.MarkNotificationsRead(bobRoot, bob.Id, token);
|
|
Assert.True(((List<ViewNotification>)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data).Single().IsRead);
|
|
}
|
|
}
|
|
}
|