2026-10-01 11:37:41 +02:00
using MongoDB.Entities ;
using PrivaPub.ClientModels.Post ;
using PrivaPub.ClientModels.Social ;
using PrivaPub.Federation.Objects ;
using PrivaPub.Models.Social ;
using PrivaPub.Tests.Support ;
using System.Text.Json.Nodes ;
namespace PrivaPub.Tests.Domain
{
[Trait("Category", "Integration")]
public sealed class TimelineTests : IAsyncLifetime
{
Harness _harness ;
public async ValueTask InitializeAsync ()
{
Assert . SkipUnless ( MongoFixture . Enabled , MongoFixture . Skip );
_harness = await Harness . Start ();
}
public async ValueTask DisposeAsync ()
{
if ( _harness != default )
await _harness . DisposeAsync ();
}
async Task < List < ViewPost >> Home ( string root , string avatarId ) =>
( List < ViewPost >)( await _harness . Timelines . Home ( root , avatarId , default , 40 , TestContext . Current . CancellationToken )). Data ;
async Task Accepted ( string root , string avatarId , RemoteActor target )
{
await _harness . Follows . Follow ( root , new FollowForm { AvatarId = avatarId , Target = target . Id }, TestContext . Current . CancellationToken );
await DB . Default . Update < Following >(). Match ( f => f . AvatarId == avatarId && f . TargetActorURI == target . Id )
. Modify ( f => f . State , FollowState . Accepted ). ExecuteAsync ( TestContext . Current . CancellationToken );
}
static JsonObject Create ( RemoteActor author , string inReplyTo = default , string text = "hello" , params ( string Uri , string Name )[] mentions )
{
var origin = new Uri ( author . Id ). GetLeftPart ( UriPartial . Authority );
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}" ,
["type"] = "Note" ,
["attributedTo"] = author . Id ,
["content"] = $"<p>{text}</p>" ,
["to"] = new JsonArray ( Addressing . Public ),
["cc"] = new JsonArray ( mentions . Select ( m => ( JsonNode ) m . Uri ). Prepend ( author . Id + "/followers" ). ToArray ()),
["tag"] = new JsonArray ( mentions . Select ( m => ( JsonNode ) new JsonObject { [ "type" ] = "Mention" , [ "href" ] = m . Uri , [ "name" ] = m . Name }). ToArray ())
};
if ( inReplyTo != default )
note [ "inReplyTo" ] = inReplyTo ;
return new JsonObject { [ "id" ] = $"{origin}/activities/{Guid.NewGuid():N}" , [ "type" ] = "Create" , [ "actor" ] = author . Id , [ "object" ] = note };
}
[Fact]
public async Task A_local_post_reaches_the_author_and_local_followers_only ()
{
var token = TestContext . Current . CancellationToken ;
var ( aliceRoot , alice ) = await _harness . Persona ( "alice" );
var ( bobRoot , bob ) = await _harness . Persona ( "bob" );
var ( carolRoot , carol ) = await _harness . Persona ( "carol" );
await _harness . Follows . Follow ( bobRoot , new FollowForm { AvatarId = bob . Id , Target = alice . UserName }, token );
await _harness . Posts . InsertPost ( aliceRoot , new InsertPostForm { AvatarId = alice . Id , Text = "for my followers" }, token );
Assert . Single ( await Home ( aliceRoot , alice . Id ));
Assert . Equal ( "<p>for my followers</p>" , Assert . Single ( await Home ( bobRoot , bob . Id )). ContentHtml );
Assert . Empty ( await Home ( carolRoot , carol . Id ));
Assert . Equal ( 404 , ( await _harness . Timelines . Home ( carolRoot , bob . Id , default , 20 , token )). StatusCode );
}
[Fact]
public async Task A_followed_remote_account_is_kept_and_its_replies_follow_mastodons_rule ()
{
var ( bobRoot , bob ) = await _harness . Persona ( "bob" );
var carol = new RemoteActor ( _harness . Peer , "carol" );
var dave = new RemoteActor ( _harness . Peer , "dave" );
await Accepted ( bobRoot , bob . Id , carol );
var top = Create ( carol , text : "top" );
await _harness . Deliver ( carol , "/human-centipede" , top );
await _harness . Deliver ( carol , "/human-centipede" , Create ( carol , top [ "object" ]![ "id" ]!. GetValue < string >(), "self reply" ));
await _harness . Deliver ( dave , "/human-centipede" , Create ( dave , text : "dave alone" ));
var daveRoot = Create ( dave , text : "unfollowed root" );
await _harness . Deliver ( carol , "/human-centipede" , Create ( carol , $"{new Uri(dave.Id).GetLeftPart(UriPartial.Authority)}/notes/unknown" , "reply to a stranger" ));
var home = ( await Home ( bobRoot , bob . Id )). Select ( p => p . ContentHtml ). ToList ();
Assert . Contains ( "<p>top</p>" , home );
Assert . Contains ( "<p>self reply</p>" , home );
Assert . DoesNotContain ( "<p>dave alone</p>" , home );
Assert . DoesNotContain ( "<p>reply to a stranger</p>" , home );
}
[Fact]
public async Task A_reply_brings_its_public_parent_and_the_grandparent_is_queued ()
{
var token = TestContext . Current . CancellationToken ;
var ( bobRoot , bob ) = await _harness . Persona ( "bob" );
var carol = new RemoteActor ( _harness . Peer , "carol" );
var dave = new RemoteActor ( _harness . Peer , "dave" );
await Accepted ( bobRoot , bob . Id , carol );
var origin = new Uri ( dave . Id ). GetLeftPart ( UriPartial . Authority );
var grandparentId = $"{origin}/notes/{Guid.NewGuid():N}" ;
var parentId = $"{origin}/notes/{Guid.NewGuid():N}" ;
_harness . Peer . Serve ( new Uri ( parentId ). AbsolutePath , new JsonObject
{
["id"] = parentId , [ "type" ] = "Note" , [ "attributedTo" ] = dave . Id , [ "content" ] = "<p>parent</p>" ,
["to"] = new JsonArray ( Addressing . Public ), [ "inReplyTo" ] = grandparentId
}. ToJsonString ());
await _harness . Deliver ( carol , "/human-centipede" , Create ( carol , parentId , "reply to dave" ));
var parent = await DB . Default . Find < PrivaPub . Models . Post . Post >(). Match ( p => p . ObjectURI == parentId ). ExecuteSingleAsync ( token );
var reply = await DB . Default . Find < PrivaPub . Models . Post . Post >(). Match ( p => p . InReplyToURI == parentId ). ExecuteSingleAsync ( token );
Assert . Equal ( parent . ID , reply . AnsweringToPostId );
Assert . Equal ( parent . AuthorAccountId , reply . InReplyToAccountId );
Assert . True ( await DB . Default . Find < PrivaPub . Models . Jobs . Job >(). Match ( j => j . DedupeKey == "ancestors|" + parent . ID ). ExecuteAnyAsync ( token ));
Assert . DoesNotContain ( "<p>reply to dave</p>" , ( await Home ( bobRoot , bob . Id )). Select ( p => p . ContentHtml ));
Assert . DoesNotContain ( "<p>parent</p>" , ( await Home ( bobRoot , bob . Id )). Select ( p => p . ContentHtml ));
}
2026-10-01 11:39:45 +02:00
[Fact]
public async Task A_remote_delete_takes_the_post_out_of_home ()
{
var ( bobRoot , bob ) = await _harness . Persona ( "bob" );
var carol = new RemoteActor ( _harness . Peer , "carol" );
await Accepted ( bobRoot , bob . Id , carol );
var create = Create ( carol , text : "soon gone" );
await _harness . Deliver ( carol , "/human-centipede" , create );
Assert . Single ( await Home ( bobRoot , bob . Id ));
await _harness . Deliver ( carol , "/human-centipede" , new JsonObject
{
["id"] = $"{new Uri(carol.Id).GetLeftPart(UriPartial.Authority)}/deletes/{Guid.NewGuid():N}" , [ "type" ] = "Delete" , [ "actor" ] = carol . Id ,
["object"] = new JsonObject { [ "id" ] = create [ "object" ]![ "id" ]!. GetValue < string >(), [ "type" ] = "Tombstone" }
});
Assert . Empty ( await Home ( bobRoot , bob . Id ));
Assert . False ( await DB . Default . Find < TimelineEntry >(). Match ( e => e . AvatarId == bob . Id ). ExecuteAnyAsync ( TestContext . Current . CancellationToken ));
}
2026-10-01 11:37:41 +02:00
[Fact]
public async Task A_mention_notifies_the_persona ()
{
var token = TestContext . Current . CancellationToken ;
var ( bobRoot , bob ) = await _harness . Persona ( "bob" );
var carol = new RemoteActor ( _harness . Peer , "carol" );
await _harness . Deliver ( carol , "/human-centipede" , Create ( carol , mentions : ( bob . Uri , "@" + bob . Handle )));
var notifications = ( List < ViewNotification >)( await _harness . Timelines . Notifications ( bobRoot , bob . Id , default , 20 , token )). Data ;
var mention = Assert . Single ( notifications );
Assert . Equal ( "Mention" , mention . Type );
Assert . Equal ( carol . Id , mention . FromActorURI );
await _harness . Timelines . MarkNotificationsRead ( bobRoot , bob . Id , token );
Assert . True ((( List < ViewNotification >)( await _harness . Timelines . Notifications ( bobRoot , bob . Id , default , 20 , token )). Data ). Single (). IsRead );
}
}
}