Mastodon's lists replace the empty stubs: CRUD, members (only accounts the persona follows; a follow that ends takes its memberships with it), accounts/:id/lists, and timelines/list/:id from the persona's home entries with the replies policy (followed, list, none; self-replies and replies to the persona always). An exclusive list's members stay out of home. Lists never federate, and go with a deleted persona. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
165 lines
8.1 KiB
C#
165 lines
8.1 KiB
C#
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<string> 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);
|
|
}
|
|
}
|
|
}
|