Files
SocialPub/PrivaPub.Tests/Http/MastodonFeedTests.cs
T

140 lines
7.0 KiB
C#
Raw Permalink Normal View History

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");
}
}
}