PrivaPubHost is a WebApplicationFactory<Program> on the fixture's database, configured only through UseSetting (visible before Build, unlike ConfigureAppConfiguration). It drops the background workers so tests run the jobs they queue (Jobs.Run, RunInbox), gives each client its own address for the rate limiter, and has a SecureMode variant. Accounts signs up roots, adds personas and gets Mastodon tokens through the real /oauth code flow. RemoteActor signs HttpRequestMessages for the real /peasants routes; Peer records bodies and headers and serves files with ranges and text pages. Program registers the Guid serializer with TryRegisterSerializer, so a second host in one process starts; the fixture runs the migrations in production's order before any test. HostBootTests: the host shares the fixture database; the harness handles exactly the activities the server registers; every job kind has one handler; every controller and page model can be made; the service graph validates with ValidateOnBuild and ValidateScopes; Swagger is 404 outside Development; a persona's token never names its root; a signed DM through the real /mouth route is queued, processed and stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
166 lines
7.1 KiB
C#
166 lines
7.1 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Localization;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Domain.Media;
|
|
using PrivaPub.Domain.Relationships;
|
|
using PrivaPub.Domain.Social;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Domain.Timelines;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Inbox;
|
|
using PrivaPub.Federation.Inbox.Handlers;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Resources;
|
|
using PrivaPub.Services;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Support
|
|
{
|
|
public sealed class Harness : IAsyncDisposable
|
|
{
|
|
public const string Host = "privapub.test";
|
|
public const string Base = "https://" + Host;
|
|
|
|
readonly DateTime _started = Millisecond(DateTime.UtcNow);
|
|
|
|
Harness(Peer peer)
|
|
{
|
|
Peer = peer;
|
|
var cache = new MemoryCache(new MemoryCacheOptions());
|
|
Local = new LocalActorService(Db, new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
|
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
|
Delivery = new DeliveryService(Db, Queue);
|
|
Fanout = new Fanout(Db);
|
|
Groups = new GroupDistributor(Delivery);
|
|
Records = new ObjectRecords(Queue);
|
|
Polls = new PollService(Db, Local, Delivery, Queue);
|
|
Reactions = new Reactions(Db, Delivery);
|
|
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
|
|
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
|
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
|
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
|
Handlers = new IActivityHandler[]
|
|
{
|
|
new FollowHandler(Db, Local, Remote, Delivery),
|
|
new AcceptHandler(Db, Local, Quotes),
|
|
new RejectHandler(Db, Local, Quotes),
|
|
new UndoHandler(Db, Local, Reactions),
|
|
new LikeHandler(Db, Reactions),
|
|
new EmojiReactHandler(Db, Reactions),
|
|
new QuoteRequestHandler(Quotes),
|
|
new DislikeHandler(Db),
|
|
new JoinHandler(Db, Local, Delivery),
|
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
|
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes),
|
|
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
|
|
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
|
|
new FlagHandler(Db, Local)
|
|
};
|
|
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance);
|
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
|
Content = new ContentRenderer(Local, Remote);
|
|
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
|
Local, default, NullLogger<MediaService>.Instance);
|
|
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups, Polls, new NoPreviews(), Quotes);
|
|
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
|
|
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
|
Relationships = new RelationshipService(Db, Follows, Delivery);
|
|
Reports = new ReportService(Db, Local, Delivery);
|
|
}
|
|
|
|
public Peer Peer { get; }
|
|
public DbEntities Db { get; } = new();
|
|
public JobQueue Queue { get; } = new();
|
|
public LocalActorService Local { get; }
|
|
public RemoteActorService Remote { get; }
|
|
public DeliveryService Delivery { get; }
|
|
public InboxReceiver Receiver { get; }
|
|
public InboxProcessor Processor { get; }
|
|
public IReadOnlyList<IActivityHandler> Handlers { get; }
|
|
public FollowService Follows { get; }
|
|
public ContentRenderer Content { get; }
|
|
public OutboxPublisher Outbox { get; }
|
|
public PostsService Posts { get; }
|
|
public StatusService Statuses { get; }
|
|
public MediaService Media { get; }
|
|
public GroupDistributor Groups { get; }
|
|
public Fanout Fanout { get; }
|
|
public RemotePosts RemotePosts { get; }
|
|
public ObjectRecords Records { get; }
|
|
public PollService Polls { get; }
|
|
public Reactions Reactions { get; }
|
|
public QuoteService Quotes { get; }
|
|
public TimelineService Timelines { get; }
|
|
public RelationshipService Relationships { get; }
|
|
public ReportService Reports { get; }
|
|
|
|
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
|
|
await DB.Default.SaveAsync(new Follower
|
|
{
|
|
LocalActorId = local.Id,
|
|
LocalActorKind = local.Kind,
|
|
ActorURI = follower.Id,
|
|
InboxURL = follower.Id + "/inbox",
|
|
SharedInboxURL = follower.SharedInbox
|
|
});
|
|
|
|
public static async Task<Harness> Start() => new(await Peer.Start());
|
|
|
|
public async Task<(string RootId, LocalActor Avatar)> Persona(string name, string rootId = default)
|
|
{
|
|
rootId ??= Guid.NewGuid().ToString("N")[..24];
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], Name = name, PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(avatar);
|
|
await DB.Default.SaveAsync(new RootToAvatar { RootId = rootId, AvatarId = avatar.ID });
|
|
return (rootId, Local.FromAvatar(avatar));
|
|
}
|
|
|
|
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
|
{
|
|
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
|
|
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
|
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
|
|
if (job != default)
|
|
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
|
|
return result;
|
|
}
|
|
|
|
public async Task<List<JsonObject>> Outgoing(string inbox) =>
|
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync())
|
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
|
.Where(p => p.Inbox == inbox)
|
|
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
|
.ToList();
|
|
|
|
public async ValueTask DisposeAsync() => await Peer.DisposeAsync();
|
|
|
|
static DateTime Millisecond(DateTime time) => new(time.Ticks - time.Ticks % TimeSpan.TicksPerMillisecond, DateTimeKind.Utc);
|
|
}
|
|
|
|
public sealed class KeyLocalizer<T> : IStringLocalizer<T>
|
|
{
|
|
public LocalizedString this[string name] => new(name, name);
|
|
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments));
|
|
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
|
|
}
|
|
|
|
public sealed class NoPreviews : ILinkPreviews
|
|
{
|
|
public Task Wanted(PrivaPub.Models.Post.Post post, CancellationToken token) => Task.CompletedTask;
|
|
}
|
|
}
|