using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Federation.Outbox; using PrivaPub.Infrastructure.Data.Migrations; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Jobs; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using System.Text.Json; using System.Text.Json.Nodes; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Tests.Infrastructure { [Xunit.Collection(nameof(Exclusive))] [Trait("Category", "Integration")] public class MigrationTests { [Fact] public async Task Stored_remote_content_is_sanitized_and_local_content_rendered() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; var remote = new Post { IsFederatedCopy = true, Text = "

hi

", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" }; var local = new Post { Text = "**bold** raw", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" }; await DB.Default.SaveAsync(new[] { remote, local }, token); await new _002_sanitize_stored_content().UpgradeAsync(); var migratedRemote = await DB.Default.Find().OneAsync(remote.ID, token); var migratedLocal = await DB.Default.Find().OneAsync(local.ID, token); Assert.Equal("

hi

", migratedRemote.Text); Assert.Equal("

hi

", migratedRemote.ContentHtml); Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat); Assert.Equal("**bold** raw", migratedLocal.Text); Assert.Equal("

bold <b>raw</b>

", migratedLocal.ContentHtml); Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat); } [Fact] public async Task Direct_posts_move_into_posts_with_their_conversation() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; var dm = new DmPost { GroupId = "conversation1", Text = "psst", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}", GroupUserId = "a1" }; await DB.Default.SaveAsync(dm, token); await new _005_direct_posts_join_posts().UpgradeAsync(); var post = await DB.Default.Find().OneAsync(dm.ID, token); Assert.Equal(PostVisibility.Direct, post.Visibility); Assert.Equal("conversation1", post.ConversationId); Assert.Equal("a1", post.AuthorAccountId); } [Fact] public async Task Fetched_records_lose_the_signature_they_never_had_and_every_record_gets_its_extensions() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; var fetched = new ObjectRecord { ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Fetched, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage", Algorithm = "rsa-sha256", SignedHeaders = new() { "host" }, ActivityContext = "\"https://www.w3.org/ns/activitystreams\"", Raw = "{\"type\":\"Note\",\"quoteUrl\":\"https://q.example/1\"}" }; var delivered = new ObjectRecord { ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Delivered, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage", Raw = "not json" }; await DB.Default.SaveAsync(new[] { fetched, delivered }, token); await new _008_fetched_records_lose_the_trigger_signature().UpgradeAsync(); await new _009_object_records_keep_their_extensions().UpgradeAsync(); var afterFetched = await DB.Default.Find().OneAsync(fetched.ID, token); var afterDelivered = await DB.Default.Find().OneAsync(delivered.ID, token); Assert.Null(afterFetched.KeyId); Assert.Null(afterFetched.SignatureScheme); Assert.Empty(afterFetched.SignedHeaders); Assert.Null(afterFetched.ActivityContext); Assert.Equal("https://x.example/u#k", afterDelivered.KeyId); Assert.Contains("legacy-quote", afterFetched.Extensions); Assert.Empty(afterDelivered.Extensions); } [Fact] public async Task Conversations_get_their_participants_key_groups_become_circles_and_their_posts_stay_home() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; var members = new List { new() { AvatarId = "a1" }, new() { AvatarId = "https://r.example/u/b", IsForeign = true } }; var conversation = new DmGroup { Members = members }; var group = new GroupEntity { UserName = $"old{Guid.NewGuid():N}"[..20], Kind = GroupKind.Community }; await DB.Default.SaveAsync(conversation, token); await DB.Default.SaveAsync(group, token); var localInGroup = new Post { GroupId = group.ID, Text = "inside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" }; var remoteInGroup = new Post { GroupId = group.ID, IsFederatedCopy = true, Text = "visiting", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" }; var ungrouped = new Post { Text = "outside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" }; await DB.Default.SaveAsync(new[] { localInGroup, remoteInGroup, ungrouped }, token); //the migration turns every group into a circle and keeps every local group post home: put the others back afterwards var communities = (await DB.Default.Find().Match(g => g.Kind != GroupKind.Circle && g.ID != group.ID).ExecuteAsync(token)).Select(g => g.ID).ToList(); var federatedGroupPosts = (await DB.Default.Find().Match(p => p.GroupId != null && !p.IsFederatedCopy && !p.IsLocalOnly && p.ID != localInGroup.ID) .ExecuteAsync(token)).Select(p => p.ID).ToList(); try { await new _003_conversation_keys_and_circles().UpgradeAsync(); Assert.Equal(DmGroup.KeyOf(members), (await DB.Default.Find().OneAsync(conversation.ID, token)).ParticipantsKey); Assert.Equal(GroupKind.Circle, (await DB.Default.Find().OneAsync(group.ID, token)).Kind); Assert.True((await DB.Default.Find().OneAsync(localInGroup.ID, token)).IsLocalOnly); Assert.False((await DB.Default.Find().OneAsync(remoteInGroup.ID, token)).IsLocalOnly); Assert.False((await DB.Default.Find().OneAsync(ungrouped.ID, token)).IsLocalOnly); } finally { if (communities.Count > 0) await DB.Default.Update().Match(g => communities.Contains(g.ID)).Modify(g => g.Kind, GroupKind.Community).ExecuteAsync(token); if (federatedGroupPosts.Count > 0) await DB.Default.Update().Match(p => federatedGroupPosts.Contains(p.ID)).Modify(p => p.IsLocalOnly, false).ExecuteAsync(token); } } [Fact] public async Task Pending_deliveries_move_to_the_job_queue_once_and_the_rest_are_left_alone() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; var host = $"r{Guid.NewGuid():N}.example"; var activityId = $"https://privapub.test/a/{Guid.NewGuid():N}"; var body = new JsonObject { ["id"] = activityId, ["type"] = "Create" }.ToJsonString(); var nextAttempt = DateTime.UtcNow.AddMinutes(7); Delivery Legacy(string inbox, DateTime? deliveredAt = default, DateTime? abandonedAt = default) => new() { SignerId = "000000000000000000000001", SignerKind = LocalActorKind.Person, InboxURL = inbox, Body = body, Attempts = 3, NextAttemptAt = nextAttempt, DeliveredAt = deliveredAt, AbandonedAt = abandonedAt }; var pending = Legacy($"https://{host.ToUpperInvariant()}/inbox"); var twice = Legacy($"https://{host}/shared"); var already = Legacy($"https://{host}/shared"); var broken = Legacy("not an address"); var delivered = Legacy($"https://{host}/done", deliveredAt: DateTime.UtcNow.AddDays(-1)); var abandoned = Legacy($"https://{host}/given-up", abandonedAt: DateTime.UtcNow.AddDays(-1)); await DB.Default.SaveAsync(new[] { pending, twice, already, broken, delivered, abandoned }, token); await new _004_pending_deliveries_become_jobs().UpgradeAsync(); var jobs = await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(activityId + "|")).ExecuteAsync(token); Assert.Equal(2, jobs.Count); var job = Assert.Single(jobs, j => j.DedupeKey == $"{activityId}|{pending.InboxURL}"); Assert.Equal(host, job.Host); Assert.Equal(3, job.Attempts); Assert.InRange(job.RunAt, nextAttempt.AddSeconds(-1), nextAttempt.AddSeconds(1)); var payload = JsonSerializer.Deserialize(job.Payload); Assert.Equal((pending.SignerId, LocalActorKind.Person, pending.InboxURL, body), (payload.SignerId, payload.SignerKind, payload.Inbox, payload.Body)); Assert.Contains(jobs, j => j.DedupeKey == $"{activityId}|{twice.InboxURL}"); foreach (var moved in new[] { pending, twice, already, broken }) { var after = await DB.Default.Find().OneAsync(moved.ID, token); Assert.NotNull(after.AbandonedAt); Assert.Equal("moved to the job queue", after.LastError); } Assert.Null((await DB.Default.Find().OneAsync(delivered.ID, token)).AbandonedAt); Assert.Null((await DB.Default.Find().OneAsync(abandoned.ID, token)).LastError); } [Fact] public async Task Year_one_edit_dates_become_no_edit_and_year_one_revisions_take_the_creation_date() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; var created = new DateTime(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc); var edited = new DateTime(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc); var broken = new Post { IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = DateTime.MinValue, UpdateDate = DateTime.MinValue, Revisions = new() { new PostRevision { ContentHtml = "

first

", EditedAt = DateTime.MinValue }, new PostRevision { ContentHtml = "

second

", EditedAt = edited } } }; var fine = new Post { IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = edited, UpdateDate = edited }; await DB.Default.SaveAsync(new[] { broken, fine }, token); await new _006_unedited_remote_posts_lose_year_one().UpgradeAsync(); var repaired = await DB.Default.Find().OneAsync(broken.ID, token); Assert.Null(repaired.EditedAt); Assert.Equal(created, repaired.UpdateDate); Assert.Equal(new[] { created, edited }, repaired.Revisions.Select(r => r.EditedAt)); var untouched = await DB.Default.Find().OneAsync(fine.ID, token); Assert.Equal((edited, edited), (untouched.EditedAt, untouched.UpdateDate)); } [Fact] public async Task Personas_and_groups_without_a_published_day_get_one_within_two_weeks_before_creation() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); var token = TestContext.Current.CancellationToken; var created = new DateTime(2026, 6, 15, 18, 30, 0, DateTimeKind.Utc); var avatar = new Avatar { UserName = $"aged{Guid.NewGuid():N}"[..20], CreatedAt = created }; var group = new GroupEntity { UserName = $"aged{Guid.NewGuid():N}"[..20], CreationDate = created }; var dated = new Avatar { UserName = $"dated{Guid.NewGuid():N}"[..20], CreatedAt = created, PublishedOn = new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc) }; await DB.Default.SaveAsync(new[] { avatar, dated }, token); await DB.Default.SaveAsync(group, token); await DB.Default.Update().MatchID(avatar.ID).Modify(b => b.Unset(a => a.PublishedOn)).ExecuteAsync(token); await DB.Default.Update().MatchID(group.ID).Modify(b => b.Unset(g => g.PublishedOn)).ExecuteAsync(token); Assert.False(await DB.Default.Find().Match(new BsonDocument("_id", ObjectId.Parse(avatar.ID)).Add(nameof(Avatar.PublishedOn), new BsonDocument("$exists", true))).ExecuteAnyAsync(token)); await new _007_personas_publish_a_spread_day().UpgradeAsync(); foreach (var published in new[] { (await DB.Default.Find().OneAsync(avatar.ID, token)).PublishedOn, (await DB.Default.Find().OneAsync(group.ID, token)).PublishedOn }) { Assert.Equal(TimeSpan.Zero, published.TimeOfDay); Assert.InRange(published, created.Date.AddDays(-13), created.Date); } Assert.Equal(new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc), (await DB.Default.Find().OneAsync(dated.ID, token)).PublishedOn); } } }