Everything on, phase 4b: ids that resolve, a hashtag page, grouped notifications, remote accounts' real counts
- A community's announces resolve, as FEDERATION.md and ROADMAP always said Announce ids do. GroupDistributor keeps
each one it sends (GroupAnnouncement, unique by id). /grunts serves it while the post it carries is shown and 410
after, and also serves the announce-{postId} ids the group outbox lists, which now keep a stable `published`
instead of the time of the fetch.
- A persona's boost points at the boosted post: its `url` in the Mastodon API is the boosted post's page, and a browser
following the boost's id is redirected there instead of getting JSON.
- /tags/{tag}, where every Hashtag link we send points, is now a public page of this server's public posts with that
tag, with the same strict CSP and noindex as the profile pages.
- Grouped notifications (/api/v2/notifications, its unread count, a group, its accounts and dismiss). We advertise
api_versions.mastodon = 7 so clients show quotes, and clients that trust it call these; they answered 404. Likes and
boosts of one post group together, as do follows within an hour. The version string stays 4.2.0 until streaming
and Web Push exist.
- A remote account's follower, following and post counts are what its server publishes. AccountCountsJob reads its
collections' totalItems from its own origin, signed by the instance actor, at most daily and only after the account
was fetched, never when someone looks. They used to be 0.
- The other ids that do not resolve are documented as such: Update, Delete, EmojiReact, QuoteRequest and its answers,
Flag, Ignore and poll votes.
676 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
8f25bf056d
commit
e2f61ede56
18 files changed
+452
-17
No files matched your search
@@ -151,6 +151,105 @@ namespace PrivaPub.Tests.Http
|
||||
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<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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user