using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Http { [Trait("Category", "Integration")] public sealed class MastodonTimelinesTests : IAsyncLifetime { PrivaPubHost _host; Peer _peer; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { if (_peer != default) await _peer.DisposeAsync(); } [Fact] public async Task The_home_timeline_pages_with_max_since_and_min_id() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); var posts = new List(); for (var i = 1; i <= 4; i++) posts.Add((await alice.Status($"post {i}")).Text("id")); (await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); var afterFollow = (await alice.Status("post 5")).Text("id"); var (p1, p2, p3, p4) = (posts[0], posts[1], posts[2], posts[3]); var first = (await alice.Client.Get("/api/v1/timelines/home?limit=2")).Ok(); Assert.Equal(new[] { afterFollow, p4 }, first.Ids); Assert.Equal(new[] { p3, p2 }, (await alice.Client.Get(first.Link("next"))).Ok().Ids); Assert.Empty((await alice.Client.Get(first.Link("prev"))).Ok().Array); Assert.Equal(new[] { p2, p1 }, (await alice.Client.Get($"/api/v1/timelines/home?max_id={p3}&limit=2")).Ok().Ids); Assert.Equal(new[] { afterFollow, p4 }, (await alice.Client.Get($"/api/v1/timelines/home?since_id={p1}&limit=2")).Ok().Ids); Assert.Equal(new[] { p3, p2 }, (await alice.Client.Get($"/api/v1/timelines/home?min_id={p1}&limit=2")).Ok().Ids); Assert.Equal(new[] { p3 }, (await alice.Client.Get($"/api/v1/timelines/home?min_id={p2}&max_id={p4}")).Ok().Ids); Assert.Equal(new[] { afterFollow }, (await bob.Client.Get("/api/v1/timelines/home")).Ok().Ids); Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/timelines/home")).Status); } static async Task> Window(HttpClient client, string filter, string since, string until) { var ids = new List(); for (var page = 0; page < 50; page++) { var batch = (await client.Get($"/api/v1/timelines/public?{filter}&since_id={since}&max_id={until}&limit=40")).Ok().Ids.ToList(); if (batch.Count == 0) break; ids.AddRange(batch); until = batch[^1]; } return ids; } [Fact] public async Task The_public_timeline_splits_local_from_remote_and_leaves_out_unlisted_posts() { var alice = await _host.Mastodon("alice"); var bob = new RemoteActor(_peer, "bob"); var since = MastodonHelpers.IdBefore(DateTime.UtcNow); var remote = await _host.PublicPostFrom(bob, alice); var open = (await alice.Status("hello world")).Text("id"); var unlisted = (await alice.Status("hello quietly", ("visibility", "unlisted"))).Text("id"); var until = MastodonHelpers.IdBefore(DateTime.UtcNow.AddSeconds(1)); var anonymous = _host.Client(); var everything = await Window(anonymous, "local=false", since, until); Assert.Contains(open, everything); Assert.Contains(remote.ID, everything); Assert.DoesNotContain(unlisted, everything); var local = await Window(anonymous, "local=true", since, until); Assert.Contains(open, local); Assert.DoesNotContain(remote.ID, local); var federated = await Window(alice.Client, "remote=true", since, until); Assert.Contains(remote.ID, federated); Assert.DoesNotContain(open, federated); } [Fact] public async Task The_tag_timeline_is_open_to_anonymous_readers_and_shows_only_public_posts() { var alice = await _host.Mastodon("alice"); var tag = $"t{Guid.NewGuid():N}"[..16]; var open = (await alice.Status($"about #{tag}")).Text("id"); await alice.Status($"quietly about #{tag}", ("visibility", "unlisted")); await alice.Status($"privately about #{tag}", ("visibility", "private")); var later = (await alice.Status($"more about #{tag.ToUpperInvariant()}")).Text("id"); var tagged = (await _host.Client().Get($"/api/v1/timelines/tag/{tag}")).Ok(); Assert.Equal(new[] { later, open }, tagged.Ids); Assert.Equal(new[] { later }, (await _host.Client().Get($"/api/v1/timelines/tag/{tag}?local=true&limit=1")).Ok().Ids); Assert.Equal(new[] { later, open }, (await alice.Client.Get($"/api/v1/timelines/tag/%23{tag}")).Ok().Ids); } [Fact] public async Task A_list_timeline_is_an_empty_stub() { var alice = await _host.Mastodon("alice"); Assert.Empty((await alice.Client.Get("/api/v1/timelines/list/anything")).Ok().Array); } [Fact] public async Task Favourites_list_what_the_persona_liked_newest_first() { var alice = await _host.Mastodon("alice"); var carol = await _host.Mastodon("carol"); var first = (await carol.Status("one")).Text("id"); var second = (await carol.Status("two")).Text("id"); (await alice.Client.Post($"/api/v1/statuses/{first}/favourite")).Ok(); (await alice.Client.Post($"/api/v1/statuses/{second}/favourite")).Ok(); Assert.Equal(new[] { second, first }, (await alice.Client.Get("/api/v1/favourites")).Ok().Ids); var page = (await alice.Client.Get("/api/v1/favourites?limit=1")).Ok(); Assert.Equal(new[] { second }, page.Ids); Assert.Equal(new[] { first }, (await alice.Client.Get(page.Link("next"))).Ok().Ids); Assert.Empty((await carol.Client.Get("/api/v1/favourites")).Ok().Array); } [Fact] public async Task Conversations_list_direct_messages_to_their_participants_only() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); var stranger = await _host.Mastodon("stranger"); (await alice.Status($"@{bob.UserName} psst", ("visibility", "direct"))).Text("id"); var answer = (await bob.Status($"@{alice.UserName} what?", ("visibility", "direct"))).Text("id"); var mine = Assert.Single((await alice.Client.Get("/api/v1/conversations")).Ok().Array); Assert.Equal(new[] { bob.Id }, mine!["accounts"]!.AsArray().Select(a => a.Text("id"))); Assert.Equal(answer, mine["last_status"].Text("id")); var theirs = Assert.Single((await bob.Client.Get("/api/v1/conversations")).Ok().Array); Assert.Equal(mine.Text("id"), theirs.Text("id")); Assert.Equal(new[] { alice.Id }, theirs!["accounts"]!.AsArray().Select(a => a.Text("id"))); Assert.Empty((await stranger.Client.Get("/api/v1/conversations")).Ok().Array); var read = (await bob.Client.Post($"/api/v1/conversations/{mine.Text("id")}/read")).Ok(); Assert.Equal(mine.Text("id"), read.Body.Text("id")); Assert.Equal(answer, read.Body["last_status"].Text("id")); Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/conversations/{mine.Text("id")}/read")).Status); Assert.DoesNotContain(answer, (await stranger.Client.Get($"/api/v1/accounts/{bob.Id}/statuses")).Ok().Ids); } [Fact] public async Task Markers_are_saved_versioned_and_read_back_per_timeline() { var alice = await _host.Mastodon("alice"); var saved = (await alice.Client.Post("/api/v1/markers", ("home[last_read_id]", "100"))).Ok(); Assert.Equal("100", saved.Body["home"].Text("last_read_id")); Assert.Equal(1, saved.Body["home"].Number("version")); Assert.Null(saved.Body["notifications"]); var again = (await alice.Client.Json(HttpMethod.Post, "/api/v1/markers", new JsonObject { ["home"] = new JsonObject { ["last_read_id"] = "200" }, ["notifications"] = new JsonObject { ["last_read_id"] = "7" } })).Ok(); Assert.Equal(("200", 2), (again.Body["home"].Text("last_read_id"), again.Body["home"].Number("version"))); Assert.Equal(("7", 1), (again.Body["notifications"].Text("last_read_id"), again.Body["notifications"].Number("version"))); var home = (await alice.Client.Get("/api/v1/markers?timeline[]=home")).Ok().Object; Assert.Equal(new[] { "home" }, home.Select(p => p.Key)); Assert.Equal("200", home["home"].Text("last_read_id")); Assert.Equal(new[] { "home", "notifications" }, (await alice.Client.Get("/api/v1/markers")).Ok().Object.Select(p => p.Key).Order()); Assert.Empty((await (await _host.Mastodon("other")).Client.Get("/api/v1/markers")).Ok().Object); } [Fact] public async Task A_notification_about_a_deleted_post_is_gone_not_null() { var alice = await _host.Mastodon("alice"); var carol = await _host.Mastodon("carol"); var post = (await alice.Status("short lived")).Text("id"); (await carol.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); var id = Assert.Single((await alice.Client.Get("/api/v1/notifications")).Ok().Array).Text("id"); (await alice.Client.Delete($"/api/v1/statuses/{post}")).Ok(); Assert.Empty((await alice.Client.Get("/api/v1/notifications")).Ok().Array); var gone = await alice.Client.Get($"/api/v1/notifications/{id}"); Assert.Equal(HttpStatusCode.NotFound, gone.Status); Assert.Equal("Record not found", gone.Body.Text("error")); } [Fact] public async Task Notifications_filter_page_dismiss_and_clear() { var alice = await _host.Mastodon("alice"); var carol = await _host.Mastodon("carol"); var post = (await alice.Status("notice me")).Text("id"); (await carol.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok(); (await carol.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); var mention = (await carol.Status($"hey @{alice.UserName}")).Text("id"); (await carol.Client.Post($"/api/v1/statuses/{post}/reblog")).Ok(); var all = (await alice.Client.Get("/api/v1/notifications")).Ok(); Assert.Equal(new[] { "reblog", "mention", "favourite", "follow" }, all.Array.Select(n => n.Text("type"))); Assert.All(all.Array, n => Assert.Equal(carol.Id, n!["account"].Text("id"))); Assert.Equal(mention, all.Array[1]!["status"].Text("id")); Assert.Equal(new[] { "mention" }, (await alice.Client.Get("/api/v1/notifications?types[]=mention")).Ok().Array.Select(n => n.Text("type"))); Assert.Equal(new[] { "mention", "favourite" }, (await alice.Client.Get("/api/v1/notifications?exclude_types[]=follow&exclude_types[]=reblog")).Ok().Array.Select(n => n.Text("type"))); Assert.Equal(4, (await alice.Client.Get($"/api/v1/notifications?account_id={carol.Id}")).Ok().Array.Count); var page = (await alice.Client.Get("/api/v1/notifications?limit=3")).Ok(); Assert.Equal(new[] { "follow" }, (await alice.Client.Get(page.Link("next"))).Ok().Array.Select(n => n.Text("type"))); Assert.Equal(4, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); var followId = all.Array[3].Text("id"); Assert.Equal("follow", (await alice.Client.Get($"/api/v1/notifications/{followId}")).Ok().Body.Text("type")); Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Get($"/api/v1/notifications/{followId}")).Status); (await carol.Client.Post($"/api/v1/notifications/{followId}/dismiss")).Ok(); Assert.Equal(HttpStatusCode.OK, (await alice.Client.Get($"/api/v1/notifications/{followId}")).Status); (await alice.Client.Post($"/api/v1/notifications/{followId}/dismiss")).Ok(); Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/notifications/{followId}")).Status); Assert.Equal(3, (await alice.Client.Get("/api/v1/notifications")).Ok().Array.Count); (await alice.Client.Post("/api/v1/notifications/clear")).Ok(); Assert.Empty((await alice.Client.Get("/api/v1/notifications")).Ok().Array); Assert.Equal(0, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); } } }