Fanout writes a TimelineEntry for every persona a post should reach: the author, local followers of a local author, local followers of a remote one, the members of a direct conversation or a circle. Mastodon's home rules apply when it is written: a reply shows only to followers of both sides (or to the one replied to), a reblog only where reblogs are wanted. A mention of a local persona becomes a Mention notification. Inbound posts are now also kept when a persona follows their author. RemotePosts holds what CreateHandler and backfill share: building a Post from a note, and fetching a public parent the first time a reply to it arrives, so its author is known (a reply to an unknown or non-public parent stays out of home timelines). Each fetched ancestor queues a FetchAncestors job for the next one, up to ten deep. /clientapi/timeline/home and /clientapi/notifications (with /notifications/read) page by max_id for the persona's own root only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
143 lines
6.5 KiB
C#
143 lines
6.5 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_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);
|
|
}
|
|
}
|
|
}
|