Files
thepraandClaude Opus 5.5 ce473f2741 Follow requests asked again, and followed accounts found by name
A server can take a Follow with 202 and drop it afterwards, as Pleroma does
while it cannot fetch our actor; the request then stayed pending for good.
Following again now sends an unanswered request once more, the same
activity, at most once an hour (a delivery's `again` key).

accounts/search takes following=true: only accounts the persona follows,
by the start of their name, display name or server, never resolved; a
client fills a list with it. "already take" becomes "already taken".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 02:40:40 +02:00

180 lines
8.8 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 Account_search_with_following_finds_only_followed_accounts()
{
var alice = await _host.Mastodon("alice");
var prefix = $"ls{Guid.NewGuid():N}"[..10];
var followed = await _host.Mastodon(prefix + "a");
var stranger = await _host.Mastodon(prefix + "b");
await Follow(alice, followed);
var found = (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Ids.ToList();
Assert.Equal(new[] { followed.Id }, found);
Assert.Contains(stranger.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}")).Ok().Ids);
Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Array);
}
[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);
}
}
}