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().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())); } // 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().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().GetReaderActor(token); var follows = await DB.Default.Find().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().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("

a thread among cats

", 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().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().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().Match(f => f.AvatarId == readerActor.Id).ExecuteAnyAsync(token)); Assert.Contains(await cats.Delivered(since), d => d.Type() == "Undo" && d["object"].Text("type") == "Follow"); } } }