Likes and boosts arrive, count, notify, and can be taken back
- Like: a Favourite per account and post (unique), the post's counter, a Favourite notification for a local author; only on posts the liker could see (public and unlisted, or followers-only and direct when they were addressed). - Announce: the original is always refetched from its own origin (or is ours), and must be public or unlisted. A boost of a local post counts and notifies; a boost by an account a persona follows is stored as a reblog row and fanned out to its followers' home timelines (as a reblog, where reblogs are wanted). A boost by a stranger of a stranger's post is ignored. FEP-1b12 group announces of activities wait for P4. - Undo of a Like or an Announce reverses each of them; an Undo naming only an id is matched against follows, likes and boosts in turn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
e99b76dbd7
commit
b34c71775c
7 files changed
+351
-22
No files matched your search
@@ -53,7 +53,7 @@ namespace PrivaPub.Tests.Federation
|
||||
_processor = new InboxProcessor(remote, new IActivityHandler[]
|
||||
{
|
||||
new FollowHandler(db, _local, remote, delivery),
|
||||
new UndoHandler(db, _local, remote, delivery),
|
||||
new UndoHandler(db, _local),
|
||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
|
||||
new DeleteHandler(db, _local, remote, delivery),
|
||||
new UpdateHandler(db, _local, remote)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Post;
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InteractionTests : 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();
|
||||
}
|
||||
|
||||
async Task<(string Root, Post Post)> LocalPost(string visibility = default)
|
||||
{
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var view = (ViewPost)(await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "like me", Visibility = visibility },
|
||||
TestContext.Current.CancellationToken)).Data;
|
||||
return (root, await DB.Default.Find<Post>().OneAsync(view.Id));
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
[Fact]
|
||||
public async Task A_like_counts_once_notifies_and_can_be_undone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, post) = await LocalPost();
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var like = new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI };
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", like);
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = carol.Id, ["object"] = post.ObjectURI });
|
||||
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
||||
Assert.Equal(NotificationType.Favourite, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = like
|
||||
});
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stranger_cannot_like_a_followers_only_post()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, post) = await LocalPost("followersonly");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
|
||||
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(mallory)}/likes/{Guid.NewGuid():N}", ["type"] = "Like", ["actor"] = mallory.Id, ["object"] = post.ObjectURI
|
||||
});
|
||||
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_boost_of_a_local_post_counts_notifies_and_can_be_undone()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, post) = await LocalPost();
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var announce = new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
|
||||
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(carol.Id + "/followers")
|
||||
};
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", announce);
|
||||
|
||||
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
|
||||
Assert.Equal(NotificationType.Reblog, (await DB.Default.Find<Notification>().Match(n => n.AvatarId == post.GroupUserId).ExecuteSingleAsync(token)).Type);
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/undos/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = carol.Id, ["object"] = announce["id"]!.GetValue<string>()
|
||||
});
|
||||
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).ReblogsCount);
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == announce["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_followed_account_boosting_a_remote_post_puts_it_in_home()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (bobRoot, bob) = await _harness.Persona("bob");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol");
|
||||
var dave = new RemoteActor(_harness.Peer, "dave");
|
||||
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||
await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = carol.Id }, token);
|
||||
await DB.Default.Update<Following>().Match(f => f.AvatarId == bob.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||
var noteId = $"{Origin(dave)}/notes/{Guid.NewGuid():N}";
|
||||
_harness.Peer.Serve(new Uri(noteId).AbsolutePath, new JsonObject
|
||||
{
|
||||
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = dave.Id, ["content"] = "<p>worth sharing</p>", ["to"] = new JsonArray(Addressing.Public)
|
||||
}.ToJsonString());
|
||||
|
||||
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(stranger)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = stranger.Id, ["object"] = noteId,
|
||||
["to"] = new JsonArray(Addressing.Public)
|
||||
});
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
|
||||
|
||||
await _harness.Deliver(carol, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(carol)}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = carol.Id, ["object"] = noteId,
|
||||
["to"] = new JsonArray(Addressing.Public)
|
||||
});
|
||||
|
||||
var home = (List<ViewPost>)(await _harness.Timelines.Home(bobRoot, bob.Id, default, 20, token)).Data;
|
||||
var boosted = Assert.Single(home);
|
||||
Assert.Equal("<p>worth sharing</p>", boosted.ReblogOf.ContentHtml);
|
||||
Assert.Equal(carol.Id, boosted.AuthorActorURI);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,9 @@ namespace PrivaPub.Tests.Support
|
||||
new FollowHandler(Db, Local, Remote, Delivery),
|
||||
new AcceptHandler(Db, Local),
|
||||
new RejectHandler(Db, Local),
|
||||
new UndoHandler(Db, Local, Remote, Delivery),
|
||||
new UndoHandler(Db, Local),
|
||||
new LikeHandler(Db),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
|
||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
|
||||
new DeleteHandler(Db, Local, Remote, Delivery),
|
||||
new UpdateHandler(Db, Local, Remote)
|
||||
|
||||
Reference in new issue
Block a user