Files
thepraandClaude Opus 5.5 6fccb6fe35
Build / Build (push) Successful in 8m45s
Deploy / privapub.thepra.dev (push) Successful in 9m0s
P7: downvotes go out, and feeds are read through the server's reader
Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as
privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an
Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back.
A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another
server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite
apart from a vote, so there a favourite stays after a switch to a downvote.

Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed)
keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader,
reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves
one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows
carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The
persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds.

Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit:
Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent;
followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes
on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach
the feed timelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-08 02:15:28 +02:00

141 lines
7.0 KiB
C#

using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
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>()));
}
// owner decision 2026-10-07: a feed is read through the server, whose reader follows its communities; the persona
// follows nothing, and reads the feed in a timeline of its own
[Fact]
public async Task A_feed_is_followed_by_the_servers_reader_and_read_in_its_own_timeline()
{
var token = TestContext.Current.CancellationToken;
var since = DateTime.UtcNow.AddSeconds(-1);
var persona = await _host.Mastodon("feedreader");
var cats = new RemoteActor(_peer, "cats", type: "Group");
var dogs = new RemoteActor(_peer, "dogs", type: "Group");
var poster = new RemoteActor(_peer, "poster");
var name = $"feed{Guid.NewGuid():N}"[..12];
var feed = $"{_peer.A}/m/{name}";
_peer.Serve($"/m/{name}", new JsonObject
{
["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox", ["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets"
}.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);
await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token);
var followed = (await persona.Client.Post($"/api/v1/accounts/{account.ID}/follow")).Ok().Body;
Assert.True(followed.Flag("following"));
Assert.Equal(new[] { account.ID }, (await persona.Client.Get("/api/v1/feeds")).Ok().Ids);
await _host.Run(j => j.Kind == JobKind.SyncFeeds, token);
var readerActor = await _host.Get<ILocalActorService>().GetReaderActor(token);
var follows = await DB.Default.Find<Following>().Match(f => f.AvatarId == readerActor.Id).ExecuteAsync(token);
Assert.Equal(new[] { cats.Id, dogs.Id }.Order(), follows.Select(f => f.TargetActorURI).Order());
Assert.All(follows, f => Assert.Equal(LocalActorKind.Reader, f.FollowerKind));
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == persona.Id).ExecuteAnyAsync(token));
var asked = Assert.Single(await cats.Delivered(since), d => d.Type() == "Follow");
Assert.Equal(readerActor.Uri, asked.Text("actor"));
var document = (await _host.Client().Fetch(new Uri(readerActor.Uri).AbsolutePath)).Json;
Assert.Equal(("Service", LocalActorService.ReaderUserName), (document.Text("type"), document.Text("preferredUsername")));
await _host.Deliver(cats, "/human-centipede", new JsonObject
{
["id"] = $"{cats.Id}/accepts/{Guid.NewGuid():N}", ["type"] = "Accept", ["actor"] = cats.Id, ["object"] = asked.Text("id")
});
var create = poster.Create("<p>a thread among cats</p>", new[] { "https://www.w3.org/ns/activitystreams#Public", cats.Id }, shape: note =>
{
note["type"] = "Page";
note["name"] = "Cats";
note["audience"] = cats.Id;
});
_peer.Serve(new Uri(create["object"]!.Text("id")).AbsolutePath, create["object"]!.ToJsonString());
await _host.Deliver(cats, "/human-centipede", new JsonObject
{
["id"] = $"{cats.Id}/announces/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = cats.Id, ["object"] = create,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public")
});
var thread = await DB.Default.Find<Post>().Match(p => p.ObjectURI == create["object"]!.Text("id")).ExecuteSingleAsync(token);
Assert.Equal(new[] { thread.ID }, (await persona.Client.Get($"/api/v1/timelines/feed/{account.ID}")).Ok().Ids);
Assert.DoesNotContain(thread.ID, (await persona.Client.Get("/api/v1/timelines/home")).Ok().Ids);
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == thread.ID).ExecuteAnyAsync(token));
var stranger = await _host.Mastodon("notreading");
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/timelines/feed/{account.ID}")).Status);
Assert.False((await persona.Client.Post($"/api/v1/accounts/{account.ID}/unfollow")).Ok().Body.Flag("following"));
await _host.Run(j => j.Kind == JobKind.SyncFeeds, token);
Assert.False(await DB.Default.Find<Following>().Match(f => f.AvatarId == readerActor.Id).ExecuteAnyAsync(token));
Assert.Contains(await cats.Delivered(since), d => d.Type() == "Undo" && d["object"].Text("type") == "Follow");
}
}
}