using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
///
/// Tâche 3 — Page de détail + événements corrélés. Les tests s'appuient sur le
/// (in-memory) ; l'implémentation
/// PostgreSQL de production exécute les mêmes contrats (GetById paramétré, corrélation
/// bornée par fenêtre temporelle + LIMIT, déduplication intrinsèque au SELECT).
///
public class DiagnosticDetailTests
{
private static readonly DateTimeOffset Base = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
private static DiagnosticEventRecord Ev(
long id,
DateTimeOffset ts,
Guid? traceId = null,
Guid? sessionId = null,
Guid? operationId = null,
string? actorKey = null,
string? message = null,
string eventCode = "ERR",
string? component = null,
string? exceptionType = null,
string? stackTrace = null,
string? metadataJson = null) =>
new()
{
Id = id,
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts,
TraceId = traceId,
SessionId = sessionId,
OperationId = operationId,
ActorKey = actorKey,
Message = message,
ExceptionType = exceptionType,
StackTrace = stackTrace,
MetadataJson = metadataJson,
Component = component,
Level = "Error",
Priority = "P2",
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticCorrelationQuery Corr(
DateTimeOffset ts,
Guid? traceId = null,
Guid? sessionId = null,
Guid? operationId = null,
string? actorKey = null,
int limit = DiagnosticCorrelationDefaults.MaxLimit,
DateTimeOffset? anchor = null) =>
new()
{
FromUtc = ts.AddHours(-DiagnosticCorrelationDefaults.DefaultWindowHours),
ToUtc = ts.AddHours(DiagnosticCorrelationDefaults.DefaultWindowHours),
TraceId = traceId,
SessionId = sessionId,
OperationId = operationId,
ActorKey = actorKey,
AnchorUtc = anchor,
Limit = limit
};
// ------------------------------------------------------- D1 : GetById existant ----
[Fact]
public async Task GetById_Existing_ReturnsEvent()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[] { Ev(42, Base) });
DiagnosticEventRecord? ev = await repo.GetByIdAsync(42);
Assert.NotNull(ev);
Assert.Equal(42, ev.Id);
}
// ------------------------------------------------------- D2 : GetById absent ----
[Fact]
public async Task GetById_Missing_ReturnsNull()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[] { Ev(1, Base) });
Assert.Null(await repo.GetByIdAsync(999));
}
[Fact]
public async Task GetById_NonPositive_ReturnsNull()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[] { Ev(1, Base) });
Assert.Null(await repo.GetByIdAsync(0));
Assert.Null(await repo.GetByIdAsync(-7));
}
// ------------------------------------------------------- C1 : TraceId ----
[Fact]
public async Task GetCorrelated_ByTraceId_ReturnsMatchingEvents()
{
Guid traceId = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, Base, traceId: traceId),
Ev(2, Base.AddMinutes(1), traceId: traceId),
Ev(3, Base.AddMinutes(2)) // autre trace → exclu
});
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId));
Assert.Equal(2, result.Count);
Assert.All(result, e => Assert.Equal(traceId, e.TraceId));
}
// ------------------------------------------------------- C2 : SessionId ----
[Fact]
public async Task GetCorrelated_BySessionId_ReturnsMatchingEvents()
{
Guid sessionId = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, Base, sessionId: sessionId),
Ev(2, Base.AddMinutes(1), sessionId: sessionId),
Ev(3, Base.AddMinutes(2))
});
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, sessionId: sessionId));
Assert.Equal(2, result.Count);
Assert.All(result, e => Assert.Equal(sessionId, e.SessionId));
}
// ------------------------------------------------------- C3 : OperationId ----
[Fact]
public async Task GetCorrelated_ByOperationId_ReturnsMatchingEvents()
{
Guid operationId = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, Base, operationId: operationId),
Ev(2, Base.AddMinutes(1), operationId: operationId),
Ev(3, Base.AddMinutes(2))
});
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, operationId: operationId));
Assert.Equal(2, result.Count);
Assert.All(result, e => Assert.Equal(operationId, e.OperationId));
}
// ------------------------------------------------------- C4 : ActorKey ----
[Fact]
public async Task GetCorrelated_ByActorKey_ReturnsMatchingEvents()
{
const string actorKey = "ak-123456";
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, Base, actorKey: actorKey),
Ev(2, Base.AddMinutes(1), actorKey: actorKey),
Ev(3, Base.AddMinutes(2), actorKey: "other")
});
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, actorKey: actorKey));
Assert.Equal(2, result.Count);
Assert.All(result, e => Assert.Equal(actorKey, e.ActorKey));
}
// ------------------------------------------------------- C5 : fenêtre temporelle ----
[Fact]
public async Task GetCorrelated_OutOfWindow_AreExcluded()
{
Guid traceId = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, Base, traceId: traceId), // dans la fenêtre
Ev(2, Base.AddHours(-25), traceId: traceId), // avant la fenêtre (±24 h)
Ev(3, Base.AddHours(25), traceId: traceId) // après la fenêtre
});
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId));
DiagnosticEventRecord item = Assert.Single(result);
Assert.Equal(1, item.Id);
}
// ------------------------------------------------------- C6 : LIMIT ----
[Fact]
public async Task GetCorrelated_RespectsLimit()
{
Guid traceId = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
var events = new List();
for (int i = 0; i < 10; i++)
{
events.Add(Ev(i + 1, Base.AddMinutes(i), traceId: traceId));
}
await repo.InsertBatchAsync(events);
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId, limit: 3));
Assert.Equal(3, result.Count);
}
// ------------------------------------------------------- C7 : tri chronologique ----
[Fact]
public async Task GetCorrelated_ReturnsChronologicalOrder()
{
Guid traceId = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(3, Base.AddMinutes(2), traceId: traceId),
Ev(1, Base, traceId: traceId),
Ev(2, Base.AddMinutes(1), traceId: traceId)
});
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base, traceId: traceId));
Assert.Equal(new long[] { 1, 2, 3 }, result.Select(e => e.Id).ToArray());
}
// ------------------------------------- C7bis : sélection par proximité (ancre) ----
[Fact]
public async Task GetCorrelated_WithAnchor_ReturnsEventsClosestToAnchor()
{
// Un acteur actif produit bien plus d'événements que la borne : sans ancre, la
// sélection retenait les plus ANCIENS de la fenêtre, donc un contexte antérieur
// à l'incident (voire sans rapport avec lui).
const string actor = "acteur-ancre";
var repo = new FakeDiagnosticEventRepository();
var events = new List();
for (int i = 30; i >= 1; i--)
{
events.Add(Ev(100 + i, Base.AddMinutes(-i), actorKey: actor, message: $"avant{i:00}"));
}
for (int i = 1; i <= 3; i++)
{
events.Add(Ev(200 + i, Base.AddMinutes(i), actorKey: actor, message: $"apres{i:00}"));
}
await repo.InsertBatchAsync(events);
IReadOnlyList result = await repo.GetCorrelatedAsync(
Corr(Base, actorKey: actor, limit: 5, anchor: Base));
// Les 5 événements les plus PROCHES de l'ancre, des deux côtés (et non les
// 5 plus anciens de la fenêtre) : la distance à l'erreur est le critère.
Assert.Equal(5, result.Count);
Assert.All(
result,
e => Assert.True(
Math.Abs((e.TimestampUtc - Base).TotalMinutes) <= 3,
$"Événement à {e.TimestampUtc:O} : trop éloigné de l'ancre."));
Assert.Contains(result, e => e.Message == "avant01");
Assert.Contains(result, e => e.Message == "apres02");
Assert.DoesNotContain(result, e => e.Message == "avant30");
Assert.DoesNotContain(result, e => e.Message == "apres03");
// Restitution chronologique malgré la sélection par proximité.
Assert.Equal(
result.OrderBy(e => e.TimestampUtc).Select(e => e.Id).ToArray(),
result.Select(e => e.Id).ToArray());
}
// ------------------------------------------------------- C8 : déduplication ----
[Fact]
public async Task GetCorrelated_MatchingMultipleIdentifiers_AppearsOnce()
{
Guid traceId = Guid.NewGuid();
Guid sessionId = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, Base, traceId: traceId, sessionId: sessionId), // correspond aux DEUX identifiants
Ev(2, Base.AddMinutes(1), traceId: traceId) // correspond au traceId seul
});
IReadOnlyList result = await repo.GetCorrelatedAsync(
Corr(Base, traceId: traceId, sessionId: sessionId));
Assert.Equal(2, result.Count);
Assert.Equal(1, result.Count(e => e.Id == 1)); // l'événement 1 n'apparaît qu'une fois
}
// ------------------------------------------------------- C9 : aucun identifiant ----
[Fact]
public async Task GetCorrelated_NoIdentifiers_ReturnsEmptyWithoutQuery()
{
// ThrowingDiagnosticRepository lèverait si une requête était tentée : le contrat
// « aucun identifiant → aucune requête » est ainsi prouvé (résultat vide, pas d'exception).
var repo = new ThrowingDiagnosticRepository();
IReadOnlyList result = await repo.GetCorrelatedAsync(Corr(Base));
Assert.Empty(result);
}
// -------------------------------------------- validate fenêtre invalide ----
[Fact]
public async Task GetCorrelated_InvalidWindow_Throws()
{
var repo = new FakeDiagnosticEventRepository();
var query = new DiagnosticCorrelationQuery
{
FromUtc = Base,
ToUtc = Base, // FromUtc >= ToUtc → invalide
TraceId = Guid.NewGuid()
};
await Assert.ThrowsAsync(() => repo.GetCorrelatedAsync(query));
}
}