2026-10-08 02:15:28 +02:00
using MongoDB.Entities ;
2026-10-07 21:42:54 +02:00
using PrivaPub.Federation.Actors ;
2026-10-08 02:15:28 +02:00
using PrivaPub.Models.Federation ;
2026-10-07 21:42:54 +02:00
using PrivaPub.Models.Jobs ;
2026-10-08 02:15:28 +02:00
using PrivaPub.Models.Post ;
using PrivaPub.Models.Social ;
2026-10-07 21:42:54 +02:00
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 >()));
2026-10-08 02:15:28 +02:00
}
// 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" );
2026-10-07 21:42:54 +02:00
}
}
}