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

277 lines
11 KiB
C#

using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 2 — Regroupement d'erreurs logiquement équivalentes. Les tests s'appuient sur
/// le <see cref="FakeDiagnosticEventRepository"/> (in-memory) ; l'implémentation
/// PostgreSQL de production exécute le même regroupement via GROUP BY serveur.
/// </summary>
public class DiagnosticErrorGroupTests
{
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset To = new(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
private static DiagnosticEventRecord Ev(
string eventCode = "ERR",
string? exceptionType = "System.Exception",
DateTimeOffset? ts = null,
string? component = null,
string? errorCode = null,
string? appVersion = null,
string? actorKey = null,
string level = "Error",
string priority = "P2") =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
Component = component,
ErrorCode = errorCode,
ExceptionType = exceptionType,
Level = level,
Priority = priority,
AppVersion = appVersion,
ActorKey = actorKey,
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
// --------------------------------- G1 : 500 équivalents → 1 groupe ----
[Fact]
public async Task GroupErrors_EquivalentEvents_ProduceSingleGroup()
{
var repo = new FakeDiagnosticEventRepository();
var events = new List<DiagnosticEventRecord>();
for (int i = 0; i < 500; i++)
{
events.Add(Ev(
eventCode: "OMK-API-001",
exceptionType: "System.InvalidOperationException",
component: "Omika.Api",
ts: From.AddSeconds(i)));
}
await repo.InsertBatchAsync(events);
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
DiagnosticErrorGroup group = Assert.Single(groups);
Assert.Equal(500, group.OccurrenceCount);
Assert.Equal("OMK-API-001", group.EventCode);
}
// ------------------------------- G2 : ExceptionType différent → séparés ----
[Fact]
public async Task GroupErrors_DifferentExceptionType_SeparateGroups()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "ERR", exceptionType: "System.A"),
Ev(eventCode: "ERR", exceptionType: "System.B")
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, groups.Count);
}
// --------------------------------- G3 : EventCode différent → séparés ----
[Fact]
public async Task GroupErrors_DifferentEventCode_SeparateGroups()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "A", exceptionType: "System.E"),
Ev(eventCode: "B", exceptionType: "System.E")
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, groups.Count);
}
// ----------------------------------------- G4 : hors période → exclus ----
[Fact]
public async Task GroupErrors_OutOfPeriod_AreExcluded()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "ERR", exceptionType: "System.E", ts: From.AddMinutes(-5)),
Ev(eventCode: "ERR", exceptionType: "System.E", ts: From.AddMinutes(5)),
Ev(eventCode: "ERR", exceptionType: "System.E", ts: To.AddMinutes(5))
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
DiagnosticErrorGroup group = Assert.Single(groups);
Assert.Equal(1, group.OccurrenceCount);
}
// ------------------------------ G5 : NULL ExceptionType → déterministe ----
[Fact]
public async Task GroupErrors_NullExceptionType_DoesNotThrow()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "ERR", exceptionType: null),
Ev(eventCode: "ERR", exceptionType: null),
Ev(eventCode: "ERR", exceptionType: "System.E")
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, groups.Count);
Assert.Contains(groups, g => g.ExceptionType is null && g.OccurrenceCount == 2);
Assert.Contains(groups, g => g.ExceptionType == "System.E" && g.OccurrenceCount == 1);
}
// --------------------------------------- G6 : FirstSeen/LastSeen ----
[Fact]
public async Task GroupErrors_FirstAndLastSeen_AreCorrect()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset first = From.AddMinutes(10);
DateTimeOffset last = From.AddMinutes(40);
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "ERR", exceptionType: "System.E", ts: first),
Ev(eventCode: "ERR", exceptionType: "System.E", ts: last),
Ev(eventCode: "ERR", exceptionType: "System.E", ts: From.AddMinutes(20))
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
DiagnosticErrorGroup group = Assert.Single(groups);
Assert.Equal(first, group.FirstSeenUtc);
Assert.Equal(last, group.LastSeenUtc);
}
// ----------------------------------------------- G7 : limite respectée ----
[Fact]
public async Task GroupErrors_RespectsLimit()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "A", exceptionType: "E"),
Ev(eventCode: "B", exceptionType: "E"),
Ev(eventCode: "C", exceptionType: "E")
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To, Limit = 2 });
Assert.Equal(2, groups.Count);
}
// ---------------------------------------- G8 : tri déterministe ----
[Fact]
public async Task GroupErrors_Ties_SortedDeterministically()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "B", exceptionType: "E"),
Ev(eventCode: "A", exceptionType: "E"),
Ev(eventCode: "C", exceptionType: "E")
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
// Tri par défaut : occurrence DESC puis clés croissantes (déterministe).
Assert.Equal(new[] { "A", "B", "C" }, groups.Select(g => g.EventCode).ToArray());
}
// ------------------------- Normalisation : casse insensible ----
[Fact]
public async Task GroupErrors_CaseInsensitiveExceptionType_SameGroup()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "ERR", exceptionType: "System.InvalidOperationException"),
Ev(eventCode: "ERR", exceptionType: "system.invalidoperationexception")
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
DiagnosticErrorGroup group = Assert.Single(groups);
Assert.Equal(2, group.OccurrenceCount);
// Valeur représentative brute (MIN) conservée pour l'affichage.
Assert.Equal("System.InvalidOperationException", group.ExceptionType);
}
// ------------------------- Normalisation : vide/espaces → NULL ----
[Fact]
public async Task GroupErrors_EmptyExceptionType_TreatedAsNull()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "ERR", exceptionType: ""),
Ev(eventCode: "ERR", exceptionType: " "),
Ev(eventCode: "ERR", exceptionType: null)
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
// vide, espaces et NULL normalisent tous vers la même clé de groupe.
DiagnosticErrorGroup group = Assert.Single(groups);
Assert.Equal(3, group.OccurrenceCount);
}
// ------------------------- Compteurs distincts (optionnels §13) ----
[Fact]
public async Task GroupErrors_AppVersionAndActorCounts()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(eventCode: "ERR", exceptionType: "E", appVersion: "1.0", actorKey: "actor-1"),
Ev(eventCode: "ERR", exceptionType: "E", appVersion: "1.0", actorKey: "actor-1"),
Ev(eventCode: "ERR", exceptionType: "E", appVersion: "2.0", actorKey: "actor-2")
});
IReadOnlyList<DiagnosticErrorGroup> groups = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
DiagnosticErrorGroup group = Assert.Single(groups);
Assert.Equal(3, group.OccurrenceCount);
Assert.Equal(2, group.AppVersionCount); // 1.0, 2.0
Assert.Equal(2, group.ActorCount); // actor-1, actor-2
}
// ------------------------- Validation période obligatoire ----
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task GroupErrors_Validate_RejectsMissingPeriod(bool missingFrom, bool missingTo)
{
var repo = new FakeDiagnosticEventRepository();
var query = new DiagnosticErrorGroupQuery
{
FromUtc = missingFrom ? null : From,
ToUtc = missingTo ? null : To
};
await Assert.ThrowsAsync<ArgumentException>(() => repo.GroupErrorsAsync(query));
}
}