using MongoDB.Entities; using PrivaPub.ClientModels.Post; using PrivaPub.ClientModels.Social; using PrivaPub.Federation.Objects; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Federation { [Trait("Category", "Integration")] public sealed class InteractionTests : 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<(string Root, Post Post)> LocalPost(string visibility = default) { var (root, alice) = await _harness.Persona("alice"); var view = (ViewPost)(await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "like me", Visibility = visibility }, TestContext.Current.CancellationToken)).Data; return (root, await DB.Default.Find().OneAsync(view.Id)); } static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); [Fact] public async Task A_like_counts_once_notifies_and_can_be_undone() { var token = TestContext.Current.CancellationToken; var (_, post) = await LocalPost(); var carol = new RemoteActor(_harness.Peer, "carol"); var like = new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI }; await _harness.Deliver(carol, "/human-centipede", like); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI }); Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); Assert.Equal(NotificationType.Favourite, (await DB.Default.Find().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = like }); Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); } [Fact] public async Task A_stranger_cannot_like_a_followers_only_post() { var token = TestContext.Current.CancellationToken; var (_, post) = await LocalPost("followersonly"); var mallory = new RemoteActor(_harness.Peer, "mallory"); await _harness.Deliver(mallory, "/human-centipede", new JsonObject { ["id"] = $"{Origin(mallory)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = mallory.Id, ["object"] = post.ObjectURI }); Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); } // Our followers-only posts are addressed to the followers collection, never to each follower, so a follower is found // among our followers, not in to/cc. The town found every follower's like, reaction and downvote dropped (G-0001). [Fact] public async Task A_follower_likes_reacts_to_and_downvotes_a_followers_only_post() { var token = TestContext.Current.CancellationToken; var (_, post) = await LocalPost("followersonly"); var carol = new RemoteActor(_harness.Peer, "carol"); await _harness.FollowedBy(_harness.Local.FromAvatar(await DB.Default.Find().OneAsync(post.GroupUserId, token)), carol); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI }); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI, ["content"] = "🔥" }); Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/dislikes/{Guid.NewGuid():N}", ["type"] = "Dislike", ["actor"] = carol.Id, ["object"] = post.ObjectURI }); // the downvote counts, and takes the place of carol's like (one vote each, as Lemmy votes) var stored = await DB.Default.Find().OneAsync(post.ID, token); Assert.Equal(0, stored.FavouritesCount); Assert.Equal(1, stored.DownvotesCount); Assert.True(await DB.Default.Find().Match(r => r.PostId == post.ID && r.ActorURI == carol.Id && r.Emoji == "🔥").ExecuteAnyAsync(token)); } [Fact] public async Task A_vote_turned_the_other_way_replaces_the_first_as_lemmy_sends_it() { var token = TestContext.Current.CancellationToken; var (_, post) = await LocalPost(); var voter = new RemoteActor(_harness.Peer, "voter"); var other = new RemoteActor(_harness.Peer, "other"); JsonObject Vote(RemoteActor actor, string type) => new() { ["id"] = $"{Origin(actor)}/{type.ToLowerInvariant()}s/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = post.ObjectURI }; await _harness.Deliver(other, "/human-centipede", Vote(other, "Like")); await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike")); await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Like")); var up = await DB.Default.Find().OneAsync(post.ID, token); Assert.Equal((2, 0), (up.FavouritesCount, up.DownvotesCount)); await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike")); var down = await DB.Default.Find().OneAsync(post.ID, token); Assert.Equal((1, 1), (down.FavouritesCount, down.DownvotesCount)); Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token)); } [Fact] public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var carol = new RemoteActor(_harness.Peer, "carol"); var mallory = new RemoteActor(_harness.Peer, "mallory"); var circle = new Group { UserName = $"circle{Guid.NewGuid():N}"[..20], Name = "a circle", Kind = GroupKind.Circle, OwnerAvatarId = alice.Id, Members = { new() { AvatarId = alice.Id }, new() { AvatarId = carol.Id, IsForeign = true } } }; await DB.Default.SaveAsync(circle, token); var post = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, GroupId = circle.ID, Visibility = PostVisibility.Circle, Text = "only us", ObjectURI = $"{alice.Uri}/scribbles/{Guid.NewGuid():N}" }; await DB.Default.SaveAsync(post, token); foreach (var who in new[] { carol, mallory }) await _harness.Deliver(who, "/human-centipede", new JsonObject { ["id"] = $"{Origin(who)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = who.Id, ["object"] = post.ObjectURI }); Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); Assert.True(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == carol.Id).ExecuteAnyAsync(token)); } [Fact] public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone() { var token = TestContext.Current.CancellationToken; var (_, post) = await LocalPost(); var carol = new RemoteActor(_harness.Peer, "carol"); var announce = new JsonObject { ["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = post.ObjectURI, ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(carol.Id + "/followers") }; await _harness.Deliver(carol, "/human-centipede", announce); Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).ReblogsCount); Assert.Equal(NotificationType.Reblog, (await DB.Default.Find().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = announce["id"]!.GetValue() }); Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).ReblogsCount); Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == announce["id"]!.GetValue()).ExecuteAnyAsync(token)); } [Fact] public async Task A_followed_account_boosting_a_remote_post_puts_it_in_home() { 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"); var stranger = new RemoteActor(_harness.Peer, "stranger"); await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = carol.Id }, token); await DB.Default.Update().Match(f => f.AvatarId == bob.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); var noteId = $"{Origin(dave)}/notes/{Guid.NewGuid():N}"; _harness.Peer.Serve(new Uri(noteId).AbsolutePath, new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "

worth sharing

", ["to"] = new JsonArray(Addressing.Public) }.ToJsonString()); await _harness.Deliver(stranger, "/human-centipede", new JsonObject { ["id"] = $"{Origin(stranger)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = stranger.Id, ["object"] = noteId, ["to"] = new JsonArray(Addressing.Public) }); Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token)); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = noteId, ["to"] = new JsonArray(Addressing.Public) }); var home = (List)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data; var boosted = Assert.Single(home); Assert.Equal("

worth sharing

", boosted.ReblogOf.ContentHtml); Assert.Equal(carol.Id, boosted.AuthorActorURI); } } }