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;
}
}