P7: feeds read as accounts, and PieFed's post Move
Build / Build (push) Successful in 7m50s

Lemmy 1.0's multi-communities and PieFed's feeds (`type: Feed`) resolve as accounts: the feed's `following` is kept
once a day with its counts, each community in it is read in turn, and `/api/v1/accounts/:id/following` lists them.
Lemmy's feed carries the instance's key and its text in `description`; both are taken as they are. Following a feed
answers 422 until the owner decides how a feed is followed.

A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves,
goes there with its thread; a move into a community we host is not taken.

Checked in the pasture: a Lemmy 1.0 feed and a PieFed feed list their communities (ours among them); a PieFed post moved
from its web code arrives moved, with its comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 21:42:54 +02:00
1 parent 62189bf11d
commit 3b3f1f6b93
10 files changed
+226 -25

No files matched your search

@@ -412,6 +412,48 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
}
// PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it
[Fact]
public async Task A_post_moved_to_another_community_takes_its_thread_along()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
var other = new RemoteActor(_harness.Peer, "dogs", type: "Group");
var poster = new RemoteActor(_harness.Peer, "poster");
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
async Task<Post> Announced(JsonObject note)
{
note["audience"] = community.Id;
_harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString());
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note)));
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
}
var page = PublicNote(poster, "<p>misplaced</p>", community.Id);
page["type"] = "Page";
page["name"] = "misplaced";
var post = await Announced(page);
var comment = PublicNote(poster, "<p>a comment</p>", community.Id);
comment["inReplyTo"] = IdOf(page);
var answer = await Announced(comment);
var move = new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id
};
// only the community it leaves speaks for it
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject
{
["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id
}));
Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason));
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move));
Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason));
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).AudienceURI);
Assert.Equal(other.Id, (await DB.Default.Find<Post>().OneAsync(answer.ID, token)).AudienceURI);
}
// Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
[Fact]
+67
View File
@@ -0,0 +1,67 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Lemmy 1.0's multi-communities and PieFed's feeds are `Feed` actors whose `following` is their communities
[Trait("Category", "Integration")]
public sealed class MastodonFeedTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task A_feed_shows_as_an_account_whose_following_is_its_communities()
{
var token = TestContext.Current.CancellationToken;
var reader = await _host.Mastodon("feeder");
var cats = new RemoteActor(_peer, "cats", type: "Group");
var dogs = new RemoteActor(_peer, "dogs", type: "Group");
var name = $"feed{Guid.NewGuid():N}"[..12];
var feed = $"{_peer.A}/m/{name}";
// as Lemmy serves one: the instance's inbox and key, its text in `description`
_peer.Serve($"/m/{name}", new JsonObject
{
["@context"] = new JsonArray("https://join-lemmy.org/context.json", "https://www.w3.org/ns/activitystreams"),
["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox",
["publicKey"] = new JsonObject { ["id"] = $"{_peer.A}/#main-key", ["owner"] = $"{_peer.A}/", ["publicKeyPem"] = "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----\n" },
["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets", ["description"] = "every pet",
["attributedTo"] = $"{_peer.A}/users/owner"
}.ToJsonString());
_peer.Serve($"/m/{name}/following", new JsonObject
{
["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id)
}.ToJsonString());
var account = await _host.Get<IRemoteActorService>().GetActor(feed, refresh: true, token);
Assert.NotNull(account);
await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token);
var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body;
Assert.Equal(("Pets", 2), (shown.Text("display_name"), shown.Number("following_count")));
Assert.Contains("every pet", shown.Text("note"));
var communities = (await reader.Client.Get($"/api/v1/accounts/{account.ID}/following")).Ok().Array;
Assert.Equal(new[] { cats.Id, dogs.Id }, communities.Select(c => c.Text("uri")));
Assert.All(communities, c => Assert.True(c!["group"]!.GetValue<bool>()));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await reader.Client.Post($"/api/v1/accounts/{account.ID}/follow")).Status);
}
}
}