using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Omika.Diagnostics.Configuration; using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Repositories; using Omika.Diagnostics.Tests.Fakes; using Omika.Diagnostics.Worker; namespace Omika.Diagnostics.Tests; /// Phase 5C.2C — rétention PostgreSQL : lots bornés, fail-open, boucle séparée de l'import. public class DiagnosticRetentionTests { private static DiagnosticEventRecord Old(DateTimeOffset t) => new() { EventId = Guid.NewGuid(), EventCode = "TEST", TimestampUtc = t, Source = "mobile", IngestedAtUtc = t }; private static DiagnosticEventRecord New() => Old(DateTimeOffset.UtcNow); /// Événement d'ERREUR (level error) ancien : soumis à la règle « traité/non traité ». private static DiagnosticEventRecord OldError(DateTimeOffset t) => new() { EventId = Guid.NewGuid(), EventCode = "TEST", Level = "error", TimestampUtc = t, Source = "mobile", IngestedAtUtc = t }; /// Identifiant URL-safe du groupe d'un événement (même normalisation que la purge). private static string GroupId(DiagnosticEventRecord e) => DiagnosticErrorGroupLink.Encode(new DiagnosticErrorGroupIdentity( e.EventCode, DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode), DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType), DiagnosticErrorGrouping.NormalizeKey(e.Component))); /// Marque le groupe d'un événement comme « traité » (tâche terminale), donc purgable. private static void MarkTreated(FakeDiagnosticEventRepository repo, DiagnosticEventRecord e) => repo.MarkGroupTreated(GroupId(e)); // ---------------------------------------------------------------- repo ---- [Fact] public async Task DeleteOlderThan_RemovesOnlyOlderThanCutoff() { var repo = new FakeDiagnosticEventRepository(); DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60); DiagnosticEventRecord old1 = Old(cutoff.AddDays(-10)); DiagnosticEventRecord old2 = Old(cutoff.AddDays(-1)); DiagnosticEventRecord recent = New(); await repo.InsertBatchAsync([old1, old2, recent]); int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100); Assert.Equal(2, deleted); DiagnosticQueryResult remaining = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); Assert.Equal(1, remaining.TotalCount); Assert.Equal(recent.EventId, remaining.Items.Single().EventId); } [Fact] public async Task DeleteOlderThan_RespectsBatchSize() { var repo = new FakeDiagnosticEventRepository(); DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60); var old = new List(); for (int i = 0; i < 5; i++) { old.Add(Old(cutoff.AddDays(-i - 1))); } await repo.InsertBatchAsync(old); // Un lot de 2 ne supprime que 2 lignes ; la purge est itérative. int first = await repo.DeleteOlderThanAsync(cutoff, batchSize: 2); int second = await repo.DeleteOlderThanAsync(cutoff, batchSize: 2); int third = await repo.DeleteOlderThanAsync(cutoff, batchSize: 2); Assert.Equal(2, first); Assert.Equal(2, second); Assert.Equal(1, third); } [Fact] public async Task DeleteOlderThan_EmptyTable_ReturnsZero() { var repo = new FakeDiagnosticEventRepository(); int deleted = await repo.DeleteOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-60), batchSize: 100); Assert.Equal(0, deleted); } [Fact] public async Task DeleteOlderThan_NonPositiveBatchSize_ReturnsZero() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync([Old(DateTimeOffset.UtcNow.AddDays(-100))]); int deleted = await repo.DeleteOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-60), batchSize: 0); Assert.Equal(0, deleted); // Aucune suppression malgré un batch invalide (config invalide => inerte). DiagnosticQueryResult remaining = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); Assert.Equal(1, remaining.TotalCount); } [Fact] public async Task DeleteOlderThan_SkipsUntreatedGroup() { var repo = new FakeDiagnosticEventRepository(); DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60); // Une ERREUR ancienne SANS tâche terminale : elle ne doit JAMAIS être purgée, // même largement au-delà du cutoff. DiagnosticEventRecord untreated = OldError(cutoff.AddDays(-200)); await repo.InsertBatchAsync([untreated]); int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100); Assert.Equal(0, deleted); DiagnosticQueryResult remaining = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); Assert.Equal(1, remaining.TotalCount); Assert.Equal(untreated.EventId, remaining.Items.Single().EventId); } [Fact] public async Task DeleteOlderThan_PurgesOnlyTreatedGroups() { var repo = new FakeDiagnosticEventRepository(); DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60); DiagnosticEventRecord treated = OldError(cutoff.AddDays(-100)); DiagnosticEventRecord untreated = new() { EventId = Guid.NewGuid(), EventCode = "AUTRE", Level = "error", TimestampUtc = cutoff.AddDays(-100), Source = "mobile", IngestedAtUtc = cutoff.AddDays(-100) }; await repo.InsertBatchAsync([treated, untreated]); MarkTreated(repo, treated); // seul le groupe « TEST » est traité int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100); Assert.Equal(1, deleted); DiagnosticQueryResult remaining = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); Assert.Single(remaining.Items); Assert.Equal(untreated.EventId, remaining.Items.Single().EventId); } [Fact] public async Task DeleteOlderThan_OpenTaskGroup_NotPurged() { // Un groupe possède une tâche NON terminale (Todo/InProgress) : il n'est pas // « traité » et ses événements sont conservés (équivalent à l'absence de // tâche terminale dans le rapprochement SQL). var repo = new FakeDiagnosticEventRepository(); DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60); DiagnosticEventRecord old = OldError(cutoff.AddDays(-100)); await repo.InsertBatchAsync([old]); // Pas de MarkGroupTreated : la tâche (Todo/InProgress) ne rend pas le groupe purgable. int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100); Assert.Equal(0, deleted); DiagnosticQueryResult remaining = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); Assert.Equal(1, remaining.TotalCount); } [Fact] public async Task DeleteOlderThan_NonError_AlwaysPurged() { // Un événement NON-erreur (trace/debug/info/warning, ou level NULL) est purgé // après le cutoff MÊME sans aucune tâche de traitement : la règle « traité/non // traité » ne s'applique qu'aux erreurs. var repo = new FakeDiagnosticEventRepository(); DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60); DiagnosticEventRecord info = new() { EventId = Guid.NewGuid(), EventCode = "MOBILE_SCREEN_VIEW", Level = "info", TimestampUtc = cutoff.AddDays(-100), Source = "mobile", IngestedAtUtc = cutoff.AddDays(-100) }; await repo.InsertBatchAsync([info]); // Aucun MarkGroupTreated : les non-erreurs ne dépendent pas des tâches. int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100); Assert.Equal(1, deleted); DiagnosticQueryResult remaining = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); Assert.Equal(0, remaining.TotalCount); } // -------------------------------------------------------------- worker ---- private static DiagnosticRetentionWorker CreateWorker( IDiagnosticEventRepository repo, DiagnosticStoreOptions options) => new(repo, Options.Create(options), NullLogger.Instance); [Fact] public async Task RetentionWorker_PurgesOlderEvents() { var repo = new FakeDiagnosticEventRepository(); DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60); DiagnosticEventRecord oldA = Old(cutoff.AddDays(-5)); DiagnosticEventRecord oldB = Old(cutoff.AddDays(-1)); DiagnosticEventRecord recent = New(); await repo.InsertBatchAsync([oldA, oldB, recent]); var worker = CreateWorker(repo, new DiagnosticStoreOptions { RetentionDays = 60, RetentionBatchSize = 100, RetentionIntervalHours = 1 }); using var cts = new CancellationTokenSource(); await worker.StartAsync(cts.Token); // La première passe est immédiate : on attend la purge (poll borné). int total = 3; for (int i = 0; i < 50; i++) { DiagnosticQueryResult r = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); total = r.TotalCount; if (total == 1) { break; } await Task.Delay(100); } cts.Cancel(); await worker.StopAsync(cts.Token); Assert.Equal(1, total); // seul l'événement récent subsiste } [Fact] public async Task RetentionWorker_Disabled_DoesNotPurge() { var repo = new FakeDiagnosticEventRepository(); await repo.InsertBatchAsync([Old(DateTimeOffset.UtcNow.AddDays(-100))]); var worker = CreateWorker(repo, new DiagnosticStoreOptions { RetentionDays = 0, // désactivé RetentionBatchSize = 100, RetentionIntervalHours = 1 }); using var cts = new CancellationTokenSource(); await worker.StartAsync(cts.Token); await Task.Delay(300); cts.Cancel(); await worker.StopAsync(cts.Token); DiagnosticQueryResult remaining = await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 }); Assert.Equal(1, remaining.TotalCount); // rien de purgé } [Fact] public async Task RetentionWorker_DbDown_DoesNotCrash() { // PostgreSQL down : la purge lève, le worker attrape (fail-open) et ne // crashe pas. L'import (boucle séparée) n'est jamais impacté. var worker = CreateWorker(new ThrowingDiagnosticRepository(), new DiagnosticStoreOptions { RetentionDays = 60, RetentionBatchSize = 100, RetentionIntervalHours = 1 }); using var cts = new CancellationTokenSource(); await worker.StartAsync(cts.Token); // ne doit pas lever await Task.Delay(300); cts.Cancel(); await worker.StopAsync(cts.Token); // arrêt propre } [Fact] public async Task RetentionWorker_Cancellation_Stops() { var repo = new FakeDiagnosticEventRepository(); var worker = CreateWorker(repo, new DiagnosticStoreOptions { RetentionDays = 60, RetentionBatchSize = 100, RetentionIntervalHours = 1 }); using var cts = new CancellationTokenSource(); await worker.StartAsync(cts.Token); cts.Cancel(); // L'annulation doit terminer ExecuteAsync sans exception. await worker.StopAsync(cts.Token); } }