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> Home(string root, string avatarId) => (List)(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().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"] = $"

{text}

", ["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("

for my followers

", 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(), "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("

top

", home); Assert.Contains("

self reply

", home); Assert.DoesNotContain("

dave alone

", home); Assert.DoesNotContain("

reply to a stranger

", 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"] = "

parent

", ["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().Match(p => p.ObjectURI == parentId).ExecuteSingleAsync(token); var reply = await DB.Default.Find().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().Match(j => j.DedupeKey == "ancestors|" + parent.ID).ExecuteAnyAsync(token)); Assert.DoesNotContain("

reply to dave

", (await Home(bobRoot, bob.Id)).Select(p => p.ContentHtml)); Assert.DoesNotContain("

parent

", (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)(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)(await _harness.Timelines.Notifications(bobRoot, bob.Id, default, 20, token)).Data).Single().IsRead); } } }