using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; namespace PrivaPub.Tests.Http { // Mastodon's lists: a persona's own groups of accounts it follows, read as timelines, never federated [Trait("Category", "Integration")] public sealed class MastodonListsTests : IAsyncLifetime { PrivaPubHost _host; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; static async Task Follow(Mastodon follower, Mastodon target) => (await follower.Client.Post($"/api/v1/accounts/{target.Id}/follow")).Ok(); static async Task List(Mastodon owner, string title, params (string Key, string Value)[] more) => (await owner.Client.Post("/api/v1/lists", more.Prepend(("title", title)).ToArray())).Ok().Object.Text("id"); [Fact] public async Task A_list_is_created_renamed_and_deleted_by_its_owner_only() { var alice = await _host.Mastodon("alice"); var mallory = await _host.Mastodon("mallory"); var created = (await alice.Client.Post("/api/v1/lists", ("title", "Friends"), ("replies_policy", "followed"), ("exclusive", "true"))).Ok().Object; var id = created.Text("id"); Assert.Equal("Friends", created.Text("title")); Assert.Equal("followed", created.Text("replies_policy")); Assert.True(created.Flag("exclusive")); var other = await List(alice, "Art"); Assert.Equal(new[] { other, id }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids); var renamed = (await alice.Client.Put($"/api/v1/lists/{id}", ("title", "Close friends"), ("exclusive", "false"))).Ok().Object; Assert.Equal("Close friends", renamed.Text("title")); Assert.Equal("followed", renamed.Text("replies_policy")); Assert.False(renamed.Flag("exclusive")); Assert.Equal("Close friends", (await alice.Client.Get($"/api/v1/lists/{id}")).Ok().Object.Text("title")); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", " "))).Status); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/lists", ("title", "x"), ("replies_policy", "everyone"))).Status); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put($"/api/v1/lists/{id}", ("title", ""))).Status); Assert.Empty((await mallory.Client.Get("/api/v1/lists")).Ok().Array); Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/lists/{id}")).Status); Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Put($"/api/v1/lists/{id}", ("title", "mine"))).Status); Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/lists/{id}")).Status); Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/timelines/list/{id}")).Status); Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/lists")).Status); (await alice.Client.Delete($"/api/v1/lists/{id}")).Ok(); Assert.Equal(new[] { other }, (await alice.Client.Get("/api/v1/lists")).Ok().Ids); Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/lists/{id}")).Status); } [Fact] public async Task Only_followed_accounts_join_a_list_and_its_timeline_shows_their_posts() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); var carol = await _host.Mastodon("carol"); var dave = await _host.Mastodon("dave"); await Follow(alice, bob); await Follow(alice, dave); var list = await List(alice, "Bob"); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", carol.Id))).Status); (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); Assert.Equal(new[] { bob.Id }, (await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Ids); Assert.Equal(new[] { list }, (await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Ids); Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{dave.Id}/lists")).Ok().Array); var fromBob = (await bob.Status("from bob")).Text("id"); await dave.Status("from dave"); await carol.Status("from carol"); Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids); (await alice.Client.Delete($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); Assert.Empty((await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Array); } [Theory] [InlineData("list", true, false, false)] [InlineData("followed", true, true, false)] [InlineData("none", true, false, false)] public async Task A_lists_replies_follow_its_policy(string policy, bool toSelf, bool toFollowed, bool toStranger) { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); var dave = await _host.Mastodon("dave"); var carol = await _host.Mastodon("carol"); await Follow(alice, bob); await Follow(alice, dave); var list = await List(alice, "Bob", ("replies_policy", policy)); (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); var root = (await bob.Status("a thread")).Text("id"); var mine = (await alice.Status("asking bob")).Text("id"); var ofDave = (await dave.Status("dave's")).Text("id"); var ofCarol = (await carol.Status("carol's")).Text("id"); var self = (await bob.Status("and more", ("in_reply_to_id", root))).Text("id"); var toAlice = (await bob.Status($"@{alice.UserName} answered", ("in_reply_to_id", mine))).Text("id"); var toDave = (await bob.Status($"@{dave.UserName} agreed", ("in_reply_to_id", ofDave))).Text("id"); var toCarol = (await bob.Status($"@{carol.UserName} hm", ("in_reply_to_id", ofCarol))).Text("id"); var shown = (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids.ToList(); Assert.Contains(root, shown); Assert.Equal(toSelf, shown.Contains(self)); Assert.Contains(toAlice, shown); Assert.Equal(toFollowed, shown.Contains(toDave)); Assert.Equal(toStranger, shown.Contains(toCarol)); } [Fact] public async Task An_exclusive_lists_members_are_read_there_and_not_at_home() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); var dave = await _host.Mastodon("dave"); await Follow(alice, bob); await Follow(alice, dave); var list = await List(alice, "Apart", ("exclusive", "true")); (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); var fromBob = (await bob.Status("from bob")).Text("id"); var fromDave = (await dave.Status("from dave")).Text("id"); var home = (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids.ToList(); Assert.Contains(fromDave, home); Assert.DoesNotContain(fromBob, home); Assert.Equal(new[] { fromBob }, (await alice.Client.Get($"/api/v1/timelines/list/{list}")).Ok().Ids); (await alice.Client.Put($"/api/v1/lists/{list}", ("exclusive", "false"))).Ok(); Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); } [Fact] public async Task An_unfollowed_account_leaves_the_personas_lists_and_does_not_come_back_with_a_new_follow() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); await Follow(alice, bob); var list = await List(alice, "Bob", ("exclusive", "true")); (await alice.Client.Post($"/api/v1/lists/{list}/accounts", ("account_ids[]", bob.Id))).Ok(); (await alice.Client.Post($"/api/v1/accounts/{bob.Id}/unfollow")).Ok(); Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{bob.Id}/lists")).Ok().Array); await Follow(alice, bob); var fromBob = (await bob.Status("back again")).Text("id"); Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); Assert.Empty((await alice.Client.Get($"/api/v1/lists/{list}/accounts")).Ok().Array); } } }