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 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); } } }