Files
Omika/tests/Omika.Diagnostics.Tests/DiagnosticRetentionTests.cs
T
2026-10-23 18:46:50 +02:00

318 lines
12 KiB
C#

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;
/// <summary>Phase 5C.2C — rétention PostgreSQL : lots bornés, fail-open, boucle séparée de l'import.</summary>
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);
/// <summary>Événement d'ERREUR (level error) ancien : soumis à la règle « traité/non traité ».</summary>
private static DiagnosticEventRecord OldError(DateTimeOffset t) =>
new() { EventId = Guid.NewGuid(), EventCode = "TEST", Level = "error", TimestampUtc = t, Source = "mobile", IngestedAtUtc = t };
/// <summary>Identifiant URL-safe du groupe d'un événement (même normalisation que la purge).</summary>
private static string GroupId(DiagnosticEventRecord e) =>
DiagnosticErrorGroupLink.Encode(new DiagnosticErrorGroupIdentity(
e.EventCode,
DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode),
DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType),
DiagnosticErrorGrouping.NormalizeKey(e.Component)));
/// <summary>Marque le groupe d'un événement comme « traité » (tâche terminale), donc purgable.</summary>
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<DiagnosticEventRecord> 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<DiagnosticEventRecord>();
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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticRetentionWorker>.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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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);
}
}