using System.Diagnostics.Metrics; using ItWorx.KnowledgeHub.Application.Attachments; using ItWorx.KnowledgeHub.Application.Audit; using ItWorx.KnowledgeHub.Application.Ingestion; using ItWorx.KnowledgeHub.Application.Ingestion.UseCases; using ItWorx.KnowledgeHub.Domain.Attachments; using ItWorx.KnowledgeHub.Domain.Ingestion; using ItWorx.KnowledgeHub.Domain.Knowledge; using ItWorx.KnowledgeHub.Infrastructure.Attachments; using ItWorx.KnowledgeHub.Infrastructure.Ingestion; using ItWorx.KnowledgeHub.Infrastructure.Persistence; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; using ItWorx.KnowledgeHub.IntegrationTests.Operations; using Microsoft.EntityFrameworkCore; namespace ItWorx.KnowledgeHub.IntegrationTests.Ingestion; /// /// PostgreSQL persistence coverage for source retention. These tests deliberately use /// separate contexts around the candidate query, so the repository's deletion-time /// ownership recheck is proved against committed state rather than a port fake. /// [Collection(PostgresTestGroup.Name)] public sealed class IngestionSourceRetentionPersistenceTests(PostgresContainerFixture postgres) { private static readonly DateTimeOffset Now = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid ActorId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private const string Digest = "c8c86d360bfb7bb0d036b7352bf89714946fb527c4074340752259109371f429"; [Fact] public async Task Cleanup_removes_persisted_expired_usage_and_reference_but_retains_the_physical_blob_record() { await using var target = await RestoreTargetDatabase.CreateAsync(postgres); var options = CreateOptions(target.ConnectionString); var seeded = await SeedExpiredSourceAsync(options); await using (var db = new KnowledgeHubDbContext(options)) { var cleanup = CreateCleanup(new IngestionProposalRepository(db), new AttachmentRepository(db)); var result = await cleanup.ExecuteAsync(CancellationToken.None); Assert.Equal(new IngestionSourceRetentionCleanupResult(1, 1), result); } await using (var db = new KnowledgeHubDbContext(options)) { Assert.Null(await db.AttachmentUsages.SingleOrDefaultAsync(usage => usage.Id == seeded.UsageId)); Assert.Null(await db.AttachmentReferences.SingleOrDefaultAsync(reference => reference.Id == seeded.ReferenceId)); Assert.NotNull(await db.AttachmentBlobs.SingleOrDefaultAsync(blob => blob.Sha256 == Digest)); } } [Fact] public async Task Cleanup_staged_usage_and_reference_removal_rolls_back_without_a_committed_transaction() { await using var target = await RestoreTargetDatabase.CreateAsync(postgres); var options = CreateOptions(target.ConnectionString); var seeded = await SeedExpiredSourceAsync(options); await using (var db = new KnowledgeHubDbContext(options)) { var retention = new IngestionProposalRepository(db); var candidate = Assert.Single(await retention.FindExpiredProposalSourcesAsync(Now.AddDays(-30), 100, CancellationToken.None)); var attachments = new AttachmentRepository(db); await using var transaction = await db.Database.BeginTransactionAsync(CancellationToken.None); Assert.Equal(new ProposalSourceAttachmentCleanupResult(true, true), await attachments.RemoveExpiredProposalSourceUsageAsync(candidate, CancellationToken.None)); await db.SaveChangesAsync(CancellationToken.None); await transaction.RollbackAsync(CancellationToken.None); } await using (var db = new KnowledgeHubDbContext(options)) { Assert.NotNull(await db.AttachmentUsages.SingleOrDefaultAsync(usage => usage.Id == seeded.UsageId)); Assert.NotNull(await db.AttachmentReferences.SingleOrDefaultAsync(reference => reference.Id == seeded.ReferenceId)); Assert.NotNull(await db.AttachmentBlobs.SingleOrDefaultAsync(blob => blob.Sha256 == Digest)); } } [Fact] public async Task Cleanup_rechecks_committed_ownership_and_preserves_a_candidate_published_and_transferred_to_a_page() { await using var target = await RestoreTargetDatabase.CreateAsync(postgres); var options = CreateOptions(target.ConnectionString); var seeded = await SeedExpiredSourceAsync(options); IngestionSourceRetentionCandidate candidate; Guid publishedPageId; await using (var db = new KnowledgeHubDbContext(options)) { var retention = new IngestionProposalRepository(db); candidate = Assert.Single(await retention.FindExpiredProposalSourcesAsync(Now.AddDays(-30), 100, CancellationToken.None)); } await using (var db = new KnowledgeHubDbContext(options)) { var proposal = await db.IngestionProposals.SingleAsync(item => item.Id == seeded.ProposalId); var page = Page.Create(WorkspaceId, null, SortKey.First(), PageTitle.Create("Published evidence"), DocumentType.WikiPage, DocumentStatus.Active, "# Published evidence", ActorId, Now); var usage = await db.AttachmentUsages.SingleAsync(item => item.Id == seeded.UsageId); proposal.SetPublication(IngestionPublication.Create(IngestionMutationKind.CreatePage, "Published evidence", "# Published evidence", "WikiPage", "Active"), ActorId, Now); proposal.BeginAnalysis(ActorId, Now); proposal.ApplyAnalysis("Reviewed analysis", ActorId, Now); proposal.Approve(ActorId, Now); proposal.BeginPublishing(ActorId, Now); proposal.MarkPublished(ActorId, page.Id, page.CurrentRevisionNumber, "retention-race", Now); usage.TransferProposalToPage(page.Id); db.Pages.Add(page); await db.SaveChangesAsync(CancellationToken.None); publishedPageId = page.Id; } await using (var db = new KnowledgeHubDbContext(options)) { var cleanup = new AttachmentRepository(db); var result = await cleanup.RemoveExpiredProposalSourceUsageAsync(candidate, CancellationToken.None); Assert.Equal(ProposalSourceAttachmentCleanupResult.None, result); await db.SaveChangesAsync(CancellationToken.None); } await using (var db = new KnowledgeHubDbContext(options)) { var usage = await db.AttachmentUsages.SingleAsync(item => item.Id == seeded.UsageId); Assert.Equal(publishedPageId, usage.PageId); Assert.Null(usage.IngestionProposalId); Assert.NotNull(await db.AttachmentReferences.SingleOrDefaultAsync(reference => reference.Id == seeded.ReferenceId)); Assert.NotNull(await db.AttachmentBlobs.SingleOrDefaultAsync(blob => blob.Sha256 == Digest)); } } private static DbContextOptions CreateOptions(string connectionString) => new DbContextOptionsBuilder() .UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name)) .UseSnakeCaseNamingConvention() .Options; private static CleanupExpiredIngestionSourcesUseCase CreateCleanup( IIngestionSourceRetentionRepository retention, IAttachmentRepository attachments) => new(retention, attachments, new AuditRecorder(), new IngestionMetrics(new Meter("ingestion-retention-persistence")), new FixedTimeProvider(Now)); private static async Task SeedExpiredSourceAsync(DbContextOptions options) { await using var db = new KnowledgeHubDbContext(options); var reference = AttachmentReference.Create(WorkspaceId, Digest, "evidence.txt", ActorId, Now.AddDays(-31)); var proposal = IngestionProposal.Create(WorkspaceId, ActorId, "retention-persistence", "safe manifest"); proposal.AddSource("evidence.txt", "text/plain", 42, Digest, "clean", reference.Id); var usage = AttachmentUsage.ForIngestionProposal(reference.Id, proposal.Id, Now.AddDays(-31)); db.AttachmentBlobs.Add(AttachmentBlob.Create(Digest, 42, "text/plain", AttachmentDeliveryPolicy.DownloadOnly, Now.AddDays(-31))); db.AttachmentReferences.Add(reference); db.IngestionProposals.Add(proposal); db.AttachmentUsages.Add(usage); await db.SaveChangesAsync(CancellationToken.None); return new SeededSource(proposal.Id, proposal.Sources.Single().Id, reference.Id, usage.Id); } private sealed record SeededSource(Guid ProposalId, Guid SourceId, Guid ReferenceId, Guid UsageId); private sealed class AuditRecorder : IAuditEventRecorder { public void Add(AuditEventRecord auditEvent) { } public Task RecordAsync(AuditEventRecord auditEvent, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } }