- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in Status.emojis and Account.emojis. - Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and image descriptions (a locked GoToSocial account no longer shows as open). - Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed; our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST /api/v1/polls/:id/votes. - An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision. Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
149 lines
6.2 KiB
C#
149 lines
6.2 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;
|
|
|
|
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);
|
|
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records);
|
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
|
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
|
{
|
|
new FollowHandler(Db, Local, Remote, Delivery),
|
|
new AcceptHandler(Db, Local),
|
|
new RejectHandler(Db, Local),
|
|
new UndoHandler(Db, Local),
|
|
new LikeHandler(Db),
|
|
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 DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
|
new UpdateHandler(Db, Local, Remote, Groups, Records),
|
|
new FlagHandler(Db, Local)
|
|
}, NullLogger<InboxProcessor>.Instance);
|
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
|
Content = new ContentRenderer(Local, Remote);
|
|
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
|
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);
|
|
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 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 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).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();
|
|
}
|
|
|
|
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>();
|
|
}
|
|
}
|