Following records what a local persona follows, local or remote, with
its state and the Follow activity's id. FollowService (behind
/clientapi/follow, /clientapi/unfollow and /clientapi/following):
- resolves @user, user@host or an actor URI;
- a local account is followed in-process: accepted unless it approves
followers by hand, a Follower row on the other side, a community gains
the persona as a member, and a Follow or FollowRequest notification;
- a remote account gets a Follow signed by the persona, at
/grunts/follow-{id}, and stays Requested until an Accept;
- unfollowing deletes the rows, or sends Undo{Follow} to a remote account.
Inbound Accept and Reject are matched to the Follow by its id (or, for an
embedded Follow without one, by its actor), and only from the account that
was followed. A remote Follow now notifies the persona too.
Notification is the per-persona record P1.2 builds on, deduplicated by
type, persona, sender and post. TimelineEntry and Favourite are created
with their indexes for the next commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
125 lines
5.1 KiB
C#
125 lines
5.1 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.Social;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Domain
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class FollowTests : IAsyncLifetime
|
|
{
|
|
Harness _harness;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_harness = await Harness.Start();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_harness != default)
|
|
await _harness.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_local_follow_is_accepted_at_once_and_notified()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var (_, bob) = await _harness.Persona("bob");
|
|
|
|
var result = await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = "@" + bob.UserName }, token);
|
|
|
|
Assert.True(result.IsValid);
|
|
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
|
|
Assert.Equal(FollowState.Accepted, following.State);
|
|
Assert.True(following.TargetIsLocal);
|
|
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == bob.Id && f.ActorURI == alice.Uri).ExecuteSingleAsync(token)).IsAccepted);
|
|
Assert.Equal(NotificationType.Follow, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == bob.Id).ExecuteSingleAsync(token)).Type);
|
|
Assert.Empty(await _harness.Outgoing(bob.Inbox));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Someone_elses_persona_cannot_be_made_to_follow()
|
|
{
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var (_, bob) = await _harness.Persona("bob");
|
|
|
|
var result = await _harness.Follows.Follow("someone-else", new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, TestContext.Current.CancellationToken);
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(404, result.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_remote_follow_is_requested_then_accepted()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
|
|
|
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
|
|
Assert.Equal(FollowState.Requested, following.State);
|
|
var follow = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
|
|
Assert.Equal("Follow", follow["type"]!.GetValue<string>());
|
|
Assert.Equal(alice.Uri, follow["actor"]!.GetValue<string>());
|
|
Assert.StartsWith(alice.Uri + "/grunts/follow-", follow["id"]!.GetValue<string>());
|
|
|
|
var accept = new JsonObject
|
|
{
|
|
["id"] = $"{carol.Id}#accepts/{Guid.NewGuid():N}",
|
|
["type"] = "Accept",
|
|
["actor"] = carol.Id,
|
|
["object"] = follow.DeepClone()
|
|
};
|
|
Assert.Equal(202, (await _harness.Deliver(carol, $"/peasants/{alice.UserName}/mouth", accept)).StatusCode);
|
|
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_accept_from_someone_else_changes_nothing_and_a_reject_ends_the_request()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
|
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteSingleAsync(token);
|
|
|
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = $"{mallory.Id}#accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = mallory.Id, ["object"] = following.FollowActivityURI
|
|
});
|
|
Assert.Equal(FollowState.Requested, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
|
|
|
|
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = $"{carol.Id}#rejects/{Guid.NewGuid():N}", ["type"] = "Reject", ["actor"] = carol.Id, ["object"] = following.FollowActivityURI
|
|
});
|
|
Assert.Null(await DB.Default.Find<Following>().OneAsync(following.ID, token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unfollowing_a_remote_account_sends_an_undo()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
|
|
|
await _harness.Follows.Unfollow(root, new FollowForm { AvatarId = alice.Id, Target = carol.Id }, token);
|
|
|
|
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
|
var undo = (await _harness.Outgoing(carol.Id + "/inbox")).Single(a => a["type"]!.GetValue<string>() == "Undo");
|
|
Assert.Equal("Follow", undo["object"]!["type"]!.GetValue<string>());
|
|
}
|
|
}
|
|
}
|