277 lines
11 KiB
C#
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));
|
|
}
|
|
}
|