using MongoDB.Entities; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text.Json.Nodes; using static PrivaPub.Tests.Support.Host.FederationHelpers; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Tests.Http { // Two views of the same fact give one answer (owner decision 2026-10-04: fix the mismatches). [Trait("Category", "Integration")] public sealed class OneAnswerTests : IAsyncLifetime { PrivaPubHost _host; HttpClient _client; Peer _peer; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _client = _host.Client(); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { _client?.Dispose(); if (_peer != default) await _peer.DisposeAsync(); } [Fact] public async Task Anyone_may_search_and_only_a_signed_in_reader_resolves_or_pages() { var reader = await _host.Mastodon("searcher"); Assert.Equal(HttpStatusCode.OK, (await _client.Get("/api/v2/search?q=somebody")).Status); Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&resolve=true")).Status); Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&offset=5")).Status); Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v2/search?q=somebody&offset=5")).Status); } [Fact] public async Task A_remote_account_that_deletes_itself_leaves_nothing_in_the_counts() { var token = TestContext.Current.CancellationToken; var author = await _host.Mastodon("liked"); var post = await author.Status("like me"); var fan = new RemoteActor(_peer, "fickle"); var like = new JsonObject { ["id"] = $"{fan.Id}#likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = fan.Id, ["object"] = post.Text("uri") }; Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", like), token)).StatusCode); await _host.RunInbox(like["id"]!.GetValue(), token); Assert.Equal(1, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count")); var delete = new JsonObject { ["id"] = $"{fan.Id}#delete", ["type"] = "Delete", ["actor"] = fan.Id, ["object"] = fan.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public") }; Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", delete), token)).StatusCode); await _host.RunInbox(delete["id"]!.GetValue(), token); Assert.Equal(0, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count")); Assert.Empty((await author.Client.Get($"/api/v1/statuses/{post.Text("id")}/favourited_by")).Ok().Array); Assert.False(await DB.Default.Find().Match(f => f.ActorURI == fan.Id).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find().Match(n => n.FromActorURI == fan.Id).ExecuteAnyAsync(token)); Assert.Equal(0, (await author.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); } [Fact] public async Task Only_public_and_unlisted_replies_are_counted() { var author = await _host.Mastodon("threadstart"); var replier = await _host.Mastodon("replier"); var root = await author.Status("say something"); var id = root.Text("id"); await replier.Status("in public", ("in_reply_to_id", id)); await replier.Status("quietly", ("in_reply_to_id", id), ("visibility", "unlisted")); await replier.Status("for my followers", ("in_reply_to_id", id), ("visibility", "private")); await replier.Status($"@{author.UserName} just you", ("in_reply_to_id", id), ("visibility", "direct")); Assert.Equal(2, (await author.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Number("replies_count")); } [Fact] public async Task A_status_longer_than_advertised_is_refused() { var author = await _host.Mastodon("wordy"); var limit = (await _client.Get("/api/v2/instance")).Ok().Body["configuration"]!["statuses"].Number("max_characters"); var refused = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit + 1))); var accepted = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit))); Assert.Equal(HttpStatusCode.UnprocessableEntity, refused.Status); Assert.Equal(HttpStatusCode.OK, accepted.Status); } [Fact] public async Task Joining_a_community_by_invitation_is_following_it() { var token = TestContext.Current.CancellationToken; var owner = await _host.Persona(await _host.SignUp(), "commowner"); var joinerRoot = await _host.SignUp("joiner"); var joiner = await _host.Persona(joinerRoot, "joiner"); var group = await _host.Group(owner, community: true); var groupName = group["userName"]!.GetValue(); using var client = _host.As(joinerRoot.Jwt); var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = group["invitationCode"]!.GetValue() }); Assert.Equal(HttpStatusCode.OK, joined.StatusCode); Assert.Equal(1, (await _client.Fetch($"/peasants/{groupName}/groupies")).Json["totalItems"]!.GetValue()); Assert.True(await DB.Default.Find().Match(f => f.AvatarId == joiner.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)); } public static TheoryData AnsweredRoutes() => new() { "/api/v1/directory", "/api/v1/tags/cats", "/api/v1/instance/languages", "/api/v1/instance/translation_languages", "/api/v1/instance/domain_blocks", "/api/v1/instance/privacy_policy", "/api/v1/timelines/link?url=https%3A%2F%2Fexample.org%2F", "/api/v1/accounts/000000000000000000000000/identity_proofs" }; [Theory] [MemberData(nameof(AnsweredRoutes))] public async Task Mastodon_routes_we_have_nothing_behind_answer_instead_of_404(string path) { Assert.Equal(HttpStatusCode.OK, (await _client.Get(path)).Status); } [Fact] public async Task Likes_of_one_post_come_as_one_group_of_notifications() { var author = await _host.Mastodon("grouped"); var first = await _host.Mastodon("fanone"); var second = await _host.Mastodon("fantwo"); var post = await author.Status("like me twice"); var id = post.Text("id"); await first.Client.Post($"/api/v1/statuses/{id}/favourite"); await second.Client.Post($"/api/v1/statuses/{id}/favourite"); var list = (await author.Client.Get("/api/v2/notifications")).Ok().Body; var group = Assert.Single(list["notification_groups"]!.AsArray(), g => g!.Text("type") == "favourite")!; var key = group.Text("group_key"); Assert.Equal($"grouped-favourite-{id}", key); Assert.Equal(2, group.Number("notifications_count")); Assert.Equal(2, group["sample_account_ids"]!.AsArray().Count); Assert.Equal(id, group.Text("status_id")); Assert.Contains(list["statuses"]!.AsArray(), s => s!.Text("id") == id); Assert.True((await author.Client.Get("/api/v2/notifications/unread_count")).Ok().Body.Number("count") >= 1); Assert.Equal(key, Assert.Single((await author.Client.Get($"/api/v2/notifications/{key}")).Ok().Body["notification_groups"]!.AsArray())!.Text("group_key")); Assert.Equal(2, (await author.Client.Get($"/api/v2/notifications/{key}/accounts")).Ok().Array.Count); Assert.Equal(HttpStatusCode.OK, (await author.Client.Post($"/api/v2/notifications/{key}/dismiss")).Status); Assert.Equal(HttpStatusCode.NotFound, (await author.Client.Get($"/api/v2/notifications/{key}")).Status); } [Fact] public async Task A_hashtag_link_opens_a_page_of_public_posts_with_that_tag() { var author = await _host.Mastodon("tagger"); var tag = $"tag{Guid.NewGuid():N}"[..16]; await author.Status($"about #{tag}"); await author.Status($"quietly about #{tag}", ("visibility", "private")); var page = await _client.Fetch($"/tags/{tag}", Browser); Assert.Equal(HttpStatusCode.OK, page.Status); Assert.Contains("about", page.Text); Assert.DoesNotContain("quietly", page.Text); Assert.Contains("default-src 'none'", page.Response.Headers.GetValues("Content-Security-Policy").Single()); } [Fact] public async Task A_communitys_announces_resolve_while_the_post_is_shown() { var token = TestContext.Current.CancellationToken; var owner = await _host.Persona(await _host.SignUp(), "announcer"); var community = await _host.FederatedGroup(owner, community: true); var post = await _host.Publish(owner, new PrivaPub.Domain.Statuses.StatusDraft { Text = "into the community", PlainText = true, GroupId = community.Id }); var kept = await DB.Default.Find().Match(a => a.GroupId == community.Id && a.ObjectURI == post.ObjectURI).ExecuteAsync(token); Assert.Equal(2, kept.Count);//the activity, and the object for Mastodon foreach (var announcement in kept) { var fetched = await _client.Fetch(PathOf(announcement.ActivityURI)); Assert.Equal(HttpStatusCode.OK, fetched.Status); Assert.Equal("Announce", fetched.Json["type"]!.GetValue()); Assert.Equal(announcement.ActivityURI, fetched.Json["id"]!.GetValue()); } var listed = await _client.Fetch($"/peasants/{community.UserName}/grunts/announce-{post.ID}"); Assert.Equal(HttpStatusCode.OK, listed.Status); await _host.Remove(owner, post); Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(PathOf(kept[0].ActivityURI))).Status); } [Fact] public async Task A_boost_points_at_the_boosted_posts_page() { var author = await _host.Mastodon("boosted"); var booster = await _host.Mastodon("booster"); var post = await author.Status("boost me"); var boost = (await booster.Client.Post($"/api/v1/statuses/{post.Text("id")}/reblog")).Ok().Body; Assert.Equal(post.Text("url"), boost.Text("url")); var browser = await _client.Fetch(PathOf(boost.Text("uri")), Browser); Assert.Equal(HttpStatusCode.Redirect, browser.Status); } [Fact] public async Task A_remote_accounts_counts_are_what_its_server_publishes() { var token = TestContext.Current.CancellationToken; var reader = await _host.Mastodon("counter"); var popular = new RemoteActor(_peer, "popular"); _peer.Serve(new Uri(popular.Id).AbsolutePath + "/followers", $$"""{"@context":"https://www.w3.org/ns/activitystreams","id":"{{popular.Id}}/followers","type":"OrderedCollection","totalItems":42}"""); var remote = _host.Get(); var account = await remote.GetActor(popular.Id, refresh: true, token); await _host.Run(j => j.Kind == PrivaPub.Models.Jobs.JobKind.CountAccount && j.Payload == popular.Id, token); var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body; Assert.Equal(42, shown.Number("followers_count")); Assert.Equal(0, shown.Number("following_count")); } [Fact] public async Task Notification_policy_and_requests_answer_a_signed_in_reader() { var reader = await _host.Mastodon("policy"); Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v1/notifications/policy")).Status); Assert.Equal("accept", (await reader.Client.Get("/api/v2/notifications/policy")).Ok().Body.Text("for_not_following")); Assert.Empty((await reader.Client.Get("/api/v1/notifications/requests")).Ok().Array); } } }