v1 notifications, and those a stream tells, carried an 'ungrouped' key, so a client reading grouped notifications could not fold a new like into the group of likes it already shows. Each now names the group it belongs to by default (likes and boosts of one post, follows within an hour). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
266 lines
12 KiB
C#
266 lines
12 KiB
C#
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<string>(), 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<string>(), 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<Favourite>().Match(f => f.ActorURI == fan.Id).ExecuteAnyAsync(token));
|
|
Assert.False(await DB.Default.Find<Notification>().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<string>();
|
|
using var client = _host.As(joinerRoot.Jwt);
|
|
|
|
var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = group["invitationCode"]!.GetValue<string>() });
|
|
|
|
Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
|
|
Assert.Equal(1, (await _client.Fetch($"/peasants/{groupName}/groupies")).Json["totalItems"]!.GetValue<int>());
|
|
Assert.True(await DB.Default.Find<Following>().Match(f => f.AvatarId == joiner.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
public static TheoryData<string> 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);
|
|
// told alone (v1, a stream), each notification names the group it belongs to
|
|
Assert.All((await author.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array, n => Assert.Equal(key, n.Text("group_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<GroupAnnouncement>().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<string>());
|
|
Assert.Equal(announcement.ActivityURI, fetched.Json["id"]!.GetValue<string>());
|
|
}
|
|
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<PrivaPub.Federation.Actors.IRemoteActorService>();
|
|
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);
|
|
}
|
|
}
|
|
}
|