Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,80 @@
using Omika.Diagnostics.Queries;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 4 — Périodes du Dashboard (§T4/5) et détection des niveaux d'erreur réels
/// (§T4/6). Logique pure (Core), testable sans intégration Web : c'est exactement la
/// logique déléguée par <c>IndexModel</c> (<c>DashboardPeriods.Resolve</c>).
/// </summary>
public class DashboardPeriodTests
{
[Fact]
public void Resolve_Null_ReturnsDefault()
{
DashboardPeriod period = DashboardPeriods.Resolve(null);
Assert.Equal(DashboardPeriods.DefaultKey, period.Key);
Assert.Equal(TimeSpan.FromHours(24), period.Duration);
}
[Fact]
public void Resolve_24h()
{
DashboardPeriod period = DashboardPeriods.Resolve("24h");
Assert.Equal("24h", period.Key);
Assert.Equal(TimeSpan.FromHours(24), period.Duration);
Assert.Equal(DiagnosticTimeBucket.Hour, period.Bucket);
}
[Fact]
public void Resolve_7d()
{
DashboardPeriod period = DashboardPeriods.Resolve("7d");
Assert.Equal("7d", period.Key);
Assert.Equal(TimeSpan.FromDays(7), period.Duration);
Assert.Equal(DiagnosticTimeBucket.Day, period.Bucket);
}
[Fact]
public void Resolve_30d()
{
DashboardPeriod period = DashboardPeriods.Resolve("30d");
Assert.Equal("30d", period.Key);
Assert.Equal(TimeSpan.FromDays(30), period.Duration);
Assert.Equal(DiagnosticTimeBucket.Day, period.Bucket);
}
[Fact]
public void Resolve_Invalid_FallsBackToDefault()
{
DashboardPeriod period = DashboardPeriods.Resolve("999d");
Assert.Equal(DashboardPeriods.DefaultKey, period.Key);
}
[Fact]
public void Resolve_IsCaseInsensitive()
{
Assert.Equal("24h", DashboardPeriods.Resolve("24H").Key);
Assert.Equal("7d", DashboardPeriods.Resolve("7D").Key);
}
[Fact]
public void All_ExposesExactlyThreePeriods()
{
Assert.Equal(3, DashboardPeriods.All.Count);
Assert.Equal(new[] { "24h", "7d", "30d" }, DashboardPeriods.All.Select(p => p.Key).ToArray());
}
[Fact]
public void IsError_MatchesErrorAndCritical_CaseInsensitively()
{
Assert.True(DiagnosticErrorLevels.IsError("Error"));
Assert.True(DiagnosticErrorLevels.IsError("error"));
Assert.True(DiagnosticErrorLevels.IsError("Critical"));
Assert.True(DiagnosticErrorLevels.IsError("CRITICAL"));
Assert.False(DiagnosticErrorLevels.IsError("Info"));
Assert.False(DiagnosticErrorLevels.IsError("Warning"));
Assert.False(DiagnosticErrorLevels.IsError(null));
Assert.False(DiagnosticErrorLevels.IsError(" "));
}
}
@@ -0,0 +1,181 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tests d'intégration ActorKey (§19/§20) : identité authentifiée → ActorKey →
/// événement canonique → JSONL → importeur → repository → filtre par ActorKey.
/// Vérifie aussi qu'aucun identifiant brut n'est persisté dans les données de
/// diagnostic (hors fixtures strictement internes au test).
/// </summary>
public class DiagnosticActorKeyIntegrationTests : IDisposable
{
private static string TestKey() => Convert.ToBase64String(new byte[32]);
private readonly string _dir = CreateTempDirectory();
public void Dispose() => DeleteDirectory(_dir);
[Fact]
public async Task FullFlow_ComputesActorKey_AndPersistsIt_WithoutRawIdentity()
{
// Contexte : Tenant A = "BATIK.01", Collaborateur 42 (identité authentifiée).
const string tenant = "BATIK.01";
const string collaborateur = "42";
DiagnosticActorKey actorKey = new DiagnosticActorKeyProvider(TestKey()).Compute(tenant, collaborateur);
Assert.True(actorKey.HasValue);
Guid eventId = Guid.NewGuid();
var record = DiagnosticTestData.Record(
eventId,
"OMK-MOB-APP-001",
actorKey: actorKey.Value,
actorKeyVersion: actorKey.Version);
var source = new FakeDiagnosticJsonlSource(new[] { record });
var repository = new FakeDiagnosticEventRepository();
var importer = new DiagnosticImporter(source, repository, Options.Create(new DiagnosticImportOptions { BatchSize = 100 }));
DiagnosticImportResult result = await importer.ImportOnceAsync();
Assert.True(result.Succeeded);
Assert.Equal(1, result.ImportedCount);
DiagnosticEventRecord persisted = (await repository.QueryAsync(new DiagnosticEventQuery())).Items.Single();
Assert.Equal(actorKey.Value, persisted.ActorKey);
Assert.Equal(actorKey.Version, persisted.ActorKeyVersion);
// Aucun identifiant brut dans les champs persistés (le JSONL/MetadataJson
// n'est pas scruté ici : il n'y en a pas dans ce cas de figure).
Assert.DoesNotContain(collaborateur, persisted.EventCode, StringComparison.Ordinal);
Assert.Null(persisted.MetadataJson);
}
[Fact]
public async Task Repository_FiltersByActorKey()
{
const string actorA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const string actorB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
var repository = new FakeDiagnosticEventRepository();
await repository.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), actorKey: actorA),
DiagnosticTestData.Event(Guid.NewGuid(), actorKey: actorB),
DiagnosticTestData.Event(Guid.NewGuid(), actorKey: null) // système / sans utilisateur
});
DiagnosticQueryResult<DiagnosticEventRecord> byA = await repository.QueryAsync(new DiagnosticEventQuery { ActorKey = actorA });
Assert.Single(byA.Items);
Assert.Equal(actorA, byA.Items[0].ActorKey);
}
[Fact]
public async Task Store_PersistsActorKey_AndStripsRawIdentity()
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
DiagnosticActorKey actorKey = new DiagnosticActorKeyProvider(TestKey()).Compute("BATIK.01", "42");
var batch = new DiagnosticBatchDto
{
BatchId = Guid.NewGuid(),
Events =
[
new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
// Identifiants bruts fournis par le mobile (transport) :
DeviceId = "device-1",
ClientId = "BATIK.01",
UserId = "user-1",
CollaborateurId = "42",
Component = "télémétrie"
}
]
};
await store.StoreAsync(batch, actorKey, "trace-id", DateTimeOffset.UtcNow);
string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
// ActorKey + version présents dans l'enregistrement canonique.
using (JsonDocument doc = JsonDocument.Parse(content))
{
JsonElement root = doc.RootElement;
Assert.Equal(actorKey.Value, root.GetProperty("actorKey").GetString());
Assert.Equal(actorKey.Version, root.GetProperty("actorKeyVersion").GetInt32());
}
// Les identifiants bruts ne sont PAS persistés dans le JSONL (§9).
Assert.DoesNotContain("\"deviceId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"clientId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"userId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"collaborateurId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("device-1", content, StringComparison.Ordinal);
Assert.DoesNotContain("user-1", content, StringComparison.Ordinal);
Assert.DoesNotContain("\"42\"", content, StringComparison.Ordinal);
}
[Fact]
public async Task Store_WithoutActorKey_WritesNullActorKey()
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
await store.StoreAsync(
new DiagnosticBatchDto
{
Events = [new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }]
},
DiagnosticActorKey.Empty,
"trace-id",
DateTimeOffset.UtcNow);
string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
using JsonDocument doc = JsonDocument.Parse(content);
// Champs null omis (WhenWritingNull) : ActorKey absent → propriété absente.
Assert.False(doc.RootElement.TryGetProperty("actorKey", out _));
Assert.False(doc.RootElement.TryGetProperty("actorKeyVersion", out _));
}
private static string CreateTempDirectory()
{
string dir = Path.Combine(Path.GetTempPath(), "omika-actor-key-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private static string ReadAllTextShared(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static void DeleteDirectory(string dir)
{
try
{
if (Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
}
@@ -0,0 +1,140 @@
using Omika.Core.Observability.Identity;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tests unitaires de l'ActorKey (§19) : HMAC-SHA256 serveur, stable par
/// (tenant, collaborateur), non réversible, fail-open. Aucune clé de production
/// utilisée ici (clés de test déterministes uniquement).
/// </summary>
public class DiagnosticActorKeyTests
{
private static string KeyA() => Convert.ToBase64String(new byte[32]); // 32 octets 0x00
private static string KeyB() => Convert.ToBase64String(Enumerable.Repeat((byte)7, 32).ToArray());
[Fact]
public void SameTenant_SameCollaborateur_ProducesSameKey()
{
var provider = new DiagnosticActorKeyProvider(KeyA());
DiagnosticActorKey a = provider.Compute("BATIK.01", "42");
DiagnosticActorKey b = provider.Compute("BATIK.01", "42");
Assert.True(a.HasValue);
Assert.Equal(a.Value, b.Value);
Assert.Equal(a.Version, b.Version);
}
[Fact]
public void DifferentTenant_SameCollaborateur_ProducesDifferentKey()
{
var provider = new DiagnosticActorKeyProvider(KeyA());
DiagnosticActorKey a = provider.Compute("BATIK.01", "42");
DiagnosticActorKey b = provider.Compute("BATIK.02", "42");
Assert.NotEqual(a.Value, b.Value);
}
[Fact]
public void SameTenant_DifferentCollaborateur_ProducesDifferentKey()
{
var provider = new DiagnosticActorKeyProvider(KeyA());
DiagnosticActorKey a = provider.Compute("BATIK.01", "42");
DiagnosticActorKey b = provider.Compute("BATIK.01", "43");
Assert.NotEqual(a.Value, b.Value);
}
[Fact]
public void DifferentKey_ProducesDifferentActorKey()
{
DiagnosticActorKey a = new DiagnosticActorKeyProvider(KeyA()).Compute("BATIK.01", "42");
DiagnosticActorKey b = new DiagnosticActorKeyProvider(KeyB()).Compute("BATIK.01", "42");
Assert.True(a.HasValue);
Assert.True(b.HasValue);
Assert.NotEqual(a.Value, b.Value);
}
[Fact]
public void ActorKey_IsHexOnly_AndContainsNoNominativeData()
{
DiagnosticActorKey key = new DiagnosticActorKeyProvider(KeyA()).Compute("BATIK.01", "42");
Assert.NotNull(key.Value);
Assert.Equal(64, key.Value!.Length);
Assert.Matches("^[0-9a-f]{64}$", key.Value);
Assert.DoesNotContain("42", key.Value, StringComparison.Ordinal);
Assert.DoesNotContain("BATIK", key.Value, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("collaborateur", key.Value, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ActorKey_HasStableFormat_Versioned()
{
DiagnosticActorKey key = new DiagnosticActorKeyProvider(KeyA()).Compute("BATIK.01", "7");
Assert.Equal(1, key.Version);
Assert.NotNull(key.Value);
Assert.Equal(64, key.Value!.Length);
Assert.Equal(key.Value.ToLowerInvariant(), key.Value); // hex minuscule
}
[Fact]
public void MissingKey_IsFailOpen_ReturnsEmpty()
{
var cases = new[]
{
new DiagnosticActorKeyProvider(null),
new DiagnosticActorKeyProvider(string.Empty),
new DiagnosticActorKeyProvider(" "),
new DiagnosticActorKeyProvider("not-base64!!!"),
new DiagnosticActorKeyProvider(Convert.ToBase64String(new byte[16])) // 128 bits < 256
};
foreach (DiagnosticActorKeyProvider provider in cases)
{
DiagnosticActorKey key = provider.Compute("BATIK.01", "42");
Assert.False(key.HasValue);
Assert.Null(key.Value);
Assert.Null(key.Version);
}
}
[Fact]
public void MissingIdentity_IsFailOpen_ReturnsEmpty()
{
var provider = new DiagnosticActorKeyProvider(KeyA());
Assert.False(provider.Compute("", "42").HasValue);
Assert.False(provider.Compute(" ", "42").HasValue);
Assert.False(provider.Compute("BATIK.01", null).HasValue);
Assert.False(provider.Compute("BATIK.01", "").HasValue);
Assert.False(provider.Compute("BATIK.01", " ").HasValue);
}
[Fact]
public void CanonicalString_IsVersionedAndDeterministic()
{
string a = DiagnosticActorKeyProvider.BuildCanonicalString(1, "BATIK.01", "42");
string b = DiagnosticActorKeyProvider.BuildCanonicalString(1, "BATIK.01", "42");
Assert.Equal(a, b);
Assert.Equal("v1|tenant:BATIK.01|collaborateur:42", a);
}
[Fact]
public void ActorKey_DoesNotRevealIdentity_WhenComparedAcrossTenants()
{
// Deux collaborateurs différents ne peuvent pas être confondus, et la clé
// seule ne permet pas de retrouver le CollaborateurId (§2 : non réversible).
var provider = new DiagnosticActorKeyProvider(KeyA());
DiagnosticActorKey key = provider.Compute("BATIK.01", "12345678");
Assert.NotNull(key.Value);
Assert.DoesNotContain("12345678", key.Value, StringComparison.Ordinal);
}
}
@@ -0,0 +1,290 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 1 — Agrégations statistiques côté serveur. Les tests s'appuient sur le
/// <see cref="FakeDiagnosticEventRepository"/> (in-memory) ; l'implémentation
/// PostgreSQL de production exécute les mêmes contrats via GROUP BY / date_trunc.
/// </summary>
public class DiagnosticAggregationTests
{
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 = "TEST",
DateTimeOffset? ts = null,
string? component = null,
string? errorCode = null,
string? appVersion = null,
string level = "Info",
string priority = "P2") =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
Component = component,
ErrorCode = errorCode,
AppVersion = appVersion,
Level = level,
Priority = priority,
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
// ---------------------------------------------------- Test 1 : EventCode ----
[Fact]
public async Task CountGrouped_EventCode_ReturnsDescendingCounts()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-API-001", From.AddMinutes(1)),
Ev("OMK-API-001", From.AddMinutes(2)),
Ev("OMK-API-001", From.AddMinutes(3)),
Ev("OMK-API-002", From.AddMinutes(4))
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.EventCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, result.Count);
Assert.Equal("OMK-API-001", result[0].Key);
Assert.Equal(3, result[0].Count);
Assert.Equal("OMK-API-002", result[1].Key);
Assert.Equal(1, result[1].Count);
}
// ------------------------------------------------------ Test 2 : période ----
[Fact]
public async Task CountGrouped_RespectsTimePeriod()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("BEFORE", From.AddMinutes(-5)), // avant FromUtc
Ev("IN", From.AddMinutes(10)), // dans la période
Ev("AFTER", To.AddMinutes(5)) // après ToUtc (borne exclusive)
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.EventCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
DiagnosticGroupCount group = Assert.Single(result);
Assert.Equal("IN", group.Key);
Assert.Equal(1, group.Count);
}
// -------------------------------------------------------- Test 3 : limit ----
[Fact]
public async Task CountGrouped_RespectsLimit()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1)), Ev("A", From.AddMinutes(2)),
Ev("B", From.AddMinutes(3)), Ev("B", From.AddMinutes(4)),
Ev("C", From.AddMinutes(5))
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.EventCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Limit = 2 });
Assert.Equal(2, result.Count);
Assert.Equal("A", result[0].Key);
Assert.Equal("B", result[1].Key);
}
// ----------------------------------------------------- Test 4 : NULL keys ----
[Fact]
public async Task CountGrouped_NullErrorCode_DoesNotThrow()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1), errorCode: "E1"),
Ev("B", From.AddMinutes(2)) // ErrorCode null
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.ErrorCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "E1" && g.Count == 1);
Assert.Contains(result, g => g.Key is null && g.Count == 1);
}
// ------------------------------------------------------ Test 5 : filtres ----
[Fact]
public async Task CountGrouped_AppliesComponentFilter()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1), component: "Omika.Api"),
Ev("A", From.AddMinutes(2), component: "Omika.Api"),
Ev("A", From.AddMinutes(3), component: "Omika.Worker")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.EventCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Component = "Omika.Api" });
DiagnosticGroupCount group = Assert.Single(result);
Assert.Equal("A", group.Key);
Assert.Equal(2, group.Count);
}
// ------------------------------------------------ Test 6 : bucket horaire ----
[Fact]
public async Task CountByTimeBucket_Hour_ReturnsChronologicalBuckets()
{
var repo = new FakeDiagnosticEventRepository();
var baseTime = new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero);
await repo.InsertBatchAsync(new[]
{
Ev("A", baseTime), // 10:30 → bucket 10:00
Ev("B", baseTime.AddMinutes(15)), // 10:45 → bucket 10:00
Ev("C", baseTime.AddHours(1)) // 11:30 → bucket 11:00
});
IReadOnlyList<DiagnosticBucketCount> result = await repo.CountByTimeBucketAsync(
DiagnosticTimeBucket.Hour,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, result.Count);
Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero), result[0].BucketUtc);
Assert.Equal(2, result[0].Count);
Assert.Equal(new DateTimeOffset(2025, 1, 1, 11, 0, 0, TimeSpan.Zero), result[1].BucketUtc);
Assert.Equal(1, result[1].Count);
}
// ----------------------------------------------- Test 7 : bucket journalier --
[Fact]
public async Task CountByTimeBucket_Day_ReturnsChronologicalBuckets()
{
var repo = new FakeDiagnosticEventRepository();
var day1 = new DateTimeOffset(2025, 1, 1, 9, 0, 0, TimeSpan.Zero);
var day2 = new DateTimeOffset(2025, 1, 2, 9, 0, 0, TimeSpan.Zero);
await repo.InsertBatchAsync(new[]
{
Ev("A", day1),
Ev("B", day1.AddHours(5)),
Ev("C", day2)
});
IReadOnlyList<DiagnosticBucketCount> result = await repo.CountByTimeBucketAsync(
DiagnosticTimeBucket.Day,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, result.Count);
Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), result[0].BucketUtc);
Assert.Equal(2, result[0].Count);
Assert.Equal(new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero), result[1].BucketUtc);
Assert.Equal(1, result[1].Count);
}
// ---------------------------------------- Test 8 : validation de la période --
[Fact]
public void AggregateQuery_Validate_RejectsMissingFrom()
{
Assert.Throws<ArgumentException>(() => new DiagnosticAggregateQuery { ToUtc = DateTimeOffset.UtcNow }.Validate());
}
[Fact]
public void AggregateQuery_Validate_RejectsMissingTo()
{
Assert.Throws<ArgumentException>(() => new DiagnosticAggregateQuery { FromUtc = DateTimeOffset.UtcNow }.Validate());
}
[Fact]
public void AggregateQuery_Validate_RejectsFromAfterTo()
{
DateTimeOffset t = DateTimeOffset.UtcNow;
Assert.Throws<ArgumentException>(() => new DiagnosticAggregateQuery { FromUtc = t.AddMinutes(1), ToUtc = t }.Validate());
}
[Fact]
public void AggregateQuery_Validate_RejectsTooLongPeriod()
{
DateTimeOffset from = DateTimeOffset.UtcNow;
DateTimeOffset to = from.AddDays(DiagnosticAggregateDefaults.MaxPeriodDays + 1);
Assert.Throws<ArgumentException>(() => new DiagnosticAggregateQuery { FromUtc = from, ToUtc = to }.Validate());
}
[Fact]
public async Task CountGrouped_InvalidPeriod_Throws()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset t = DateTimeOffset.UtcNow;
await Assert.ThrowsAsync<ArgumentException>(() =>
repo.CountGroupedAsync(
DiagnosticGroupDimension.EventCode,
new DiagnosticAggregateQuery { FromUtc = t, ToUtc = t }));
}
// ------------------------------------------- Dimensions complémentaires -----
[Fact]
public async Task CountGrouped_AppVersion_ReturnsGroups()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1), appVersion: "1.3.1"),
Ev("B", From.AddMinutes(2), appVersion: "1.3.1"),
Ev("C", From.AddMinutes(3), appVersion: "1.3.2")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.AppVersion,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "1.3.1" && g.Count == 2);
Assert.Contains(result, g => g.Key == "1.3.2" && g.Count == 1);
}
[Fact]
public async Task CountGrouped_Component_ReturnsGroups()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1), component: "Omika.Api"),
Ev("B", From.AddMinutes(2), component: "Omika.Api"),
Ev("C", From.AddMinutes(3), component: "Omika.Diagnostics.Worker")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.Component,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "Omika.Api" && g.Count == 2);
Assert.Contains(result, g => g.Key == "Omika.Diagnostics.Worker" && g.Count == 1);
}
[Fact]
public void AggregateQuery_ClampsLimit()
{
var tooBig = new DiagnosticAggregateQuery { Limit = 100_000 };
Assert.Equal(DiagnosticAggregateDefaults.MaxLimit, tooBig.Limit);
var tooSmall = new DiagnosticAggregateQuery { Limit = 0 };
Assert.Equal(1, tooSmall.Limit);
var defaultQuery = new DiagnosticAggregateQuery();
Assert.Equal(DiagnosticAggregateDefaults.DefaultLimit, defaultQuery.Limit);
}
}
@@ -0,0 +1,85 @@
using Omika.Diagnostics.Security;
namespace Omika.Diagnostics.Tests;
/// <summary>Tests de la génération/hachage de la clé d'accès serveur client (T19, §27-§32).</summary>
public class DiagnosticApiKeyGeneratorTests
{
[Fact]
public void GenerateKey_HasPrefix_AndDecodesTo32Bytes()
{
string key = DiagnosticApiKeyGenerator.GenerateKey();
Assert.StartsWith(DiagnosticApiKeyGenerator.KeyPrefix, key);
string base64Url = key[DiagnosticApiKeyGenerator.KeyPrefix.Length..];
byte[] bytes = DecodeBase64Url(base64Url);
Assert.Equal(DiagnosticApiKeyGenerator.KeyBytes, bytes.Length);
}
[Fact]
public void GenerateKey_ProducesUniqueKeys()
{
HashSet<string> keys = Enumerable.Range(0, 100).Select(_ => DiagnosticApiKeyGenerator.GenerateKey()).ToHashSet();
Assert.Equal(100, keys.Count);
}
[Fact]
public void Hash_IsDeterministic_And64HexChars()
{
string hash1 = DiagnosticApiKeyGenerator.Hash("omkdiag_test");
string hash2 = DiagnosticApiKeyGenerator.Hash("omkdiag_test");
Assert.Equal(hash1, hash2);
Assert.Equal(64, hash1.Length);
Assert.Matches("^[0-9A-F]{64}$", hash1);
}
[Fact]
public void Verify_ReturnsTrue_ForMatchingKey()
{
string key = DiagnosticApiKeyGenerator.GenerateKey();
string hash = DiagnosticApiKeyGenerator.Hash(key);
Assert.True(DiagnosticApiKeyGenerator.Verify(key, hash));
}
[Fact]
public void Verify_ReturnsFalse_ForWrongKey()
{
string hash = DiagnosticApiKeyGenerator.Hash("omkdiag_real_key");
Assert.False(DiagnosticApiKeyGenerator.Verify("omkdiag_other_key", hash));
}
[Fact]
public void Verify_ReturnsFalse_ForMalformedHash()
{
Assert.False(DiagnosticApiKeyGenerator.Verify("omkdiag_x", "not-a-hex-hash"));
}
[Fact]
public void Verify_ReturnsFalse_ForEmptyInputs()
{
Assert.False(DiagnosticApiKeyGenerator.Verify("", "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"));
Assert.False(DiagnosticApiKeyGenerator.Verify("omkdiag_x", ""));
}
[Fact]
public void DisplayPrefix_ReturnsFirst8Chars()
{
string prefix = DiagnosticApiKeyGenerator.DisplayPrefix("omkdiag_abcdefgh", 8);
Assert.Equal("omkdiag_", prefix);
Assert.Equal(8, prefix.Length);
}
private static byte[] DecodeBase64Url(string value)
{
string base64 = value.Replace('-', '+').Replace('_', '/');
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
@@ -0,0 +1,123 @@
using Omika.Diagnostics.Security;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 9 — Authentification : logique de sécurité PURE (hachage PBKDF2, authentification
/// d'opérateur, règles de rôles/policies). Les comportements HTTP (cookie, redirection,
/// 403) sont appliqués par la convention ASP.NET Core <c>AuthorizeFolder("/")</c> et ne
/// sont pas testables ici (le projet de tests ne référence pas Omika.Diagnostics.Web).
/// </summary>
public class DiagnosticAuthTests
{
// -------------------------------------------------- Hachage ----
[Fact]
public void PasswordHasher_HashAndVerify_RoundTrip()
{
string hash = DiagnosticPasswordHasher.Hash("s3cret-pass!");
Assert.True(DiagnosticPasswordHasher.Verify("s3cret-pass!", hash));
}
[Fact]
public void PasswordHasher_WrongPassword_Fails()
{
string hash = DiagnosticPasswordHasher.Hash("s3cret-pass!");
Assert.False(DiagnosticPasswordHasher.Verify("autre", hash));
}
[Fact]
public void PasswordHasher_UniqueSalts()
{
string h1 = DiagnosticPasswordHasher.Hash("same");
string h2 = DiagnosticPasswordHasher.Hash("same");
Assert.NotEqual(h1, h2); // sel aléatoire → hash différent.
}
[Theory]
[InlineData("")]
[InlineData("not-a-valid-hash")]
[InlineData("bad.bad.bad")]
[InlineData("100000.!!!.###")]
public void PasswordHasher_MalformedOrEmpty_Fails(string storedHash)
{
Assert.False(DiagnosticPasswordHasher.Verify("x", storedHash));
}
// -------------------------------------------------- Authentification opérateur ----
[Fact]
public void Authenticate_ValidCredentials_ReturnsOperator()
{
string hash = DiagnosticPasswordHasher.Hash("pw");
var op = new DiagnosticOperator("alice", hash, new[] { DiagnosticRoles.Support });
DiagnosticOperator? result = DiagnosticOperatorAuthenticator.Authenticate(new[] { op }, "alice", "pw");
Assert.NotNull(result);
Assert.Equal("alice", result.Username);
Assert.Contains(DiagnosticRoles.Support, result.Roles);
}
[Fact]
public void Authenticate_WrongPassword_ReturnsNull()
{
var op = new DiagnosticOperator("alice", DiagnosticPasswordHasher.Hash("pw"), new[] { "Support" });
Assert.Null(DiagnosticOperatorAuthenticator.Authenticate(new[] { op }, "alice", "nope"));
}
[Fact]
public void Authenticate_UnknownUser_ReturnsNull()
{
var op = new DiagnosticOperator("alice", DiagnosticPasswordHasher.Hash("pw"), new[] { "Support" });
Assert.Null(DiagnosticOperatorAuthenticator.Authenticate(new[] { op }, "bob", "pw"));
}
[Fact]
public void Authenticate_NoOperators_FailClosed()
{
// Production sans configuration → aucun accès (§T9/30).
Assert.Null(DiagnosticOperatorAuthenticator.Authenticate(Array.Empty<DiagnosticOperator>(), "alice", "pw"));
}
[Fact]
public void Authenticate_BlankInput_Fails()
{
var op = new DiagnosticOperator("alice", DiagnosticPasswordHasher.Hash("pw"), new[] { "Support" });
Assert.Null(DiagnosticOperatorAuthenticator.Authenticate(new[] { op }, " ", "pw"));
Assert.Null(DiagnosticOperatorAuthenticator.Authenticate(new[] { op }, "alice", " "));
}
// -------------------------------------------------- Rôles / policies ----
[Fact]
public void IsSupport_SupportOrAdmin()
{
Assert.True(DiagnosticAuthorization.IsSupport(new[] { DiagnosticRoles.Support }));
Assert.True(DiagnosticAuthorization.IsSupport(new[] { DiagnosticRoles.Admin }));
Assert.True(DiagnosticAuthorization.IsSupport(new[] { DiagnosticRoles.Support, DiagnosticRoles.Admin }));
Assert.False(DiagnosticAuthorization.IsSupport(new[] { "Autre" }));
Assert.False(DiagnosticAuthorization.IsSupport(Array.Empty<string>()));
}
[Fact]
public void IsAdmin_OnlyAdmin()
{
Assert.True(DiagnosticAuthorization.IsAdmin(new[] { DiagnosticRoles.Admin }));
Assert.False(DiagnosticAuthorization.IsAdmin(new[] { DiagnosticRoles.Support }));
Assert.False(DiagnosticAuthorization.IsAdmin(Array.Empty<string>()));
}
[Fact]
public void Roles_AreCaseInsensitive()
{
Assert.True(DiagnosticAuthorization.IsSupport(new[] { "support" }));
Assert.True(DiagnosticAuthorization.IsAdmin(new[] { "admin" }));
}
[Fact]
public void RoleConstants_AreStable()
{
Assert.Equal("Support", DiagnosticRoles.Support);
Assert.Equal("Admin", DiagnosticRoles.Admin);
Assert.Equal("DiagnosticsSupport", DiagnosticAuthPolicies.Support);
Assert.Equal("DiagnosticsAdmin", DiagnosticAuthPolicies.Admin);
}
}
@@ -0,0 +1,51 @@
using Omika.Diagnostics.Security;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T23 — Matrice d'autorisation finale (Support / Admin), au niveau des règles pures.
/// Les conventions ASP.NET (Program.cs) appliquent : dossier "/" → DiagnosticsSupport ;
/// /System, /Clients, /Users → DiagnosticsAdmin. Ici on vérifie la logique de rôle.
/// </summary>
public class DiagnosticAuthorizationMatrixTests
{
private static readonly string[] SupportOnly = [DiagnosticRoles.Support];
private static readonly string[] AdminOnly = [DiagnosticRoles.Admin];
[Theory]
[InlineData("Dashboard")]
[InlineData("Diagnostics")]
[InlineData("Erreurs")]
[InlineData("Traces")]
[InlineData("Synchronisation")]
[InlineData("Telemetry")]
[InlineData("Parc")]
[InlineData("Versions")]
[InlineData("Referentiel")]
public void SupportPages_AccessibleToSupportAndAdmin(string _)
{
Assert.True(DiagnosticAuthorization.IsSupport(SupportOnly));
Assert.True(DiagnosticAuthorization.IsSupport(AdminOnly));
}
[Theory]
[InlineData("System")]
[InlineData("Clients")]
[InlineData("Users")]
public void AdminPages_AdminOnly(string _)
{
// Admin : accès.
Assert.True(DiagnosticAuthorization.IsAdmin(AdminOnly));
// Support seul : refus.
Assert.False(DiagnosticAuthorization.IsAdmin(SupportOnly));
// Mais un Support reste un Support (accès aux pages de consultation).
Assert.True(DiagnosticAuthorization.IsSupport(SupportOnly));
}
[Fact]
public void Admin_KeepsSupportRights()
{
// §18 : un Admin doit conserver l'accès Support (IsSupport(Admin) == true).
Assert.True(DiagnosticAuthorization.IsSupport(AdminOnly));
}
}
@@ -0,0 +1,210 @@
using System.Text.Json;
using Omika.Core.Observability.External;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 13-A — Socle de stockage ClientCode / ServerInstance / Environment :
/// compatibilité additive (NULL), conservation des valeurs, lecture/écriture du
/// repository (fake) et filtres backend paramétrés (miroir du repository réel).
/// Aucun PostgreSQL réel requis (même convention que les autres tests, §51).
/// </summary>
public class DiagnosticClientContextTests
{
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
// --------------------------- A1 : ancien record sans contexte --------------
[Fact]
public void A1_RecordWithoutContext_AllContextFieldsAreNull()
{
DiagnosticEventRecord record = DiagnosticTestData.Event(Guid.NewGuid(), "OLD");
Assert.Null(record.ClientCode);
Assert.Null(record.ServerInstance);
Assert.Null(record.Environment);
}
// --------------------------- A2/A3/A4 : conservation d'une valeur ----------
[Fact]
public void A2_RecordWithClientCode_PreservesValue()
{
DiagnosticEventRecord record = EventWithContext(Guid.NewGuid(), clientCode: "CLIENT-A");
Assert.Equal("CLIENT-A", record.ClientCode);
}
[Fact]
public void A3_RecordWithServerInstance_PreservesValue()
{
DiagnosticEventRecord record = EventWithContext(Guid.NewGuid(), serverInstance: "API-SRV-01");
Assert.Equal("API-SRV-01", record.ServerInstance);
}
[Fact]
public void A4_RecordWithEnvironment_PreservesValue()
{
DiagnosticEventRecord record = EventWithContext(Guid.NewGuid(), environment: "Production");
Assert.Equal("Production", record.Environment);
}
// --------------------------- A5 : insert avec les trois valeurs -------------
[Fact]
public async Task A5_InsertWithAllThreeValues_StoresAndReadsThem()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord record = EventWithContext(Guid.NewGuid(), "CLIENT-A", "API-SRV-01", "Production");
record.Id = 42;
int inserted = await repo.InsertBatchAsync(new[] { record });
Assert.Equal(1, inserted);
DiagnosticEventRecord? read = await repo.GetByIdAsync(42);
Assert.NotNull(read);
Assert.Equal("CLIENT-A", read!.ClientCode);
Assert.Equal("API-SRV-01", read.ServerInstance);
Assert.Equal("Production", read.Environment);
}
// --------------------------- A6 : insert NULL compatible --------------------
[Fact]
public async Task A6_InsertWithNullContext_IsAcceptedAndReadsNull()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord record = EventWithContext(Guid.NewGuid(), null, null, null);
record.Id = 7;
int inserted = await repo.InsertBatchAsync(new[] { record });
Assert.Equal(1, inserted);
DiagnosticEventRecord? read = await repo.GetByIdAsync(7);
Assert.NotNull(read);
Assert.Null(read!.ClientCode);
Assert.Null(read.ServerInstance);
Assert.Null(read.Environment);
}
// --------------------------- A7 : lecture repository restitue les valeurs ----
[Fact]
public async Task A7_QueryAsync_RestoresContextValues()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord record = EventWithContext(Guid.NewGuid(), "CLIENT-B", "API-SRV-02", "Staging");
record.Id = 10;
await repo.InsertBatchAsync(new[] { record });
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.QueryAsync(new DiagnosticEventQuery());
DiagnosticEventRecord read = Assert.Single(result.Items);
Assert.Equal("CLIENT-B", read.ClientCode);
Assert.Equal("API-SRV-02", read.ServerInstance);
Assert.Equal("Staging", read.Environment);
}
// --------------------------- A8 : ancien JSONL / source absente → NULL ------
[Fact]
public void A8_Mapper_OldRecordWithoutContext_LeavesContextNull()
{
// RemoteDiagnosticRecord expose désormais ClientCode/ServerInstance/Environment
// (T13-B), mais un ancien enregistrement sans contexte porte ces champs à null :
// le mapper doit les laisser NULL (aucune valeur inventée).
RemoteDiagnosticRecord old = DiagnosticTestData.Record(Guid.NewGuid(), "OLD");
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(old, "mobile");
Assert.NotNull(mapped);
Assert.Null(mapped!.ClientCode);
Assert.Null(mapped.ServerInstance);
Assert.Null(mapped.Environment);
}
[Fact]
public void A8_OldJsonlLineWithoutContextFields_DeserializesAndMapsToNull()
{
RemoteDiagnosticRecord old = DiagnosticTestData.Record(Guid.NewGuid(), "OLD");
string line = DiagnosticTestData.SerializeLine(old);
// Aucun champ « ClientCode/ServerInstance/Environment » requis : la ligne
// ancienne (sans ces champs) doit être acceptée telle quelle (§20).
RemoteDiagnosticRecord? parsed =
JsonSerializer.Deserialize<RemoteDiagnosticRecord>(line, new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(parsed);
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(parsed, "mobile");
Assert.NotNull(mapped);
Assert.Null(mapped!.ClientCode);
Assert.Null(mapped.ServerInstance);
Assert.Null(mapped.Environment);
}
// --------------------------- A9/A10/A11 : filtres backend -------------------
[Fact]
public async Task A9_ClientCodeFilter_ReturnsOnlyMatching()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
EventWithContext(Guid.NewGuid(), "CLIENT-A", "SRV-1", "Prod", From.AddMinutes(1)),
EventWithContext(Guid.NewGuid(), "CLIENT-B", "SRV-1", "Prod", From.AddMinutes(2))
});
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = "CLIENT-A" });
Assert.Single(result.Items);
Assert.Equal("CLIENT-A", result.Items[0].ClientCode);
}
[Fact]
public async Task A10_ServerInstanceFilter_ReturnsOnlyMatching()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
EventWithContext(Guid.NewGuid(), "CLIENT-A", "SRV-1", "Prod", From.AddMinutes(1)),
EventWithContext(Guid.NewGuid(), "CLIENT-A", "SRV-2", "Prod", From.AddMinutes(2))
});
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { ServerInstance = "SRV-2" });
Assert.Single(result.Items);
Assert.Equal("SRV-2", result.Items[0].ServerInstance);
}
[Fact]
public async Task A11_EnvironmentFilter_ReturnsOnlyMatching()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
EventWithContext(Guid.NewGuid(), "CLIENT-A", "SRV-1", "Production", From.AddMinutes(1)),
EventWithContext(Guid.NewGuid(), "CLIENT-A", "SRV-1", "Staging", From.AddMinutes(2))
});
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Staging" });
Assert.Single(result.Items);
Assert.Equal("Staging", result.Items[0].Environment);
}
private static DiagnosticEventRecord EventWithContext(
Guid id,
string? clientCode = null,
string? serverInstance = null,
string? environment = null,
DateTimeOffset? timestamp = null)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(id, "TEST", timestamp);
record.ClientCode = clientCode;
record.ServerInstance = serverInstance;
record.Environment = environment;
return record;
}
}
@@ -0,0 +1,168 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 4 (extension) — Séries par client et classement des licences en erreur.
/// Contrats couverts : <c>CountByTimeBucketAndClientAsync</c> (répartition par client,
/// NULL/vides regroupés, ErrorsOnly) et <c>CountLicensesByErrorsAsync</c> (classement
/// par nombre d'erreurs, licences manquantes exclues, limite). Tests sur le
/// <see cref="FakeDiagnosticEventRepository"/> (in-memory) ; l'implémentation PostgreSQL
/// exécute les mêmes contrats via <c>GROUP BY date_trunc(...), NULLIF(TRIM(client_code),'')</c>
/// et <c>GROUP BY NULLIF(TRIM(license_number),'')</c>.
/// </summary>
public class DiagnosticClientSeriesTests
{
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(
DateTimeOffset? ts = null,
string? clientCode = null,
string? licenseNumber = null,
string level = "Info") =>
new()
{
EventId = Guid.NewGuid(),
EventCode = "TEST",
TimestampUtc = ts ?? From.AddMinutes(1),
ClientCode = clientCode,
LicenseNumber = licenseNumber,
Level = level,
Priority = "P2",
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticAggregateQuery All() => new() { FromUtc = From, ToUtc = To };
// --------------------------------------------- série par client : répartition ----
[Fact]
public async Task CountByTimeBucketAndClient_SplitsByClient()
{
var repo = new FakeDiagnosticEventRepository();
var t = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero);
await repo.InsertBatchAsync(new[]
{
Ev(t, clientCode: "BATIK.01"),
Ev(t.AddMinutes(5), clientCode: "BATIK.01"),
Ev(t.AddMinutes(10), clientCode: "AUTRE.02")
});
IReadOnlyList<DiagnosticBucketClientCount> result = await repo.CountByTimeBucketAndClientAsync(
DiagnosticTimeBucket.Hour, All());
Assert.Equal(2, result.Count);
Assert.All(result, r => Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero), r.BucketUtc));
Assert.Contains(result, r => r.ClientCode == "BATIK.01" && r.Count == 2);
Assert.Contains(result, r => r.ClientCode == "AUTRE.02" && r.Count == 1);
}
// ---------------------------------------- série par client : client manquant ----
[Fact]
public async Task CountByTimeBucketAndClient_NullAndBlankClients_GroupedUnderNull()
{
var repo = new FakeDiagnosticEventRepository();
var t = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero);
await repo.InsertBatchAsync(new[]
{
Ev(t, clientCode: null),
Ev(t.AddMinutes(5), clientCode: " "), // espaces → groupe « non identifié »
Ev(t.AddMinutes(10), clientCode: "BATIK.01")
});
IReadOnlyList<DiagnosticBucketClientCount> result = await repo.CountByTimeBucketAndClientAsync(
DiagnosticTimeBucket.Hour, All());
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.ClientCode is null && r.Count == 2);
Assert.Contains(result, r => r.ClientCode == "BATIK.01" && r.Count == 1);
}
// ------------------------------------------- série par client : ErrorsOnly ----
[Fact]
public async Task CountByTimeBucketAndClient_ErrorsOnly_OnlyErrorLevels()
{
var repo = new FakeDiagnosticEventRepository();
var t = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero);
await repo.InsertBatchAsync(new[]
{
Ev(t, clientCode: "A", level: "Error"),
Ev(t.AddMinutes(5), clientCode: "A", level: "Info"),
Ev(t.AddMinutes(10), clientCode: "B", level: "critical")
});
IReadOnlyList<DiagnosticBucketClientCount> result = await repo.CountByTimeBucketAndClientAsync(
DiagnosticTimeBucket.Hour,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true });
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.ClientCode == "A" && r.Count == 1);
Assert.Contains(result, r => r.ClientCode == "B" && r.Count == 1);
}
// ------------------------------------------ licences en erreur : classement ----
[Fact]
public async Task CountLicensesByErrors_RanksByErrorCount()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(From.AddMinutes(1), licenseNumber: "L-1", level: "Error"),
Ev(From.AddMinutes(2), licenseNumber: "L-1", level: "Critical"),
Ev(From.AddMinutes(3), licenseNumber: "L-2", level: "Error"),
Ev(From.AddMinutes(4), licenseNumber: "L-1", level: "Info"), // non-erreur → exclu
Ev(From.AddMinutes(5), licenseNumber: "L-2", level: "Info")
});
IReadOnlyList<DiagnosticLicenseErrorCount> result = await repo.CountLicensesByErrorsAsync(All());
Assert.Equal(2, result.Count);
Assert.Equal("L-1", result[0].LicenseNumber);
Assert.Equal(2, result[0].ErrorCount);
Assert.Equal("L-2", result[1].LicenseNumber);
Assert.Equal(1, result[1].ErrorCount);
}
// ----------------------------------- licences en erreur : licences manquantes ----
[Fact]
public async Task CountLicensesByErrors_ExcludesMissingLicenses()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(From.AddMinutes(1), licenseNumber: null, level: "Error"),
Ev(From.AddMinutes(2), licenseNumber: " ", level: "Error"),
Ev(From.AddMinutes(3), licenseNumber: "L-1", level: "Error")
});
IReadOnlyList<DiagnosticLicenseErrorCount> result = await repo.CountLicensesByErrorsAsync(All());
DiagnosticLicenseErrorCount row = Assert.Single(result);
Assert.Equal("L-1", row.LicenseNumber);
Assert.Equal(1, row.ErrorCount);
}
// ------------------------------------------- licences en erreur : limite ----
[Fact]
public async Task CountLicensesByErrors_RespectsLimit()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(From.AddMinutes(1), licenseNumber: "L-1", level: "Error"),
Ev(From.AddMinutes(2), licenseNumber: "L-2", level: "Error"),
Ev(From.AddMinutes(3), licenseNumber: "L-2", level: "Error"),
Ev(From.AddMinutes(4), licenseNumber: "L-3", level: "Error")
});
IReadOnlyList<DiagnosticLicenseErrorCount> result = await repo.CountLicensesByErrorsAsync(
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Limit = 2 });
Assert.Equal(2, result.Count);
Assert.Equal("L-2", result[0].LicenseNumber);
Assert.Equal("L-1", result[1].LicenseNumber); // ex-aequo 1 erreur : ordre alphabétique
}
}
@@ -0,0 +1,214 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 4 — Dashboard : compteurs agrégés (<c>CountAsync</c>,
/// <c>CountErrorGroupsAsync</c>) et prédicat <c>ErrorsOnly</c> (niveaux d'erreur réels).
/// Les tests s'appuient sur le <see cref="FakeDiagnosticEventRepository"/> (in-memory) ;
/// l'implémentation PostgreSQL exécute les mêmes contrats via <c>count(*)</c> et
/// <c>LOWER(level) IN ('error','critical')</c>.
/// </summary>
public class DiagnosticDashboardTests
{
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 = "TEST",
DateTimeOffset? ts = null,
string? component = null,
string? errorCode = null,
string? exceptionType = null,
string level = "Info",
string priority = "P2") =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
Component = component,
ErrorCode = errorCode,
ExceptionType = exceptionType,
Level = level,
Priority = priority,
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticAggregateQuery All() => new() { FromUtc = From, ToUtc = To };
// --------------------------------------------------- CountAsync : total ----
[Fact]
public async Task CountAsync_Total_ReturnsCorrectTotal()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1)),
Ev("B", From.AddMinutes(2)),
Ev("C", From.AddMinutes(3))
});
long count = await repo.CountAsync(All());
Assert.Equal(3, count);
}
// --------------------------------------------------- CountAsync : période ----
[Fact]
public async Task CountAsync_RespectsPeriod()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(-5)), // avant
Ev("B", From.AddMinutes(5)), // dans
Ev("C", To.AddMinutes(5)) // après (borne exclusive)
});
long count = await repo.CountAsync(All());
Assert.Equal(1, count);
}
// --------------------------------------------------- CountAsync : zéro ----
[Fact]
public async Task CountAsync_NoData_ReturnsZero()
{
var repo = new FakeDiagnosticEventRepository();
long count = await repo.CountAsync(All());
Assert.Equal(0, count);
}
// ---------------------------------------------- CountAsync : ErrorsOnly ----
[Fact]
public async Task CountAsync_ErrorsOnly_CountsErrorLevelsCaseInsensitively()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1), level: "Error"),
Ev("B", From.AddMinutes(2), level: "error"), // minuscule (ServerDiagnosticMiddleware)
Ev("C", From.AddMinutes(3), level: "Critical"),
Ev("D", From.AddMinutes(4), level: "Info"),
Ev("E", From.AddMinutes(5), level: "Warning")
});
long count = await repo.CountAsync(new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true });
Assert.Equal(3, count);
}
// --------------------------------------------- CountAsync : filtre priority ----
[Fact]
public async Task CountAsync_RespectsPriorityFilter()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1), priority: "P0"),
Ev("B", From.AddMinutes(2), priority: "P0"),
Ev("C", From.AddMinutes(3), priority: "P1")
});
long count = await repo.CountAsync(new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Priority = "P0" });
Assert.Equal(2, count);
}
// ------------------------------------------ CountErrorGroupsAsync : distinct ----
[Fact]
public async Task CountErrorGroups_DistinctGroups_AreCounted()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(1), exceptionType: "System.TimeoutException"),
Ev("A", From.AddMinutes(2), exceptionType: "System.TimeoutException"),
Ev("A", From.AddMinutes(3), exceptionType: "System.NullReferenceException")
});
long count = await repo.CountErrorGroupsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, count); // 2 exception_type distincts pour le même event_code
}
[Fact]
public async Task CountErrorGroups_EquivalentErrors_SingleGroup()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.TimeoutException"),
Ev("ERR", From.AddMinutes(2), exceptionType: "system.timeoutexception") // casse ≠ → même groupe
});
long count = await repo.CountErrorGroupsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To });
Assert.Equal(1, count);
}
[Fact]
public async Task CountErrorGroups_ErrorsOnly_ExcludesNonErrors()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.Exception", level: "Error"),
Ev("INFO", From.AddMinutes(2), exceptionType: null, level: "Info") // non-erreur → exclu
});
long count = await repo.CountErrorGroupsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true });
Assert.Equal(1, count);
}
// ------------------------------------- série temporelle : ErrorsOnly ----
[Fact]
public async Task CountByTimeBucket_ErrorsOnly_OnlyErrorLevels()
{
var repo = new FakeDiagnosticEventRepository();
var t = new DateTimeOffset(2025, 1, 1, 10, 0, 0, TimeSpan.Zero);
await repo.InsertBatchAsync(new[]
{
Ev("A", t, level: "Error"),
Ev("B", t.AddMinutes(5), level: "Info"),
Ev("C", t.AddMinutes(10), level: "critical")
});
IReadOnlyList<DiagnosticBucketCount> result = await repo.CountByTimeBucketAsync(
DiagnosticTimeBucket.Hour,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true });
DiagnosticBucketCount bucket = Assert.Single(result);
Assert.Equal(2, bucket.Count); // Error + critical ; Info exclu
}
// ------------------------------------- groupé : ErrorsOnly ----
[Fact]
public async Task CountGrouped_ErrorsOnly_OnlyErrorLevels()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), level: "Error"),
Ev("ERR", From.AddMinutes(2), level: "Info")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.EventCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ErrorsOnly = true });
DiagnosticGroupCount group = Assert.Single(result);
Assert.Equal("ERR", group.Key);
Assert.Equal(1, group.Count);
}
}
@@ -0,0 +1,46 @@
using Microsoft.Extensions.Options;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>§53 — Déduplication : deux événements avec le même EventId → 1 événement PostgreSQL, sans exception fatale.</summary>
public class DiagnosticDeduplicationTests
{
[Fact]
public async Task Importer_DuplicateEventIds_ProducesSingleInserted()
{
Guid id = Guid.NewGuid();
var source = new FakeDiagnosticJsonlSource(new[]
{
DiagnosticTestData.Record(id, "A"),
DiagnosticTestData.Record(id, "A") // doublon (at-least-once)
});
var repository = new FakeDiagnosticEventRepository();
var importer = new DiagnosticImporter(source, repository, Options.Create(new DiagnosticImportOptions { BatchSize = 100 }));
DiagnosticImportResult result = await importer.ImportOnceAsync();
Assert.True(result.Succeeded);
Assert.Equal(2, result.ReadCount);
Assert.Equal(1, result.ImportedCount); // 1 seul événement persisté
Assert.Equal(1, repository.InsertedCount);
}
[Fact]
public async Task Repository_DuplicateEventIds_IsIdempotent()
{
Guid id = Guid.NewGuid();
var repository = new FakeDiagnosticEventRepository();
DiagnosticEventRecord record = DiagnosticTestData.Event(id);
int first = await repository.InsertBatchAsync(new[] { record });
int second = await repository.InsertBatchAsync(new[] { DiagnosticTestData.Event(id) });
Assert.Equal(1, first);
Assert.Equal(0, second); // ON CONFLICT DO NOTHING → 0 nouvelle ligne
Assert.Equal(1, repository.InsertedCount);
}
}
@@ -0,0 +1,302 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Omika.Core.Observability.Deployment;
using Omika.Core.Observability.Outbox;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 13-D — Résolution centralisée ClientCode / ServerInstance / Environment :
/// configuration prioritaire, fallback ParametreSysteme (fake), normalisation
/// (trim, longueurs), fail-open, cache/concurrence, absence de secret.
/// Aucune base HFSQL/PostgreSQL réelle : fake de <see cref="IDiagnosticClientCodeSource"/>
/// et <see cref="IHostEnvironment"/> en mémoire.
/// </summary>
public class DiagnosticDeploymentContextProviderTests
{
// --------------------------- D1 : config ClientCode prioritaire ---------------
[Fact]
public async Task D1_ClientCodeFromConfig_IsUsed()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ClientCode = "CLIENT-A" });
Assert.Equal("CLIENT-A", context.ClientCode);
}
// --------------------------- D2 : trim ---------------------------------------
[Fact]
public async Task D2_ClientCodeWithSpaces_IsTrimmed()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ClientCode = " CLIENT-A " });
Assert.Equal("CLIENT-A", context.ClientCode);
}
// --------------------------- D3 : config vide → fallback ---------------------
[Fact]
public async Task D3_EmptyConfig_FallsBackToParametreSysteme()
{
var source = new FakeClientCodeSource("CLIENT-HFSQL");
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ClientCode = " " },
source: source);
Assert.Equal("CLIENT-HFSQL", context.ClientCode);
Assert.Equal(1, source.CallCount);
}
// --------------------------- D4 : ParametreSysteme présent -------------------
[Fact]
public async Task D4_ParametreSystemeClientCode_IsUsed()
{
var source = new FakeClientCodeSource("CLIENT-HFSQL");
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions(),
source: source);
Assert.Equal("CLIENT-HFSQL", context.ClientCode);
}
// --------------------------- D5 : ParametreSysteme absent → null -------------
[Fact]
public async Task D5_ParametreSystemeAbsent_ClientCodeIsNull()
{
var source = new FakeClientCodeSource((string?)null);
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions(),
source: source);
Assert.Null(context.ClientCode);
}
// --------------------------- D6 : HFSQL lève → null, pas d'exception ---------
[Fact]
public async Task D6_HfsqlThrows_ClientCodeNull_NoException()
{
var source = new FakeClientCodeSource(new InvalidOperationException("HFSQL down"));
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions(),
source: source);
Assert.Null(context.ClientCode);
// Fail-open : le provider continue et fournit quand même ServerInstance/Environment.
Assert.NotNull(context.ServerInstance);
Assert.Equal("Production", context.Environment);
}
// --------------------------- D7 : ServerInstance config ----------------------
[Fact]
public async Task D7_ServerInstanceFromConfig_IsUsed()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ServerInstanceId = "API-SRV-01" });
Assert.Equal("API-SRV-01", context.ServerInstance);
}
// --------------------------- D8 : ServerInstance vide → MachineName ----------
[Fact]
public async Task D8_EmptyServerInstance_FallsBackToMachineName()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ServerInstanceId = "" });
Assert.Equal(Environment.MachineName, context.ServerInstance);
}
// --------------------------- D9 : Environment Production ---------------------
[Fact]
public async Task D9_EnvironmentProduction_IsPreserved()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions(),
environmentName: "Production");
Assert.Equal("Production", context.Environment);
}
// --------------------------- D10 : contexte complet --------------------------
[Fact]
public async Task D10_FinalContext_ContainsAllThreeValues()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ClientCode = "CLIENT-A", ServerInstanceId = "SRV-01" },
environmentName: "Staging");
Assert.Equal("CLIENT-A", context.ClientCode);
Assert.Equal("SRV-01", context.ServerInstance);
Assert.Equal("Staging", context.Environment);
}
// --------------------------- D11 : config présente → HFSQL non appelé --------
[Fact]
public async Task D11_ClientCodeConfigPresent_HfsqlNotCalled()
{
var source = new FakeClientCodeSource("CLIENT-HFSQL");
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ClientCode = "CLIENT-CONFIG" },
source: source);
Assert.Equal("CLIENT-CONFIG", context.ClientCode);
Assert.Equal(0, source.CallCount);
}
// --------------------------- D12 : concurrence → une seule résolution --------
[Fact]
public async Task D12_ConcurrentGetAsync_ResolvesClientCodeOnce()
{
// Délai pour élargir la fenêtre de concurrence réelle.
var source = new FakeClientCodeSource("CLIENT-A", delay: TimeSpan.FromMilliseconds(50));
using ServiceProvider services = BuildServices(new DiagnosticDeploymentOptions(), "Production", source);
IDiagnosticDeploymentContextProvider provider =
services.GetRequiredService<IDiagnosticDeploymentContextProvider>();
Task<DiagnosticDeploymentContext>[] tasks = Enumerable.Range(0, 50)
.Select(_ => provider.GetAsync())
.ToArray();
DiagnosticDeploymentContext[] results = await Task.WhenAll(tasks);
Assert.All(results, r => Assert.Equal("CLIENT-A", r.ClientCode));
Assert.Equal(1, source.CallCount);
}
// --------------------------- D13 : ClientCode > 100 --------------------------
[Fact]
public async Task D13_ClientCodeTooLong_IsIgnored()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ClientCode = new string('X', 101) });
// Pas de source HFSQL → la valeur invalide est ignorée (warning) → null.
Assert.Null(context.ClientCode);
}
// --------------------------- D14 : ServerInstance > 100 ----------------------
[Fact]
public async Task D14_ServerInstanceTooLong_FallsBackToMachineName()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ServerInstanceId = new string('X', 101) });
Assert.Equal(Environment.MachineName, context.ServerInstance);
}
// --------------------------- D15 : Environment > 20 --------------------------
[Fact]
public async Task D15_EnvironmentTooLong_FallsBackToProduction()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions(),
environmentName: new string('E', 21));
Assert.Equal("Production", context.Environment);
}
// --------------------------- D16 : aucun secret ------------------------------
[Fact]
public async Task D16_ContextAndOptions_NeverContainSecrets()
{
DiagnosticDeploymentContext context = await ResolveAsync(
new DiagnosticDeploymentOptions { ClientCode = "CLIENT-A", ServerInstanceId = "SRV-01" },
environmentName: "Production");
string combined = $"{context.ClientCode}|{context.ServerInstance}|{context.Environment}";
Assert.DoesNotContain("CL_CleAPIInstall", combined, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("password", combined, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("SigningKey", combined, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("token", combined, StringComparison.OrdinalIgnoreCase);
// Les options n'exposent que les deux clés de déploiement attendues (aucun
// champ secret, aucun Environment : celui-ci vient d'IHostEnvironment).
string[] properties = typeof(DiagnosticDeploymentOptions)
.GetProperties()
.Select(p => p.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
Assert.Equal(["ClientCode", "ServerInstanceId"], properties);
}
// --------------------------- Helpers -----------------------------------------
private static async Task<DiagnosticDeploymentContext> ResolveAsync(
DiagnosticDeploymentOptions options,
string environmentName = "Production",
FakeClientCodeSource? source = null)
{
using ServiceProvider services = BuildServices(options, environmentName, source);
IDiagnosticDeploymentContextProvider provider =
services.GetRequiredService<IDiagnosticDeploymentContextProvider>();
return await provider.GetAsync();
}
private static ServiceProvider BuildServices(
DiagnosticDeploymentOptions options,
string environmentName,
FakeClientCodeSource? source)
{
var services = new ServiceCollection();
services.AddSingleton(Options.Create(options));
services.AddSingleton<IHostEnvironment>(new FakeHostEnvironment { EnvironmentName = environmentName });
services.AddSingleton<ILogger<DiagnosticDeploymentContextProvider>>(
NullLogger<DiagnosticDeploymentContextProvider>.Instance);
if (source is not null)
{
services.AddScoped<IDiagnosticClientCodeSource>(_ => source);
}
services.AddSingleton<IDiagnosticDeploymentContextProvider, DiagnosticDeploymentContextProvider>();
return services.BuildServiceProvider();
}
private sealed class FakeClientCodeSource : IDiagnosticClientCodeSource
{
private readonly Func<CancellationToken, Task<string?>> _resolve;
private int _callCount;
public int CallCount => Volatile.Read(ref _callCount);
public FakeClientCodeSource(string? value, TimeSpan? delay = null)
{
_resolve = async ct =>
{
Interlocked.Increment(ref _callCount);
if (delay is { } d)
{
await Task.Delay(d, ct).ConfigureAwait(false);
}
return value;
};
}
public FakeClientCodeSource(Exception exception)
{
_resolve = _ =>
{
Interlocked.Increment(ref _callCount);
return Task.FromException<string?>(exception);
};
}
public Task<string?> GetClientCodeAsync(CancellationToken cancellationToken = default)
=> _resolve(cancellationToken);
}
private sealed class FakeHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = "Production";
public string ApplicationName { get; set; } = "test";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}
@@ -0,0 +1,242 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 13-F — Filtres de contexte de déploiement (ClientCode / ServerInstance /
/// Environment) : filtres backend (F1-F5), regroupement d'erreurs pré-agrégation
/// (F6), et valeurs distinctes bornées/ordonnées/sans doublon (F7-F10). Le tout sur
/// le <see cref="FakeDiagnosticEventRepository"/> (in-memory), miroir du repository
/// PostgreSQL réel (mêmes contrats, §51).
/// </summary>
public class DiagnosticDeploymentFilterTests
{
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static DiagnosticEventRecord Ev(
string eventCode = "TEST",
string? clientCode = null,
string? serverInstance = null,
string? environment = null,
DateTimeOffset? ts = null,
string level = "Info",
string? exceptionType = null,
string? component = null,
string? errorCode = null)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(Guid.NewGuid(), eventCode, ts ?? From, component: component, level: level, errorCode: errorCode);
record.ClientCode = clientCode;
record.ServerInstance = serverInstance;
record.Environment = environment;
record.ExceptionType = exceptionType;
return record;
}
// --------------------------- F1 : filtre ClientCode --------------------------
[Fact]
public async Task F1_ClientCodeFilter_ReturnsOnlyMatchingClient()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(clientCode: "CLIENT-A"),
Ev(clientCode: "CLIENT-B")
});
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = "CLIENT-A" });
DiagnosticEventRecord record = Assert.Single(result.Items);
Assert.Equal("CLIENT-A", record.ClientCode);
}
// --------------------------- F2 : filtre ServerInstance ----------------------
[Fact]
public async Task F2_ServerInstanceFilter_ReturnsOnlyMatchingInstance()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(serverInstance: "WKR-SRV-01"),
Ev(serverInstance: "API-SRV-01")
});
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { ServerInstance = "WKR-SRV-01" });
DiagnosticEventRecord record = Assert.Single(result.Items);
Assert.Equal("WKR-SRV-01", record.ServerInstance);
}
// --------------------------- F3 : filtre Environment -------------------------
[Fact]
public async Task F3_EnvironmentFilter_ExcludesOtherEnvironments()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(environment: "Production"),
Ev(environment: "Staging"),
Ev(environment: "Development")
});
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Production" });
DiagnosticEventRecord record = Assert.Single(result.Items);
Assert.Equal("Production", record.Environment);
}
// --------------------------- F4 : combinaison des trois ----------------------
[Fact]
public async Task F4_CombinedFilters_ApplyAllThreeSimultaneously()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(clientCode: "CLIENT-A", serverInstance: "API-SRV-01", environment: "Production"),
Ev(clientCode: "CLIENT-A", serverInstance: "API-SRV-01", environment: "Staging"),
Ev(clientCode: "CLIENT-A", serverInstance: "WKR-SRV-01", environment: "Production"),
Ev(clientCode: "CLIENT-B", serverInstance: "API-SRV-01", environment: "Production")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.QueryAsync(new DiagnosticEventQuery
{
ClientCode = "CLIENT-A",
ServerInstance = "API-SRV-01",
Environment = "Production"
});
DiagnosticEventRecord record = Assert.Single(result.Items);
Assert.Equal("CLIENT-A", record.ClientCode);
Assert.Equal("API-SRV-01", record.ServerInstance);
Assert.Equal("Production", record.Environment);
}
// --------------------------- F5 : NULL historiques ---------------------------
[Fact]
public async Task F5_HistoricalNullContext_IsReturnedWithoutFilter()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord historical = Ev(clientCode: null, serverInstance: null, environment: null);
await repo.InsertBatchAsync(new[]
{
historical,
Ev(clientCode: "CLIENT-A", serverInstance: "SRV-1", environment: "Production")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.QueryAsync(new DiagnosticEventQuery());
Assert.Equal(2, result.TotalCount);
Assert.Contains(result.Items, e => e.ClientCode is null && e.ServerInstance is null && e.Environment is null);
}
// --------------------------- F6 : ErrorGroup pré-agrégation ------------------
[Fact]
public async Task F6_ErrorGroupFilter_IsAppliedBeforeAggregation()
{
var repo = new FakeDiagnosticEventRepository();
// Même signature d'erreur logique (event_code + exception + component) répartie
// sur deux clients / environnements.
await repo.InsertBatchAsync(new[]
{
Ev("ERR", "CLIENT-A", "SRV-1", "Production", exceptionType: "System.E", component: "Omika.Api", level: "Error"),
Ev("ERR", "CLIENT-A", "SRV-1", "Production", exceptionType: "System.E", component: "Omika.Api", level: "Error"),
Ev("ERR", "CLIENT-B", "SRV-1", "Staging", exceptionType: "System.E", component: "Omika.Api", level: "Error"),
Ev("ERR", "CLIENT-A", "SRV-1", "Staging", exceptionType: "System.E", component: "Omika.Api", level: "Error")
});
IReadOnlyList<DiagnosticErrorGroup> all = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery { FromUtc = From, ToUtc = From.AddDays(1) });
DiagnosticErrorGroup allGroup = Assert.Single(all);
Assert.Equal(4, allGroup.OccurrenceCount);
IReadOnlyList<DiagnosticErrorGroup> filtered = await repo.GroupErrorsAsync(
new DiagnosticErrorGroupQuery
{
FromUtc = From,
ToUtc = From.AddDays(1),
ClientCode = "CLIENT-A",
Environment = "Production"
});
DiagnosticErrorGroup filteredGroup = Assert.Single(filtered);
Assert.Equal(2, filteredGroup.OccurrenceCount);
}
// --------------------------- F7 : distinct ClientCode ------------------------
[Fact]
public async Task F7_DistinctClientCodes_AreDistinctSorted()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(clientCode: "CLIENT-B"),
Ev(clientCode: "CLIENT-A"),
Ev(clientCode: "CLIENT-A"),
Ev(clientCode: null) // exclu
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ClientCode, From, DiagnosticFilterDefaults.ClientCodeOptionsLimit);
Assert.Equal(new[] { "CLIENT-A", "CLIENT-B" }, result);
}
// --------------------------- F8 : distinct ServerInstance --------------------
[Fact]
public async Task F8_DistinctServerInstances_AreDistinctSorted()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(serverInstance: "WKR-SRV-01"),
Ev(serverInstance: "API-SRV-01"),
Ev(serverInstance: "WKR-SRV-01")
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ServerInstance, From, DiagnosticFilterDefaults.ServerInstanceOptionsLimit);
Assert.Equal(new[] { "API-SRV-01", "WKR-SRV-01" }, result);
}
// --------------------------- F9 : distinct Environment -----------------------
[Fact]
public async Task F9_DistinctEnvironments_AreDistinctSorted()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(environment: "Production"),
Ev(environment: "Development"),
Ev(environment: "Production"),
Ev(environment: "Staging")
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.Environment, From, DiagnosticFilterDefaults.EnvironmentOptionsLimit);
Assert.Equal(new[] { "Development", "Production", "Staging" }, result);
}
// --------------------------- F10 : limite des options ------------------------
[Fact]
public async Task F10_DistinctOptions_RespectLimit()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(clientCode: "CLIENT-A"),
Ev(clientCode: "CLIENT-B"),
Ev(clientCode: "CLIENT-C"),
Ev(clientCode: "CLIENT-D")
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ClientCode, From, 2);
Assert.Equal(2, result.Count);
}
}
@@ -0,0 +1,327 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 3 — Page de détail + événements corrélés. Les tests s'appuient sur le
/// <see cref="FakeDiagnosticEventRepository"/> (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).
/// </summary>
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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord>();
for (int i = 0; i < 10; i++)
{
events.Add(Ev(i + 1, Base.AddMinutes(i), traceId: traceId));
}
await repo.InsertBatchAsync(events);
IReadOnlyList<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord>();
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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<DiagnosticEventRecord> 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<ArgumentException>(() => repo.GetCorrelatedAsync(query));
}
}
@@ -0,0 +1,279 @@
using Microsoft.AspNetCore.Mvc;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T17-D — Contexte Client / Licence / Appareil / Application : formatage d'affichage
/// (NULL historique → « — »), détail diagnostic complet, filtres Licence/Appareil,
/// pagination, et non-régression sécurité (aucun identifiant brut exposé).
/// </summary>
public class DiagnosticDeviceContextUiTests
{
// ------------------------------------------------ formatage -------------------------
[Fact]
public void Value_NullOrEmpty_RendersEmDash()
{
Assert.Equal("—", UiDeviceContext.Value(null));
Assert.Equal("—", UiDeviceContext.Value(""));
Assert.Equal("—", UiDeviceContext.Value(" "));
Assert.Equal("Android", UiDeviceContext.Value(" Android "));
}
[Theory]
[InlineData(1080, 2400, "1080 × 2400 px")]
[InlineData(null, 2400, "—")]
[InlineData(1080, null, "—")]
[InlineData(0, 2400, "—")]
[InlineData(1080, 0, "—")]
public void Resolution_RequiresBothDimensions(int? w, int? h, string expected)
=> Assert.Equal(expected, UiDeviceContext.Resolution(w, h, "px"));
[Fact]
public void Scale_FormatsWithoutFloatingNoise()
{
Assert.Equal("2.75×", UiDeviceContext.Scale(2.75));
Assert.Equal("3×", UiDeviceContext.Scale(3.0));
Assert.Equal("—", UiDeviceContext.Scale(null));
Assert.Equal("—", UiDeviceContext.Scale(0));
}
[Fact]
public void DeviceIdentity_CombinesManufacturerAndModel()
{
Assert.Equal("Xiaomi · 23113RKC6G", UiDeviceContext.DeviceIdentity("Xiaomi", "23113RKC6G"));
Assert.Equal("Apple", UiDeviceContext.DeviceIdentity("Apple", null));
Assert.Equal("—", UiDeviceContext.DeviceIdentity(null, null));
}
[Fact]
public void PlatformOs_CombinesOsAndVersion()
{
Assert.Equal("Android 16", UiDeviceContext.PlatformOs("Android", "16"));
Assert.Equal("iOS 18.6", UiDeviceContext.PlatformOs("iOS", "18.6"));
Assert.Equal("—", UiDeviceContext.PlatformOs(null, "16"));
}
[Fact]
public void VersionBuild_ShowsBuildWhenPresent()
{
Assert.Equal("1.4.1 (10401)", UiDeviceContext.VersionBuild("1.4.1", "10401"));
Assert.Equal("1.4.1", UiDeviceContext.VersionBuild("1.4.1", null));
Assert.Equal("—", UiDeviceContext.VersionBuild(null, "10401"));
}
[Fact]
public void Short_TruncatesAndKeepsFullViaTitle()
{
Assert.Equal("—", UiDeviceContext.Short(null));
Assert.Equal("f71919f4f9974d70…", UiDeviceContext.Short("f71919f4f9974d70967014fbbefabf9d", 16));
Assert.Equal("abc", UiDeviceContext.Short("abc", 16)); // court → intact
}
// ------------------------------------------------ détail complet (§41) -------------
[Fact]
public async Task Detail_LoadsFullDeviceAndLicenseContext()
{
var repo = new FakeDiagnosticEventRepository();
Guid traceId = Guid.NewGuid();
Guid operationId = Guid.NewGuid();
Guid sessionId = Guid.NewGuid();
DiagnosticEventRecord record = DeviceRecord(42, traceId: traceId, operationId: operationId, sessionId: sessionId);
await repo.InsertBatchAsync(new[] { record });
var model = new DiagnosticsDetailModel(repo);
IActionResult result = await model.OnGetAsync(42);
Assert.IsType<Microsoft.AspNetCore.Mvc.RazorPages.PageResult>(result);
Assert.NotNull(model.Event);
Assert.Equal("CLIENT-042", model.Event!.ClientCode);
Assert.Equal("LIC-000842", model.Event.LicenseNumber);
Assert.Equal("hash-test", model.Event.DeviceKey);
Assert.Equal(1, model.Event.DeviceKeyVersion);
Assert.Equal("Android", model.Event.Platform);
Assert.Equal("Android", model.Event.OperatingSystem);
Assert.Equal("16", model.Event.OperatingSystemVersion);
Assert.Equal("Xiaomi", model.Event.DeviceManufacturer);
Assert.Equal("23113RKC6G", model.Event.DeviceModel);
Assert.Equal("1.4.1", model.Event.AppVersion);
Assert.Equal("10401", model.Event.AppBuild);
Assert.Equal(1080, model.Event.ScreenWidth);
Assert.Equal(2400, model.Event.ScreenHeight);
Assert.Equal(393, model.Event.LogicalWidth);
Assert.Equal(873, model.Event.LogicalHeight);
Assert.Equal(2.75, model.Event.DisplayScale);
Assert.Equal(traceId, model.Event.TraceId);
Assert.Equal(operationId, model.Event.OperationId);
Assert.Equal(sessionId, model.Event.SessionId);
}
// ------------------------------------------------ anciennes données NULL (§42) ------
[Fact]
public void OldData_WithAllNulls_FormatsWithoutBreaking()
{
DiagnosticEventRecord legacy = DiagnosticTestData.Event(Guid.NewGuid());
legacy.ClientCode = null;
legacy.LicenseNumber = null;
legacy.DeviceKey = null;
legacy.DeviceManufacturer = null;
legacy.DeviceModel = null;
legacy.OperatingSystem = null;
legacy.ScreenWidth = null;
legacy.ScreenHeight = null;
legacy.LogicalWidth = null;
legacy.LogicalHeight = null;
legacy.DisplayScale = null;
Assert.Equal("Non renseigné", UiDeploymentContext.Value(legacy.ClientCode));
Assert.Equal("—", UiDeviceContext.Value(legacy.LicenseNumber));
Assert.Equal("—", UiDeviceContext.Resolution(legacy.ScreenWidth, legacy.ScreenHeight, "px"));
Assert.Equal("—", UiDeviceContext.Resolution(legacy.LogicalWidth, legacy.LogicalHeight));
Assert.Equal("—", UiDeviceContext.Scale(legacy.DisplayScale));
Assert.Null(UiDeviceContext.CompactDeviceOrNull(legacy));
}
// ------------------------------------------------ filtre licence (§43) -------------
[Fact]
public async Task LicenseFilter_ReturnsOnlyMatchingEvents()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
LicenseRecord(1, "LIC-A"),
LicenseRecord(2, "LIC-A"),
LicenseRecord(3, "LIC-B")
});
var model = new DiagnosticsModel(repo) { LicenseNumber = "LIC-A" };
await model.OnGetAsync();
Assert.Equal(2, model.Items.Count);
Assert.All(model.Items, e => Assert.Equal("LIC-A", e.LicenseNumber));
}
[Fact]
public void Route_PreservesLicenseAndDeviceFilters()
{
var model = new DiagnosticsModel(new FakeDiagnosticEventRepository())
{
LicenseNumber = "LIC-A",
DeviceKey = "hash-test"
};
Dictionary<string, string> route = model.Route(2);
Assert.Equal("2", route["pageNumber"]);
Assert.Equal("LIC-A", route["licenseNumber"]);
Assert.Equal("hash-test", route["deviceKey"]);
}
// ------------------------------------------------ filtre appareil (§44) ------------
[Fact]
public async Task DeviceKeyFilter_ReturnsOnlyMatchingDevice()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DeviceKeyRecord(1, "hash-test"),
DeviceKeyRecord(2, "hash-test"),
DeviceKeyRecord(3, "hash-other")
});
var model = new DiagnosticsModel(repo) { DeviceKey = "hash-test" };
await model.OnGetAsync();
Assert.Equal(2, model.Items.Count);
Assert.All(model.Items, e => Assert.Equal("hash-test", e.DeviceKey));
Assert.DoesNotContain(model.Items, e => e.DeviceKey == "hash-other");
}
// ------------------------------------------------ filtres analytiques T17-E -------
[Fact]
public async Task ManufacturerAndOsFilters_ReturnOnlyMatchingEvents()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord samsung = DiagnosticTestData.Event(Guid.NewGuid());
samsung.Id = 1;
samsung.DeviceManufacturer = "Samsung";
samsung.OperatingSystem = "Android";
DiagnosticEventRecord apple = DiagnosticTestData.Event(Guid.NewGuid());
apple.Id = 2;
apple.DeviceManufacturer = "Apple";
apple.OperatingSystem = "iOS";
await repo.InsertBatchAsync(new[] { samsung, apple });
var model = new DiagnosticsModel(repo) { Manufacturer = "Samsung", OperatingSystem = "Android" };
await model.OnGetAsync();
DiagnosticEventRecord record = Assert.Single(model.Items);
Assert.Equal("Samsung", record.DeviceManufacturer);
}
// ------------------------------------------------ sécurité UI (§45) ----------------
[Fact]
public void DiagnosticEventRecord_ExposesNoRawIdentityOrSecrets()
{
string[] names = typeof(DiagnosticEventRecord).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain("DeviceId", names);
Assert.DoesNotContain("UserId", names);
Assert.DoesNotContain("CollaborateurId", names);
Assert.DoesNotContain("Token", names);
Assert.DoesNotContain("HmacKey", names);
Assert.DoesNotContain("LI_PKID", names);
Assert.DoesNotContain("LI_CleAPI", names);
Assert.DoesNotContain("CL_CleAPIInstall", names);
// DeviceKey (pseudonymisé) est autorisé.
Assert.Contains("DeviceKey", names);
}
// ------------------------------------------------ fabriques ------------------------
private static DiagnosticEventRecord DeviceRecord(
long id,
Guid? traceId = null,
Guid? operationId = null,
Guid? sessionId = null)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(Guid.NewGuid());
record.Id = id;
record.ClientCode = "CLIENT-042";
record.LicenseNumber = "LIC-000842";
record.DeviceKey = "hash-test";
record.DeviceKeyVersion = 1;
record.Platform = "Android";
record.OperatingSystem = "Android";
record.OperatingSystemVersion = "16";
record.DeviceManufacturer = "Xiaomi";
record.DeviceModel = "23113RKC6G";
record.AppVersion = "1.4.1";
record.AppBuild = "10401";
record.ScreenWidth = 1080;
record.ScreenHeight = 2400;
record.LogicalWidth = 393;
record.LogicalHeight = 873;
record.DisplayScale = 2.75;
record.TraceId = traceId;
record.OperationId = operationId;
record.SessionId = sessionId;
return record;
}
private static DiagnosticEventRecord LicenseRecord(long id, string licenseNumber)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(Guid.NewGuid());
record.Id = id;
record.LicenseNumber = licenseNumber;
return record;
}
private static DiagnosticEventRecord DeviceKeyRecord(long id, string deviceKey)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(Guid.NewGuid());
record.Id = id;
record.DeviceKey = deviceKey;
return record;
}
}
@@ -0,0 +1,352 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T17-E — Statistiques de parc : identité appareil = DeviceKey (jamais LicenseNumber,
/// réattribuable), dernière observation par DeviceKey, NULL historique, réattribution de
/// licence, normalisation analytique et pourcentages. Tests sur
/// <see cref="FakeDiagnosticEventRepository"/> ; la production exécute les mêmes contrats
/// via le CTE PostgreSQL <c>latest_device</c> (DISTINCT ON / COUNT(DISTINCT device_key)).
/// </summary>
public class DiagnosticDeviceFleetTests
{
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 DeviceFleetQuery All(int? limit = null) => new()
{
FromUtc = From,
ToUtc = To,
Limit = limit ?? DiagnosticAggregateDefaults.MaxLimit
};
private static DiagnosticEventRecord Ev(
string deviceKey,
DateTimeOffset ts,
string? license = null,
string? platform = "Android",
string? os = "Android",
string? osVersion = "15",
string? manufacturer = null,
string? model = null,
string? appVersion = null,
int? screenW = null,
int? screenH = null)
{
DiagnosticEventRecord e = DiagnosticTestData.Event(Guid.NewGuid(), timestamp: ts);
e.DeviceKey = deviceKey;
e.LicenseNumber = license;
e.Platform = platform;
e.OperatingSystem = os;
e.OperatingSystemVersion = osVersion;
e.DeviceManufacturer = manufacturer;
e.DeviceModel = model;
e.AppVersion = appVersion;
e.ScreenWidth = screenW;
e.ScreenHeight = screenH;
return e;
}
// ------------------------------- §61 : 1000 événements = 1 appareil ----
[Fact]
public async Task OneDeviceWithManyEvents_CountsAsOneDevice()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord[] events = Enumerable.Range(0, 1000)
.Select(i => Ev("dev-a", From.AddSeconds(i)))
.ToArray();
await repo.InsertBatchAsync(events);
Assert.Equal(1, await repo.CountActiveDevicesAsync(All()));
}
// ------------------------------- §5/§61 : deux appareils distincts ----
[Fact]
public async Task TwoDevices_CountAsTwo_RegardlessOfEventVolume()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddMinutes(1)),
Ev("dev-a", From.AddMinutes(2)),
Ev("dev-b", From.AddMinutes(3))
});
Assert.Equal(2, await repo.CountActiveDevicesAsync(All()));
}
// ------------------------------- §62 : DeviceKey NULL → aucun appareil ----
[Fact]
public async Task NullDeviceKey_ProducesNoFakeDevice()
{
var repo = new FakeDiagnosticEventRepository();
var events = new List<DiagnosticEventRecord>();
for (int i = 0; i < 5; i++)
{
DiagnosticEventRecord e = DiagnosticTestData.Event(Guid.NewGuid(), timestamp: From.AddMinutes(i));
e.DeviceKey = null;
events.Add(e);
}
await repo.InsertBatchAsync(events);
Assert.Equal(0, await repo.CountActiveDevicesAsync(All()));
DeviceFleetQueryResult devices = await repo.GetDevicesAsync(All());
Assert.Equal(0, devices.TotalCount);
}
// ------------------------------- §63 : dernière observation OS ----
[Fact]
public async Task OsVersionDistribution_UsesLastObservation()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddDays(1), osVersion: "15", manufacturer: "Samsung"),
Ev("dev-a", From.AddDays(20), osVersion: "16", manufacturer: "Samsung")
});
IReadOnlyList<DeviceDistribution> result = await repo.CountDevicesGroupedAsync(
DeviceFleetDimension.OperatingSystemVersion, All());
DeviceDistribution group = Assert.Single(result);
Assert.Equal("Android 16", group.Key);
Assert.Equal(1, group.DeviceCount);
}
// ------------------------------- §64 : dernière observation AppVersion ----
[Fact]
public async Task AppVersionDistribution_UsesLastObservation()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddDays(1), appVersion: "1.4.0"),
Ev("dev-a", From.AddDays(20), appVersion: "1.4.1")
});
IReadOnlyList<DeviceDistribution> result = await repo.CountDevicesGroupedAsync(
DeviceFleetDimension.AppVersion, All());
DeviceDistribution group = Assert.Single(result);
Assert.Equal("1.4.1", group.Key);
Assert.Equal(1, group.DeviceCount);
}
// ------------------------------- §47 : filtre dernière observation OS ----
[Fact]
public async Task LastObservationFilter_Android15_ExcludesDeviceNowOnAndroid16()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddDays(1), osVersion: "15"),
Ev("dev-a", From.AddDays(20), osVersion: "16")
});
var filter15 = new DeviceFleetQuery { FromUtc = From, ToUtc = To, OperatingSystemVersion = "15", Limit = DiagnosticAggregateDefaults.MaxLimit };
Assert.Equal(0, await repo.CountActiveDevicesAsync(filter15));
var filter16 = new DeviceFleetQuery { FromUtc = From, ToUtc = To, OperatingSystemVersion = "16", Limit = DiagnosticAggregateDefaults.MaxLimit };
Assert.Equal(1, await repo.CountActiveDevicesAsync(filter16));
}
// ------------------------------- §65/§66 : réattribution = 2 appareils ----
[Fact]
public async Task LicenseReassignment_CountsTwoDistinctDevices()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddDays(1), license: "LIC-001", manufacturer: "Samsung", model: "SM-DEMO", os: "Android", osVersion: "15"),
Ev("dev-b", From.AddDays(20), license: "LIC-001", manufacturer: "Apple", model: "iPhoneDemo", os: "iOS", osVersion: "18")
});
Assert.Equal(2, await repo.CountActiveDevicesAsync(All()));
DeviceFleetQueryResult devices = await repo.GetDevicesAsync(All());
Assert.Equal(2, devices.TotalCount);
Assert.Contains(devices.Items, d => d.DeviceKey == "dev-a");
Assert.Contains(devices.Items, d => d.DeviceKey == "dev-b");
}
// ------------------------------- §66 : jamais de fusion par licence ----
[Fact]
public async Task SameLicense_DifferentDeviceKeys_RemainDifferentDevices()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddDays(1), license: "LIC-001"),
Ev("dev-a", From.AddDays(2), license: "LIC-001"),
Ev("dev-b", From.AddDays(3), license: "LIC-001")
});
// Sans filtre : 2 appareils. Avec filtre licence : TOUJOURS 2 appareils distincts,
// jamais fusionnés en 1 simplement parce que la licence est identique.
Assert.Equal(2, await repo.CountActiveDevicesAsync(All()));
Assert.Equal(2, await repo.CountActiveDevicesAsync(
new DeviceFleetQuery { FromUtc = From, ToUtc = To, LicenseNumber = "LIC-001", Limit = DiagnosticAggregateDefaults.MaxLimit }));
}
// ------------------------------- §67 : filtre licence = les deux appareils ----
[Fact]
public async Task LicenseFilter_ReturnsAllDevicesThatUsedLicense()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddDays(1), license: "LIC-001"),
Ev("dev-b", From.AddDays(20), license: "LIC-001"),
Ev("dev-c", From.AddDays(5), license: "LIC-002")
});
var query = new DeviceFleetQuery { FromUtc = From, ToUtc = To, LicenseNumber = "LIC-001", Limit = DiagnosticAggregateDefaults.MaxLimit };
DeviceFleetQueryResult devices = await repo.GetDevicesAsync(query);
Assert.Equal(2, devices.TotalCount);
Assert.All(devices.Items, d => Assert.Equal("LIC-001", d.LicenseNumber));
}
// ------------------------------- §16 : appareils distincts par licence ----
[Fact]
public async Task DevicesByLicense_CountsDistinctDevicesPerLicense()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("dev-a", From.AddDays(1), license: "LIC-001"),
Ev("dev-a", From.AddDays(2), license: "LIC-001"),
Ev("dev-b", From.AddDays(3), license: "LIC-001"),
Ev("dev-c", From.AddDays(4), license: "LIC-002")
});
IReadOnlyList<DeviceDistribution> result = await repo.CountDevicesByLicenseAsync(All());
DeviceDistribution lic001 = Assert.Single(result, r => r.Key == "LIC-001");
Assert.Equal(2, lic001.DeviceCount); // dev-a + dev-b, jamais 3 événements
DeviceDistribution lic002 = Assert.Single(result, r => r.Key == "LIC-002");
Assert.Equal(1, lic002.DeviceCount);
}
// ------------------------------- §69 : pourcentages ----
[Fact]
public void Percentage_ComputesShareWithoutDivisionByZero()
{
Assert.Equal(80.0, DeviceFleetStatistics.Percentage(8, 10));
Assert.Equal(20.0, DeviceFleetStatistics.Percentage(2, 10));
Assert.Null(DeviceFleetStatistics.Percentage(5, 0));
Assert.Null(DeviceFleetStatistics.Percentage(0, 0));
}
// ------------------------------- §70 : normalisation ----
[Fact]
public async Task Normalization_GroupsCaseVariants()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("d1", From.AddDays(1), platform: "Android", manufacturer: "Samsung"),
Ev("d2", From.AddDays(2), platform: "ANDROID", manufacturer: "SAMSUNG"),
Ev("d3", From.AddDays(3), platform: "iOS", manufacturer: "Apple")
});
IReadOnlyList<DeviceDistribution> platforms = await repo.CountDevicesGroupedAsync(
DeviceFleetDimension.Platform, All());
DeviceDistribution android = Assert.Single(platforms, p => p.Key == "Android");
Assert.Equal(2, android.DeviceCount);
IReadOnlyList<DeviceDistribution> manufacturers = await repo.CountDevicesGroupedAsync(
DeviceFleetDimension.Manufacturer, All());
DeviceDistribution samsung = Assert.Single(manufacturers, m => m.Key == "Samsung");
Assert.Equal(2, samsung.DeviceCount);
}
[Theory]
[InlineData("Android", "Android")]
[InlineData("ANDROID", "Android")]
[InlineData("android", "Android")]
[InlineData("iOS", "iOS")]
[InlineData("IOS", "iOS")]
[InlineData("macOS", "macOS")]
[InlineData("MACOS", "macOS")]
[InlineData(null, null)]
[InlineData(" ", null)]
public void PlatformHelper_NormalizesDeterministically(string? input, string? expected)
{
Assert.Equal(expected, DeviceFleetNormalization.Platform(input));
}
[Theory]
[InlineData("Samsung", "Samsung")]
[InlineData("SAMSUNG", "Samsung")]
[InlineData("samsung", "Samsung")]
[InlineData("Xiaomi", "Xiaomi")]
[InlineData(null, null)]
public void ManufacturerHelper_NormalizesDeterministically(string? input, string? expected)
{
Assert.Equal(expected, DeviceFleetNormalization.Manufacturer(input));
}
[Theory]
[InlineData("SM-S928B", "SM-S928B")]
[InlineData("iPhone17,2", "iPhone17,2")]
[InlineData("23113RKC6G", "23113RKC6G")]
[InlineData(" sm-s928b ", "sm-s928b")] // TRIM seulement, casse conservée (§44)
[InlineData(null, null)]
public void DeviceModelHelper_TrimsAndPreservesCase(string? input, string? expected)
{
Assert.Equal(expected, DeviceFleetNormalization.DeviceModel(input));
}
// ------------------------------- §71 : anciennes données NULL ----
[Fact]
public async Task OldData_NullDimensions_NoCrashNoFakeDevice()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord legacy = DiagnosticTestData.Event(Guid.NewGuid(), timestamp: From.AddDays(1));
legacy.DeviceKey = "legacy-device";
legacy.DeviceManufacturer = null;
legacy.OperatingSystem = null;
legacy.OperatingSystemVersion = null;
legacy.AppVersion = null;
legacy.ScreenWidth = null;
legacy.ScreenHeight = null;
await repo.InsertBatchAsync(new[] { legacy });
Assert.Equal(1, await repo.CountActiveDevicesAsync(All())); // appareil identifiable
IReadOnlyList<DeviceDistribution> manufacturers = await repo.CountDevicesGroupedAsync(
DeviceFleetDimension.Manufacturer, All());
Assert.Empty(manufacturers); // NULL exclu, pas de faux « Inconnu »
IReadOnlyList<DeviceDistribution> resolutions = await repo.CountDevicesGroupedAsync(
DeviceFleetDimension.Resolution, All());
Assert.Empty(resolutions);
}
// ------------------------------- §55/§56 : pagination + tri LastSeen DESC ----
[Fact]
public async Task GetDevices_PaginatesAndSortsByLastSeenDescending()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("d1", From.AddDays(1)),
Ev("d2", From.AddDays(3)),
Ev("d3", From.AddDays(2))
});
var query = new DeviceFleetQuery { FromUtc = From, ToUtc = To, Page = 1, PageSize = 2, Limit = DiagnosticAggregateDefaults.MaxLimit };
DeviceFleetQueryResult page = await repo.GetDevicesAsync(query);
Assert.Equal(3, page.TotalCount);
Assert.Equal(2, page.Items.Count);
Assert.Equal(2, page.TotalPages);
Assert.Equal("d2", page.Items[0].DeviceKey); // dernière activité d'abord
Assert.Equal("d3", page.Items[1].DeviceKey);
}
}
@@ -0,0 +1,147 @@
using Omika.Core.Observability.Identity;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tests unitaires de la DeviceKey (T17-B) : HMAC-SHA256 serveur, stable par
/// (tenant, device), non réversible, tenant-scoped, séparation de domaine avec
/// l'ActorKey, fail-open. Aucune clé de production utilisée ici (clés de test
/// déterministes uniquement).
/// </summary>
public class DiagnosticDeviceKeyTests
{
private static string KeyA() => Convert.ToBase64String(new byte[32]); // 32 octets 0x00
private static string KeyB() => Convert.ToBase64String(Enumerable.Repeat((byte)7, 32).ToArray());
[Fact]
public void SameTenant_SameDevice_ProducesSameKey()
{
var provider = new DiagnosticDeviceKeyProvider(KeyA());
DiagnosticDeviceKey a = provider.Compute("BATIK.01", "device-1");
DiagnosticDeviceKey b = provider.Compute("BATIK.01", "device-1");
Assert.True(a.HasValue);
Assert.Equal(a.Value, b.Value);
Assert.Equal(a.Version, b.Version);
}
[Fact]
public void SameTenant_DifferentDevice_ProducesDifferentKey()
{
var provider = new DiagnosticDeviceKeyProvider(KeyA());
DiagnosticDeviceKey a = provider.Compute("BATIK.01", "device-1");
DiagnosticDeviceKey b = provider.Compute("BATIK.01", "device-2");
Assert.NotEqual(a.Value, b.Value);
}
[Fact]
public void DifferentTenant_SameDevice_ProducesDifferentKey()
{
var provider = new DiagnosticDeviceKeyProvider(KeyA());
DiagnosticDeviceKey a = provider.Compute("BATIK.01", "device-1");
DiagnosticDeviceKey b = provider.Compute("BATIK.02", "device-1");
Assert.NotEqual(a.Value, b.Value);
}
[Fact]
public void DifferentKey_ProducesDifferentDeviceKey()
{
DiagnosticDeviceKey a = new DiagnosticDeviceKeyProvider(KeyA()).Compute("BATIK.01", "device-1");
DiagnosticDeviceKey b = new DiagnosticDeviceKeyProvider(KeyB()).Compute("BATIK.01", "device-1");
Assert.True(a.HasValue);
Assert.True(b.HasValue);
Assert.NotEqual(a.Value, b.Value);
}
[Fact]
public void DeviceKey_IsHexOnly_AndContainsNoIdentity()
{
DiagnosticDeviceKey key = new DiagnosticDeviceKeyProvider(KeyA()).Compute("BATIK.01", "device-12345");
Assert.NotNull(key.Value);
Assert.Equal(64, key.Value!.Length);
Assert.Matches("^[0-9a-f]{64}$", key.Value);
Assert.DoesNotContain("device", key.Value, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("12345", key.Value, StringComparison.Ordinal);
Assert.DoesNotContain("BATIK", key.Value, StringComparison.OrdinalIgnoreCase);
// La clé HMAC elle-même ne doit jamais fuiter dans le résultat.
Assert.DoesNotContain(KeyA(), key.Value, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DeviceKey_HasStableFormat_Versioned()
{
DiagnosticDeviceKey key = new DiagnosticDeviceKeyProvider(KeyA()).Compute("BATIK.01", "device-7");
Assert.Equal(1, key.Version);
Assert.NotNull(key.Value);
Assert.Equal(64, key.Value!.Length);
Assert.Equal(key.Value.ToLowerInvariant(), key.Value); // hex minuscule
}
[Fact]
public void MissingKey_IsFailOpen_ReturnsEmpty()
{
var cases = new[]
{
new DiagnosticDeviceKeyProvider(null),
new DiagnosticDeviceKeyProvider(string.Empty),
new DiagnosticDeviceKeyProvider(" "),
new DiagnosticDeviceKeyProvider("not-base64!!!"),
new DiagnosticDeviceKeyProvider(Convert.ToBase64String(new byte[16])) // 128 bits < 256
};
foreach (DiagnosticDeviceKeyProvider provider in cases)
{
DiagnosticDeviceKey key = provider.Compute("BATIK.01", "device-1");
Assert.False(key.HasValue);
Assert.Null(key.Value);
Assert.Null(key.Version);
}
}
[Fact]
public void MissingIdentity_IsFailOpen_ReturnsEmpty()
{
var provider = new DiagnosticDeviceKeyProvider(KeyA());
Assert.False(provider.Compute("", "device-1").HasValue);
Assert.False(provider.Compute(" ", "device-1").HasValue);
Assert.False(provider.Compute("BATIK.01", null).HasValue);
Assert.False(provider.Compute("BATIK.01", "").HasValue);
Assert.False(provider.Compute("BATIK.01", " ").HasValue);
}
[Fact]
public void CanonicalString_IsVersionedAndDeterministic()
{
string a = DiagnosticDeviceKeyProvider.BuildCanonicalString(1, "BATIK.01", "device-1");
string b = DiagnosticDeviceKeyProvider.BuildCanonicalString(1, "BATIK.01", "device-1");
Assert.Equal(a, b);
Assert.Equal("v1|tenant:BATIK.01|device:device-1", a);
}
[Fact]
public void DeviceKey_IsDomainSeparated_FromActorKey()
{
// Même clé HMAC, même tenant, même valeur d'identifiant : les deux pseudonymes
// doivent rester distincts grâce au libellé de champ (device: vs collaborateur:).
var actor = new DiagnosticActorKeyProvider(KeyA());
var device = new DiagnosticDeviceKeyProvider(KeyA());
DiagnosticActorKey actorKey = actor.Compute("BATIK.01", "42");
DiagnosticDeviceKey deviceKey = device.Compute("BATIK.01", "42");
Assert.True(actorKey.HasValue);
Assert.True(deviceKey.HasValue);
Assert.NotEqual(actorKey.Value, deviceKey.Value);
}
}
@@ -0,0 +1,255 @@
using System.Text.Json;
using Omika.Core.Observability.External;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Models;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 17-B — Mapping Worker + transport JSONL des dimensions appareil/licence :
/// <list type="bullet">
/// <item>Le mapper ne perd plus OperatingSystem/OSVersion/DeviceModel/DeviceManufacturer.</item>
/// <item>LicenseNumber/DeviceKey/DeviceKeyVersion + AppBuild + Screen*/DisplayScale sont mappés.</item>
/// <item>Le store écrit les champs serveur (licenseNumber/deviceKey/deviceKeyVersion) en JSONL
/// et continue d'épurer le DeviceId brut.</item>
/// <item>Toutes les valeurs null restent null (compatibilité anciennes lignes).</item>
/// </list>
/// Aucun PostgreSQL réel requis.
/// </summary>
public class DiagnosticDeviceLicenseMapperTests : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly string _dir = CreateTempDirectory();
public void Dispose() => DeleteDirectory(_dir);
// --------------------------- M1 : mapper conserve toutes les dimensions ------
[Fact]
public void M1_Mapper_PreservesDeviceAndLicenseDimensions()
{
RemoteDiagnosticRecord record = NewRecord(
Guid.NewGuid(),
licenseNumber: "LIC-123",
deviceKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
deviceKeyVersion: 1,
manufacturer: "Xiaomi",
model: "POCO Demo",
os: "Android",
osVersion: "16",
appBuild: "1410",
screenWidth: 1080, screenHeight: 2400,
logicalWidth: 360, logicalHeight: 800,
displayScale: 3.0);
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Equal("LIC-123", mapped!.LicenseNumber);
Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", mapped.DeviceKey);
Assert.Equal(1, mapped.DeviceKeyVersion);
Assert.Equal("Xiaomi", mapped.DeviceManufacturer);
Assert.Equal("POCO Demo", mapped.DeviceModel);
Assert.Equal("Android", mapped.OperatingSystem);
Assert.Equal("16", mapped.OperatingSystemVersion);
Assert.Equal("1410", mapped.AppBuild);
Assert.Equal(1080, mapped.ScreenWidth);
Assert.Equal(2400, mapped.ScreenHeight);
Assert.Equal(360, mapped.LogicalWidth);
Assert.Equal(800, mapped.LogicalHeight);
Assert.Equal(3.0, mapped.DisplayScale);
}
// --------------------------- M2 : toutes valeurs null → null ----------------
[Fact]
public void M2_Mapper_AllNull_LeavesAllDimensionsNull()
{
RemoteDiagnosticRecord record = NewRecord(Guid.NewGuid());
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Null(mapped!.LicenseNumber);
Assert.Null(mapped.DeviceKey);
Assert.Null(mapped.DeviceKeyVersion);
Assert.Null(mapped.DeviceManufacturer);
Assert.Null(mapped.DeviceModel);
Assert.Null(mapped.OperatingSystem);
Assert.Null(mapped.OperatingSystemVersion);
Assert.Null(mapped.AppBuild);
Assert.Null(mapped.ScreenWidth);
Assert.Null(mapped.ScreenHeight);
Assert.Null(mapped.LogicalWidth);
Assert.Null(mapped.LogicalHeight);
Assert.Null(mapped.DisplayScale);
}
// --------------------------- M3 : JSONL conserve les champs serveur ---------
[Fact]
public async Task M3_Store_WritesServerIdentityFields_AndPreservesOnReadBack()
{
var deviceKey = new DiagnosticDeviceKey("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 1);
DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), deviceId: "device-raw-secret");
string content = await WriteAsync(evt, deviceKey, licenseNumber: "LIC-456");
using JsonDocument doc = JsonDocument.Parse(content);
Assert.Equal("LIC-456", doc.RootElement.GetProperty("licenseNumber").GetString());
Assert.Equal(deviceKey.Value, doc.RootElement.GetProperty("deviceKey").GetString());
Assert.Equal(1, doc.RootElement.GetProperty("deviceKeyVersion").GetInt32());
RemoteDiagnosticRecord? record = JsonSerializer.Deserialize<RemoteDiagnosticRecord>(content, JsonOptions);
Assert.NotNull(record);
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Equal("LIC-456", mapped!.LicenseNumber);
Assert.Equal(deviceKey.Value, mapped.DeviceKey);
Assert.Equal(1, mapped.DeviceKeyVersion);
}
// --------------------------- M4 : DeviceId brut jamais persisté ---------------
[Fact]
public async Task M4_Store_NeverWritesRawDeviceId()
{
DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), deviceId: "device-raw-secret");
string content = await WriteAsync(evt, DiagnosticDeviceKey.Empty, licenseNumber: null);
Assert.DoesNotContain("\"deviceId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("device-raw-secret", content, StringComparison.Ordinal);
}
// --------------------------- M5 : absence serveur → champs omis --------------
[Fact]
public async Task M5_Store_AbsentIdentity_OmitsServerFields()
{
string content = await WriteAsync(NewEvent(Guid.NewGuid()), DiagnosticDeviceKey.Empty, licenseNumber: null);
using JsonDocument doc = JsonDocument.Parse(content);
Assert.False(doc.RootElement.TryGetProperty("licenseNumber", out _));
Assert.False(doc.RootElement.TryGetProperty("deviceKey", out _));
Assert.False(doc.RootElement.TryGetProperty("deviceKeyVersion", out _));
}
// --------------------------- M6 : dimensions écran null → transport null -----
[Fact]
public void M6_Mapper_NullScreenDimensions_RemainNull()
{
RemoteDiagnosticRecord record = NewRecord(Guid.NewGuid(), screenWidth: null, displayScale: null);
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Null(mapped!.ScreenWidth);
Assert.Null(mapped.DisplayScale);
}
// --------------------------- Helpers ------------------------------------------
private static RemoteDiagnosticRecord NewRecord(
Guid eventId,
string? licenseNumber = null,
string? deviceKey = null,
int? deviceKeyVersion = null,
string? manufacturer = null,
string? model = null,
string? os = null,
string? osVersion = null,
string? appBuild = null,
int? screenWidth = null,
int? screenHeight = null,
int? logicalWidth = null,
int? logicalHeight = null,
double? displayScale = null)
{
DateTimeOffset ts = DateTimeOffset.UtcNow;
return new RemoteDiagnosticRecord
{
ReceivedAtUtc = ts,
BatchId = Guid.NewGuid(),
IngestionTraceId = Guid.NewGuid().ToString("D"),
LicenseNumber = licenseNumber,
DeviceKey = deviceKey,
DeviceKeyVersion = deviceKeyVersion,
Event = new DiagnosticEventDto
{
EventId = eventId,
EventCode = "OMK-MOB-APP-001",
TimestampUtc = ts,
Level = "Info",
Priority = "P3",
DeviceManufacturer = manufacturer,
DeviceModel = model,
OperatingSystem = os,
OperatingSystemVersion = osVersion,
AppBuild = appBuild,
ScreenWidth = screenWidth,
ScreenHeight = screenHeight,
LogicalWidth = logicalWidth,
LogicalHeight = logicalHeight,
DisplayScale = displayScale
}
};
}
private static DiagnosticEventDto NewEvent(Guid eventId, string? deviceId = null) => new()
{
EventId = eventId,
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
DeviceId = deviceId
};
private async Task<string> WriteAsync(
DiagnosticEventDto evt,
DiagnosticDeviceKey deviceKey,
string? licenseNumber)
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
var batch = new DiagnosticBatchDto { Events = [evt] };
await store.StoreAsync(
batch,
DiagnosticActorKey.Empty,
"trace-id",
DateTimeOffset.UtcNow,
CancellationToken.None,
deploymentContext: null,
deviceKey: deviceKey,
licenseNumber: licenseNumber);
return ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
}
private static string CreateTempDirectory()
{
string dir = Path.Combine(Path.GetTempPath(), "omika-device-license-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private static string ReadAllTextShared(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static void DeleteDirectory(string dir)
{
try
{
if (Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
}
@@ -0,0 +1,213 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Suppression d'un groupe d'erreurs complet (bouton « Supprimer le groupe » /Erreurs).
/// Le repository supprime TOUTES les occurrences d'un groupe (aucune borne de période),
/// restreintes aux niveaux d'erreur réels. Tests sur <see cref="FakeDiagnosticEventRepository"/> ;
/// la production exécute la même sémantique via <c>IS NOT DISTINCT FROM</c> + prédicat
/// d'erreur réel.
/// </summary>
public class DiagnosticErrorGroupDeleteTests
{
private static DiagnosticEventRecord Ev(
string eventCode = "ERR",
DateTimeOffset? ts = null,
string? errorCode = null,
string? exceptionType = null,
string? component = null,
string level = "Error") =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? DateTimeOffset.UtcNow,
ErrorCode = errorCode,
ExceptionType = exceptionType,
Component = component,
Level = level,
Priority = "P2",
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
/// <summary>
/// Identité de groupe aux clés NORMALISÉES (même normalisation que
/// <see cref="DiagnosticErrorGroupLink.TryDecode"/>, qui re-normalise à la réception).
/// </summary>
private static DiagnosticErrorGroupIdentity Identity(
string eventCode = "ERR",
string? errorCode = null,
string? exceptionType = null,
string? component = null) =>
new(eventCode,
DiagnosticErrorGrouping.NormalizeKey(errorCode),
DiagnosticErrorGrouping.NormalizeKey(exceptionType),
DiagnosticErrorGrouping.NormalizeKey(component));
// --------------------------------------- D1 : supprime tout le groupe ----
[Fact]
public async Task DeleteErrorGroup_RemovesAllOccurrences()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", exceptionType: "System.TimeoutException"),
Ev("ERR", exceptionType: "System.TimeoutException"),
Ev("ERR", exceptionType: "System.TimeoutException")
});
long deleted = await repo.DeleteErrorGroupAsync(
Identity(exceptionType: "system.timeoutexception"));
Assert.Equal(3, deleted);
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
Assert.Equal(0, remaining.TotalCount);
}
// ------------------------------- D2 : aucune borne de période (tout le groupe) ----
[Fact]
public async Task DeleteErrorGroup_NoPeriodBound_RemovesAncientAndRecent()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", DateTimeOffset.UtcNow.AddYears(-2), exceptionType: "System.Exception"),
Ev("ERR", DateTimeOffset.UtcNow, exceptionType: "System.Exception")
});
long deleted = await repo.DeleteErrorGroupAsync(
Identity(exceptionType: "system.exception"));
Assert.Equal(2, deleted);
}
// ------------------------------- D3 : casse/espaces → même groupe ----
[Fact]
public async Task DeleteErrorGroup_NormalizesKeys()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", exceptionType: "System.TimeoutException", component: "Omika.Api"),
Ev("ERR", exceptionType: "system.timeoutexception", component: " omika.api ")
});
long deleted = await repo.DeleteErrorGroupAsync(
Identity(exceptionType: "SYSTEM.TIMEOUTEXCEPTION", component: "OMIKA.API"));
Assert.Equal(2, deleted);
}
// ------------------------------- D4 : NULL ErrorCode → retrouvé ----
[Fact]
public async Task DeleteErrorGroup_NullErrorCode_IsMatched()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", errorCode: null),
Ev("ERR", errorCode: string.Empty),
Ev("ERR", errorCode: " ")
});
long deleted = await repo.DeleteErrorGroupAsync(Identity(errorCode: null));
Assert.Equal(3, deleted); // null / '' / espaces → tous normalisés en NULL
}
// ------------------------------- D5 : autre EventCode → exclu ----
[Fact]
public async Task DeleteErrorGroup_DifferentEventCode_IsExcluded()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord keep = Ev("OTHER", exceptionType: "System.Exception");
await repo.InsertBatchAsync(new[]
{
Ev("ERR", exceptionType: "System.Exception"),
keep
});
long deleted = await repo.DeleteErrorGroupAsync(
Identity(eventCode: "ERR", exceptionType: "system.exception"));
Assert.Equal(1, deleted);
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
DiagnosticEventRecord item = Assert.Single(remaining.Items);
Assert.Equal(keep.EventId, item.EventId);
}
// ------------------------------- D6 : non-erreur de même signature → conservé ----
[Fact]
public async Task DeleteErrorGroup_NonErrorSameSignature_IsKept()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord info = Ev("ERR", exceptionType: "System.Exception", level: "info");
await repo.InsertBatchAsync(new[]
{
Ev("ERR", exceptionType: "System.Exception", level: "error"),
info
});
long deleted = await repo.DeleteErrorGroupAsync(
Identity(exceptionType: "system.exception"));
Assert.Equal(1, deleted);
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
DiagnosticEventRecord item = Assert.Single(remaining.Items);
Assert.Equal(info.EventId, item.EventId);
}
// ------------------------------- D7 : groupe inexistant → 0 ----
[Fact]
public async Task DeleteErrorGroup_Nonexistent_ReturnsZero()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[] { Ev("ERR", exceptionType: "System.Exception") });
long deleted = await repo.DeleteErrorGroupAsync(
Identity(exceptionType: "system.nullreferenceexception"));
Assert.Equal(0, deleted);
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
Assert.Equal(1, remaining.TotalCount);
}
// ------------------------------- D8 : table vide → 0 ----
[Fact]
public async Task DeleteErrorGroup_EmptyTable_ReturnsZero()
{
var repo = new FakeDiagnosticEventRepository();
long deleted = await repo.DeleteErrorGroupAsync(Identity(exceptionType: "system.exception"));
Assert.Equal(0, deleted);
}
// ------------------------------- D9 : ne supprime pas les autres groupes ----
[Fact]
public async Task DeleteErrorGroup_LeavesOtherGroups()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord other = Ev("ERR", exceptionType: "System.NullReferenceException");
await repo.InsertBatchAsync(new[]
{
Ev("ERR", exceptionType: "System.TimeoutException"),
other
});
long deleted = await repo.DeleteErrorGroupAsync(
Identity(exceptionType: "system.timeoutexception"));
Assert.Equal(1, deleted);
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
DiagnosticEventRecord item = Assert.Single(remaining.Items);
Assert.Equal(other.EventId, item.EventId);
}
}
@@ -0,0 +1,110 @@
using Omika.Diagnostics.Queries;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 5 — Identifiant URL-safe du groupe d'erreurs (§T5/12/28). Le codec ne doit
/// JAMAIS lever depuis une URL malformée (décodage défensif → null) et doit faire un
/// aller-retour fidèle (caractères spéciaux, null, séparateur de contrôle).
/// </summary>
public class DiagnosticErrorGroupLinkTests
{
[Fact]
public void EncodeDecode_RoundTrip_PreservesIdentity()
{
var identity = new DiagnosticErrorGroupIdentity(
"ERR",
"timeout",
"system.timeoutexception",
"omika.api");
string encoded = DiagnosticErrorGroupLink.Encode(identity);
DiagnosticErrorGroupIdentity? decoded = DiagnosticErrorGroupLink.TryDecode(encoded);
Assert.NotNull(decoded);
Assert.Equal(identity, decoded);
}
[Fact]
public void EncodeDecode_WithNulls_RoundTrips()
{
var identity = new DiagnosticErrorGroupIdentity("ERR", null, null, null);
DiagnosticErrorGroupIdentity? decoded = DiagnosticErrorGroupLink.TryDecode(
DiagnosticErrorGroupLink.Encode(identity));
Assert.NotNull(decoded);
Assert.Null(decoded.NormalizedErrorCode);
Assert.Null(decoded.NormalizedExceptionType);
Assert.Null(decoded.NormalizedComponent);
Assert.Equal("ERR", decoded.EventCode);
}
[Fact]
public void EncodeDecode_SpecialCharacters_RoundTrips()
{
var identity = new DiagnosticErrorGroupIdentity(
"OMK/API-001_2.3",
"code+avec=et&des?",
"type avec espaces / accents éà",
"component\u001Favec-séparateur");
DiagnosticErrorGroupIdentity? decoded = DiagnosticErrorGroupLink.TryDecode(
DiagnosticErrorGroupLink.Encode(identity));
Assert.NotNull(decoded);
Assert.Equal(identity, decoded);
}
[Fact]
public void Encode_ProducesUrlSafeCharactersOnly()
{
string encoded = DiagnosticErrorGroupLink.Encode(
new DiagnosticErrorGroupIdentity("ERR", "a/b+c=d", "e", "f"));
Assert.All(encoded, c => Assert.True(
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'));
}
[Fact]
public void TryDecode_NullOrEmpty_ReturnsNull()
{
Assert.Null(DiagnosticErrorGroupLink.TryDecode(null));
Assert.Null(DiagnosticErrorGroupLink.TryDecode(string.Empty));
Assert.Null(DiagnosticErrorGroupLink.TryDecode(" "));
}
[Theory]
[InlineData("!!!not-base64!!!")]
[InlineData("abc")] // base64 invalide (longueur)
[InlineData("YWJjZA==")] // base64 standard avec padding '=' (non URL-safe)
public void TryDecode_Invalid_ReturnsNull(string encoded)
{
Assert.Null(DiagnosticErrorGroupLink.TryDecode(encoded));
}
[Fact]
public void TryDecode_ReNormalizesKeys()
{
// On encode volontairement des clés NON normalisées ; le décodage re-normalise
// (défense en profondeur) pour rester cohérent avec le regroupement.
string encoded = DiagnosticErrorGroupLink.Encode(
new DiagnosticErrorGroupIdentity("ERR", " TimeOut ", "System.TimeoutException", "Omika.Api"));
DiagnosticErrorGroupIdentity? decoded = DiagnosticErrorGroupLink.TryDecode(encoded);
Assert.NotNull(decoded);
Assert.Equal("timeout", decoded.NormalizedErrorCode);
Assert.Equal("system.timeoutexception", decoded.NormalizedExceptionType);
Assert.Equal("omika.api", decoded.NormalizedComponent);
}
[Fact]
public void TryDecode_EmptyEventCode_ReturnsNull()
{
string encoded = DiagnosticErrorGroupLink.Encode(
new DiagnosticErrorGroupIdentity("", "a", "b", "c"));
Assert.Null(DiagnosticErrorGroupLink.TryDecode(encoded));
}
}
@@ -0,0 +1,273 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 5 — Occurrences d'un groupe d'erreurs. La récupération des occurrences doit
/// reproduire EXACTEMENT la normalisation du regroupement (NormalizeKey =
/// LOWER(NULLIF(TRIM(col),''))). Tests sur <see cref="FakeDiagnosticEventRepository"/> ;
/// la production exécute la même sémantique via <c>IS NOT DISTINCT FROM</c>.
/// </summary>
public class DiagnosticErrorGroupOccurrenceTests
{
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",
DateTimeOffset? ts = null,
string? errorCode = null,
string? exceptionType = null,
string? component = null,
string level = "Error") =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
ErrorCode = errorCode,
ExceptionType = exceptionType,
Component = component,
Level = level,
Priority = "P2",
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticErrorGroupOccurrenceQuery Query(
string eventCode = "ERR",
string? normalizedErrorCode = null,
string? normalizedExceptionType = null,
string? normalizedComponent = null,
int page = 1,
int pageSize = DiagnosticErrorGroupOccurrenceQuery.DefaultPageSize) =>
new()
{
FromUtc = From,
ToUtc = To,
EventCode = eventCode,
NormalizedErrorCode = normalizedErrorCode,
NormalizedExceptionType = normalizedExceptionType,
NormalizedComponent = normalizedComponent,
Page = page,
PageSize = pageSize
};
// --------------------------------------------------- E1 : 3 → 3 occurrences ----
[Fact]
public async Task Occurrences_ThreeEvents_ReturnThreeOccurrences()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.TimeoutException"),
Ev("ERR", From.AddMinutes(2), exceptionType: "System.TimeoutException"),
Ev("ERR", From.AddMinutes(3), exceptionType: "System.TimeoutException")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedExceptionType: "system.timeoutexception"));
Assert.Equal(3, result.Count);
}
// --------------------------------------- E2 : casse différente → même groupe ----
[Fact]
public async Task Occurrences_CaseInsensitiveExceptionType_SameGroup()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.TimeoutException"),
Ev("ERR", From.AddMinutes(2), exceptionType: "system.timeoutexception")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedExceptionType: "system.timeoutexception"));
Assert.Equal(2, result.Count);
}
// --------------------------------------- E3 : espaces autour → même groupe ----
[Fact]
public async Task Occurrences_WhitespaceAroundComponent_SameGroup()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), component: "Omika.Api"),
Ev("ERR", From.AddMinutes(2), component: " omika.api ")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedComponent: "omika.api"));
Assert.Equal(2, result.Count);
}
// --------------------------------------- E4 : NULL ErrorCode → retrouvé ----
[Fact]
public async Task Occurrences_NullErrorCode_AreFound()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), errorCode: null),
Ev("ERR", From.AddMinutes(2), errorCode: string.Empty),
Ev("ERR", From.AddMinutes(3), errorCode: " ")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedErrorCode: null));
Assert.Equal(3, result.Count); // null / '' / espaces → tous normalisés en NULL
}
// --------------------------------------- E5 : EventCode différent → exclu ----
[Fact]
public async Task Occurrences_DifferentEventCode_AreExcluded()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.Exception"),
Ev("OTHER", From.AddMinutes(2), exceptionType: "System.Exception")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(eventCode: "ERR", normalizedExceptionType: "system.exception"));
DiagnosticEventRecord item = Assert.Single(result);
Assert.Equal("ERR", item.EventCode);
}
// --------------------------------------- E6 : hors période → exclu ----
[Fact]
public async Task Occurrences_OutOfPeriod_AreExcluded()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(-5), exceptionType: "System.Exception"), // avant
Ev("ERR", From.AddMinutes(5), exceptionType: "System.Exception"), // dans
Ev("ERR", To.AddMinutes(5), exceptionType: "System.Exception") // après (borne exclusive)
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedExceptionType: "system.exception"));
DiagnosticEventRecord item = Assert.Single(result);
}
// --------------------------------------- E7 : limite respectée ----
[Fact]
public async Task Occurrences_RespectsPageSize()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.Exception"),
Ev("ERR", From.AddMinutes(2), exceptionType: "System.Exception"),
Ev("ERR", From.AddMinutes(3), exceptionType: "System.Exception")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedExceptionType: "system.exception", pageSize: 2));
Assert.Equal(2, result.Count);
}
// --------------------------------------- E8 : pagination ----
[Fact]
public async Task Occurrences_Pagination_ReturnsSecondPage()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.Exception"),
Ev("ERR", From.AddMinutes(2), exceptionType: "System.Exception"),
Ev("ERR", From.AddMinutes(3), exceptionType: "System.Exception")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedExceptionType: "system.exception", page: 2, pageSize: 1));
DiagnosticEventRecord item = Assert.Single(result);
Assert.Equal(From.AddMinutes(2), item.TimestampUtc); // 2e plus récent
}
// --------------------------------------- E9 : tri timestamp DESC ----
[Fact]
public async Task Occurrences_ReturnsDescendingOrder()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(2), exceptionType: "System.Exception"),
Ev("ERR", From.AddMinutes(1), exceptionType: "System.Exception"),
Ev("ERR", From.AddMinutes(3), exceptionType: "System.Exception")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedExceptionType: "system.exception"));
Assert.Equal(
new[]
{
From.AddMinutes(3),
From.AddMinutes(2),
From.AddMinutes(1)
},
result.Select(e => e.TimestampUtc).ToArray());
}
// --------------------------------------- E10 : groupe inexistant → vide ----
[Fact]
public async Task Occurrences_NonexistentGroup_ReturnsEmpty()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.Exception")
});
IReadOnlyList<DiagnosticEventRecord> result = await repo.GetErrorGroupOccurrencesAsync(
Query(normalizedExceptionType: "system.nullreferenceexception"));
Assert.Empty(result);
}
// ----------------------------------- GetErrorGroupAsync : résumé ----
[Fact]
public async Task GetErrorGroup_ReturnsSummary()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("ERR", From.AddMinutes(1), exceptionType: "System.TimeoutException", component: "Omika.Api"),
Ev("ERR", From.AddMinutes(10), exceptionType: "System.TimeoutException", component: "Omika.Api")
});
DiagnosticErrorGroup? group = await repo.GetErrorGroupAsync(
Query(normalizedExceptionType: "system.timeoutexception", normalizedComponent: "omika.api"));
Assert.NotNull(group);
Assert.Equal(2, group.OccurrenceCount);
Assert.Equal(From.AddMinutes(1), group.FirstSeenUtc);
Assert.Equal(From.AddMinutes(10), group.LastSeenUtc);
}
[Fact]
public async Task GetErrorGroup_Nonexistent_ReturnsNull()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[] { Ev("ERR", From.AddMinutes(1), exceptionType: "System.Exception") });
DiagnosticErrorGroup? group = await repo.GetErrorGroupAsync(
Query(normalizedExceptionType: "system.nullreferenceexception"));
Assert.Null(group);
}
}
@@ -0,0 +1,276 @@
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));
}
}
@@ -0,0 +1,130 @@
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 12 — Options de filtre : conservation de la valeur active (C8/C9) et
/// comportement des valeurs distinctes (borne, tri, exclusion des vides).
/// </summary>
public class DiagnosticFilterOptionsTests
{
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
// --------------------------- C8 : filtre EventCode conserve sa valeur ----
[Fact]
public void EnsureIncluded_KeepsActiveEventCodeAtFrontWithoutDuplicate()
{
IReadOnlyList<string> options = new[] { "OMK-API-HTTP-001", "OMK-API-HTTP-003" };
IReadOnlyList<string> result = DiagnosticFilterDefaults.EnsureIncluded(options, "OMK-WKR-SYNC-002");
Assert.Equal(3, result.Count);
Assert.Equal("OMK-WKR-SYNC-002", result[0]);
Assert.Equal("OMK-API-HTTP-001", result[1]);
Assert.Equal("OMK-API-HTTP-003", result[2]);
}
// ----------------------------- C9 : filtre version conserve sa valeur ----
[Fact]
public void EnsureIncluded_KeepsActiveVersionWithoutDuplicate()
{
IReadOnlyList<string> options = new[] { "1.0.0.0", "1.1.0.0" };
IReadOnlyList<string> result = DiagnosticFilterDefaults.EnsureIncluded(options, "2.0.0.0");
Assert.Equal(3, result.Count);
Assert.Contains("2.0.0.0", result);
Assert.Equal(1, result.Count(v => v == "2.0.0.0"));
}
[Fact]
public void EnsureIncluded_ReturnsSameListWhenAlreadyPresent()
{
IReadOnlyList<string> options = new[] { "1.0.0.0", "1.1.0.0" };
IReadOnlyList<string> result = DiagnosticFilterDefaults.EnsureIncluded(options, "1.0.0.0");
Assert.Same(options, result);
Assert.Equal(2, result.Count);
}
// ------------------------------------ Options : distinct, triées, bornées ----
[Fact]
public async Task GetFilterOptions_ReturnsDistinctSortedBoundedValues()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), component: "Zeta"),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(2), component: "Alpha"),
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(3), component: "Alpha"),
DiagnosticTestData.Event(Guid.NewGuid(), "D", From.AddMinutes(4), component: null), // exclu (null)
DiagnosticTestData.Event(Guid.NewGuid(), "E", From.AddMinutes(5), component: " "), // exclu (vide)
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.Component, From, 10);
Assert.Equal(new[] { "Alpha", "Zeta" }, result);
}
[Fact]
public async Task GetFilterOptions_RespectsLimit()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), component: "C1"),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(2), component: "C2"),
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(3), component: "C3"),
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.Component, From, 2);
Assert.Equal(2, result.Count);
}
// ---------------- T20 §3/§6 : options Component normalisées (une seule option) ----
[Fact]
public async Task GetFilterOptions_Component_NormalizesCaseAndWhitespace()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), component: "Omika.Api"),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(2), component: "omika.api"),
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(3), component: "OMIKA.API"),
DiagnosticTestData.Event(Guid.NewGuid(), "D", From.AddMinutes(4), component: " Omika.Api "),
DiagnosticTestData.Event(Guid.NewGuid(), "E", From.AddMinutes(5), component: "Omika.Worker"),
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.Component, From, 10);
// 4 variantes du même composant + 1 autre = 2 options logiques (tri ascendant).
Assert.Equal(new[] { "Omika.Api", "Omika.Worker" }, result);
}
// ---------------- T20 §4/§6 : options ErrorCode normalisées (une seule option) ----
[Fact]
public async Task GetFilterOptions_ErrorCode_NormalizesCaseAndWhitespace()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), errorCode: "timeout"),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(2), errorCode: "TIMEOUT"),
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(3), errorCode: " Timeout"),
DiagnosticTestData.Event(Guid.NewGuid(), "D", From.AddMinutes(4), errorCode: " timeout "),
DiagnosticTestData.Event(Guid.NewGuid(), "E", From.AddMinutes(5), errorCode: "CRASH"),
});
IReadOnlyList<string> result = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ErrorCode, From, 10);
// 4 variantes du même code + 1 autre = 2 options logiques (tri ascendant).
Assert.Equal(new[] { "CRASH", "TIMEOUT" }, result);
}
}
@@ -0,0 +1,67 @@
using Microsoft.Extensions.Options;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>§52 — Fail-open PostgreSQL : l'importeur survit, backoff, JSONL intact, checkpoint non avancé.</summary>
public class DiagnosticImporterFailOpenTests
{
[Fact]
public async Task RepositoryDown_ReturnsRetryable_AndDoesNotAdvanceCheckpoint()
{
var source = new FakeDiagnosticJsonlSource(new[]
{
DiagnosticTestData.Record(Guid.NewGuid(), "A"),
DiagnosticTestData.Record(Guid.NewGuid(), "B")
});
var importer = new DiagnosticImporter(
source,
new ThrowingDiagnosticRepository(),
Options.Create(new DiagnosticImportOptions { BatchSize = 100 }));
DiagnosticImportResult result = await importer.ImportOnceAsync();
Assert.False(result.Succeeded);
Assert.True(result.Retryable);
Assert.Equal("InvalidOperationException", result.Error);
// Le checkpoint n'a PAS avancé : le JSONL reste intact et sera relu.
Assert.Equal(0, source.CommitCount);
Assert.Null(source.LoadCheckpoint());
}
[Fact]
public async Task RepositoryDown_NextPassReadsSameRecords()
{
var source = new FakeDiagnosticJsonlSource(new[]
{
DiagnosticTestData.Record(Guid.NewGuid(), "A")
});
var importer = new DiagnosticImporter(
source,
new ThrowingDiagnosticRepository(),
Options.Create(new DiagnosticImportOptions { BatchSize = 100 }));
await importer.ImportOnceAsync();
await importer.ImportOnceAsync();
// Toujours 0 commit : reprise idempotente, jamais de perte de données.
Assert.Equal(0, source.CommitCount);
}
[Fact]
public async Task Backoff_IsBoundedExponential_NoCpuSpin()
{
var options = new DiagnosticImportOptions { RetryDelaySeconds = 5, MaxRetryDelaySeconds = 300 };
TimeSpan first = DiagnosticBackoff.Compute(options, 1);
TimeSpan second = DiagnosticBackoff.Compute(options, 2);
TimeSpan capped = DiagnosticBackoff.Compute(options, 1000);
Assert.True(second > first);
Assert.True(capped <= TimeSpan.FromSeconds(options.MaxRetryDelaySeconds));
}
}
@@ -0,0 +1,313 @@
using System.Text.Json;
using Omika.Core.Observability.External;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 13-B — Propagation ClientCode / ServerInstance / Environment dans le pipeline
/// JSONL (transport uniquement, aucune résolution réelle) :
/// <list type="bullet">
/// <item>B1–B5 : compatibilité ancien JSONL + round-trip des nouveaux champs.</item>
/// <item>B6/B7 : le mapper (remote ET server via le store) conserve les 3 valeurs.</item>
/// <item>B8/B24 : le ClientId mobile ne devient JAMAIS le ClientCode autoritaire.</item>
/// <item>B9/B10 : StripIdentity épure les identités brutes et conserve l'ActorKey.</item>
/// <item>B11/B12 : le ClientCode autoritaire fourni au store est conservé ; l'absence → null.</item>
/// </list>
/// Aucun PostgreSQL réel requis (fake + store fichier en temp, §51).
/// </summary>
public class DiagnosticJsonlContextTests : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly string _dir = CreateTempDirectory();
public void Dispose() => DeleteDirectory(_dir);
// --------------------------- B1 : ancien JSONL sans nouveaux champs ----------
[Fact]
public void B1_OldJsonlWithoutNewFields_DeserializesToNullContext()
{
const string line = """
{"receivedAtUtc":"2025-01-01T00:00:00+00:00","batchId":"11111111-1111-1111-1111-111111111111","ingestionTraceId":"22222222-2222-2222-2222-222222222222","event":{"eventId":"33333333-3333-3333-3333-333333333333","eventCode":"OMK-MOB-APP-001","timestampUtc":"2025-01-01T00:00:00+00:00","level":"Info"}}
""";
RemoteDiagnosticRecord? parsed = JsonSerializer.Deserialize<RemoteDiagnosticRecord>(line, JsonOptions);
Assert.NotNull(parsed);
Assert.Null(parsed!.ClientCode);
Assert.Null(parsed.ServerInstance);
Assert.Null(parsed.Environment);
}
// --------------------------- B2/B3/B4/B5 : round-trip ------------------------
[Fact]
public void B2_RecordWithClientCode_RoundTrips()
{
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
Guid.NewGuid(), "OMK-MOB-APP-001", clientCode: "CLIENT-TEST-A");
RemoteDiagnosticRecord? back = RoundTrip(record);
Assert.Equal("CLIENT-TEST-A", back!.ClientCode);
}
[Fact]
public void B3_RecordWithServerInstance_RoundTrips()
{
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
Guid.NewGuid(), "OMK-MOB-APP-001", serverInstance: "API-TEST-A1");
RemoteDiagnosticRecord? back = RoundTrip(record);
Assert.Equal("API-TEST-A1", back!.ServerInstance);
}
[Fact]
public void B4_RecordWithEnvironment_RoundTrips()
{
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
Guid.NewGuid(), "OMK-MOB-APP-001", environment: "Development");
RemoteDiagnosticRecord? back = RoundTrip(record);
Assert.Equal("Development", back!.Environment);
}
[Fact]
public void B5_RecordWithAllThreeFields_RoundTrips()
{
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
Guid.NewGuid(),
"OMK-MOB-APP-001",
clientCode: "CLIENT-TEST-A",
serverInstance: "API-TEST-A1",
environment: "Development");
RemoteDiagnosticRecord? back = RoundTrip(record);
Assert.Equal("CLIENT-TEST-A", back!.ClientCode);
Assert.Equal("API-TEST-A1", back!.ServerInstance);
Assert.Equal("Development", back!.Environment);
}
// --------------------------- B6 : mapper remote ------------------------------
[Fact]
public void B6_Mapper_RemoteRecord_PreservesThreeValues()
{
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
Guid.NewGuid(),
"OMK-MOB-APP-001",
clientCode: "CLIENT-TEST-A",
serverInstance: "API-TEST-A1",
environment: "Development");
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Equal("CLIENT-TEST-A", mapped!.ClientCode);
Assert.Equal("API-TEST-A1", mapped.ServerInstance);
Assert.Equal("Development", mapped.Environment);
}
// --------------------------- B7 : mapper server (via le store) ---------------
[Fact]
public async Task B7_ServerRecordThroughStoreAndMapper_PreservesValues()
{
// ServerDiagnosticRecord (privé) n'est jamais mappé directement : il est écrit
// par le store puis relu comme RemoteDiagnosticRecord. Le cycle complet doit
// conserver les 3 valeurs jusqu'au DiagnosticEventRecord.
var context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-TEST-A",
ServerInstance = "API-TEST-A1",
Environment = "Development"
};
Guid eventId = Guid.NewGuid();
string content = await WriteAsync(NewEvent(eventId), DiagnosticActorKey.Empty, context);
RemoteDiagnosticRecord? record = JsonSerializer.Deserialize<RemoteDiagnosticRecord>(content, JsonOptions);
Assert.NotNull(record);
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Equal("CLIENT-TEST-A", mapped!.ClientCode);
Assert.Equal("API-TEST-A1", mapped.ServerInstance);
Assert.Equal("Development", mapped.Environment);
}
// --------------------------- B8 : ClientId mobile ≠ ClientCode ----------------
[Fact]
public async Task B8_MobileClientId_NeverBecomesClientCodeAutomatically()
{
// Identité transport mobile brute : NE DOIT PAS devenir clientCode.
DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), clientId: "FAUX-CLIENT");
string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context: null);
using JsonDocument doc = JsonDocument.Parse(content);
// Contexte serveur absent → clientCode non écrit (WhenWritingNull), et le
// ClientId mobile est épuré par StripIdentity : "FAUX-CLIENT" n'apparaît pas.
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
Assert.DoesNotContain("FAUX-CLIENT", content, StringComparison.Ordinal);
}
// --------------------------- B9 : StripIdentity -------------------------------
[Fact]
public async Task B9_StripIdentity_StillRemovesForbiddenIdentities()
{
DiagnosticEventDto evt = NewEvent(
Guid.NewGuid(),
deviceId: "device-1",
clientId: "BATIK.01",
userId: "user-1",
collaborateurId: "42");
string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context: null);
Assert.DoesNotContain("\"deviceId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"clientId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"userId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"collaborateurId\"", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("device-1", content, StringComparison.Ordinal);
Assert.DoesNotContain("user-1", content, StringComparison.Ordinal);
Assert.DoesNotContain("\"42\"", content, StringComparison.Ordinal);
}
// --------------------------- B10 : ActorKey conservé --------------------------
[Fact]
public async Task B10_ActorKey_IsPreserved()
{
const string actorKeyValue = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
var actorKey = new DiagnosticActorKey(actorKeyValue, 1);
string content = await WriteAsync(NewEvent(Guid.NewGuid()), actorKey, context: null);
using JsonDocument doc = JsonDocument.Parse(content);
Assert.Equal(actorKeyValue, doc.RootElement.GetProperty("actorKey").GetString());
Assert.Equal(1, doc.RootElement.GetProperty("actorKeyVersion").GetInt32());
}
// --------------------------- B11 : ClientCode autoritaire conservé ------------
[Fact]
public async Task B11_AuthoritativeClientCode_ProvidedToStore_IsPreserved()
{
var context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-AUTORITAIRE" };
string content = await WriteAsync(NewEvent(Guid.NewGuid()), DiagnosticActorKey.Empty, context);
using JsonDocument doc = JsonDocument.Parse(content);
Assert.Equal("CLIENT-AUTORITAIRE", doc.RootElement.GetProperty("clientCode").GetString());
}
// --------------------------- B12 : valeur absente → null ----------------------
[Fact]
public async Task B12_AbsentContext_WritesNoContextFields_AndDeserializesToNull()
{
string content = await WriteAsync(NewEvent(Guid.NewGuid()), DiagnosticActorKey.Empty, context: null);
using (JsonDocument doc = JsonDocument.Parse(content))
{
// Champs null omis (WhenWritingNull) : compatibilité ancien JSONL.
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _));
Assert.False(doc.RootElement.TryGetProperty("environment", out _));
}
RemoteDiagnosticRecord? record = JsonSerializer.Deserialize<RemoteDiagnosticRecord>(content, JsonOptions);
Assert.NotNull(record);
Assert.Null(record!.ClientCode);
Assert.Null(record.ServerInstance);
Assert.Null(record.Environment);
}
// --------------------------- B24 : sécurité ----------------------------------
[Fact]
public async Task B24_MobileClientId_WithAuthoritativeContext_UsesAuthoritativeCode()
{
// DTO mobile : identité transport falsifiable.
DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), clientId: "FAUX-CLIENT");
// Contexte serveur : ClientCode AUTORITAIRE (T13-C le fournira réellement).
var context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-AUTORITAIRE" };
string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context);
using JsonDocument doc = JsonDocument.Parse(content);
Assert.Equal("CLIENT-AUTORITAIRE", doc.RootElement.GetProperty("clientCode").GetString());
Assert.DoesNotContain("FAUX-CLIENT", content, StringComparison.Ordinal);
Assert.DoesNotContain("\"clientId\"", content, StringComparison.OrdinalIgnoreCase);
}
// --------------------------- Helpers ------------------------------------------
private static DiagnosticEventDto NewEvent(
Guid eventId,
string? deviceId = null,
string? clientId = null,
string? userId = null,
string? collaborateurId = null) => new()
{
EventId = eventId,
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
DeviceId = deviceId,
ClientId = clientId,
UserId = userId,
CollaborateurId = collaborateurId
};
private static RemoteDiagnosticRecord? RoundTrip(RemoteDiagnosticRecord record)
{
string json = DiagnosticTestData.SerializeLine(record);
return JsonSerializer.Deserialize<RemoteDiagnosticRecord>(json, JsonOptions);
}
private async Task<string> WriteAsync(
DiagnosticEventDto evt,
DiagnosticActorKey actorKey,
DiagnosticDeploymentContext? context)
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
var batch = new DiagnosticBatchDto { Events = [evt] };
await store.StoreAsync(batch, actorKey, "trace-id", DateTimeOffset.UtcNow, CancellationToken.None, context);
return ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
}
private static string CreateTempDirectory()
{
string dir = Path.Combine(Path.GetTempPath(), "omika-jsonl-context-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private static string ReadAllTextShared(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static void DeleteDirectory(string dir)
{
try
{
if (Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
}
@@ -0,0 +1,216 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche T13-G — Tests PageModel multi-clients (G25-G32) : conservation des filtres
/// dans /Diagnostics, pagination, détail, libellé NULL, non-confusion du serveur
/// Worker, variation des statistiques Dashboard/Telemetry par client. PageModels
/// testés sans HttpContext (constructeur repository uniquement, cf. infra T13-F).
/// </summary>
public class DiagnosticMultiClientPageModelTests
{
private const string ClientA = "DEMO-CLIENT-A";
private const string ClientB = "DEMO-CLIENT-B";
private const string ClientC = "DEMO-CLIENT-C";
private const string ApiA = "API-A-01";
private const string WkrA = "WORKER-A-01";
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static DiagnosticEventRecord Ev(
long id,
string eventCode = "TEST",
string? client = null,
string? server = null,
string? environment = null,
DateTimeOffset? ts = null,
string level = "Info",
string? component = null,
Guid? operationId = null)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(Guid.NewGuid(), eventCode, ts ?? From, component: component, level: level);
record.Id = id;
record.ClientCode = client;
record.ServerInstance = server;
record.Environment = environment;
record.OperationId = operationId;
return record;
}
// --------------------------- G25 : filtre Client conservé ------------------
[Fact]
public async Task G25_DiagnosticsModel_SelectedClientIsPreserved()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, client: ClientA, server: ApiA, environment: "Production"),
Ev(2, client: ClientB, server: "API-B-01", environment: "Production")
});
var model = new DiagnosticsModel(repo) { ClientCode = ClientA };
await model.OnGetAsync();
DiagnosticEventRecord record = Assert.Single(model.Items);
Assert.Equal(ClientA, record.ClientCode);
Assert.Equal(ClientA, model.ClientCode);
}
// --------------------------- G26 : pagination conserve 3 filtres -----------
[Fact]
public void G26_DiagnosticsModel_RoutePreservesAllThreeFilters()
{
var model = new DiagnosticsModel(new FakeDiagnosticEventRepository())
{
ClientCode = ClientA,
ServerInstance = WkrA,
Environment = "Production"
};
Dictionary<string, string> route = model.Route(3);
Assert.Equal("3", route["pageNumber"]);
Assert.Equal(ClientA, route["clientCode"]);
Assert.Equal(WkrA, route["serverInstance"]);
Assert.Equal("Production", route["environment"]);
}
// --------------------------- G27 : détail affiche les 3 valeurs ------------
[Fact]
public async Task G27_DiagnosticsDetail_ShowsDeploymentContext()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord record = Ev(42, client: ClientA, server: ApiA, environment: "Production");
await repo.InsertBatchAsync(new[] { record });
var model = new DiagnosticsDetailModel(repo);
Microsoft.AspNetCore.Mvc.IActionResult result = await model.OnGetAsync(42);
Assert.IsType<Microsoft.AspNetCore.Mvc.RazorPages.PageResult>(result);
Assert.NotNull(model.Event);
Assert.Equal(ClientA, model.Event!.ClientCode);
Assert.Equal(ApiA, model.Event.ServerInstance);
Assert.Equal("Production", model.Event.Environment);
}
// --------------------------- G28 : NULL → « Non renseigné » ----------------
[Fact]
public async Task G28_NullContext_RendersNonRenseigneAndLoadsWithoutCrash()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord record = Ev(7, client: null, server: null, environment: null);
await repo.InsertBatchAsync(new[] { record });
var model = new DiagnosticsDetailModel(repo);
Microsoft.AspNetCore.Mvc.IActionResult result = await model.OnGetAsync(7);
// Le détail se charge sans planter et l'événement NULL reste lisible.
Assert.IsType<Microsoft.AspNetCore.Mvc.RazorPages.PageResult>(result);
Assert.NotNull(model.Event);
Assert.Null(model.Event!.ClientCode);
// L'UI affichera « Non renseigné » pour chacune des trois valeurs NULL.
Assert.Equal("Non renseigné", UiDeploymentContext.Value(model.Event.ClientCode));
Assert.Equal("Non renseigné", UiDeploymentContext.Value(model.Event.ServerInstance));
Assert.Equal("Non renseigné", UiDeploymentContext.Value(model.Event.Environment));
}
// --------------------------- G29 : serveur Worker ≠ serveur API ------------
[Fact]
public async Task G29_WorkerOccurrence_ShowsWorkerServerNotApiServer()
{
var repo = new FakeDiagnosticEventRepository();
Guid operationId = Guid.NewGuid();
await repo.InsertBatchAsync(new[]
{
Ev(1, "OMK-API-HTTP-001", ClientA, ApiA, "Production", operationId: operationId, component: "Omika.Api"),
Ev(2, "OMK-WKR-SYNC-001", ClientA, WkrA, "Production", operationId: operationId, component: "Omika.Worker")
});
var model = new TracesModel(repo) { OperationId = operationId.ToString() };
await model.OnGetAsync();
Assert.Equal(2, model.Timeline.Count);
DiagnosticEventRecord worker = model.Timeline.Single(e => e.Component == "Omika.Worker");
Assert.Equal(WkrA, worker.ServerInstance);
Assert.NotEqual(ApiA, worker.ServerInstance);
}
// --------------------------- G30 : dashboard varie par client --------------
[Fact]
public async Task G30_Dashboard_StatsVaryByClient()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, client: ClientA, server: ApiA, environment: "Production", ts: now.AddMinutes(-30)),
Ev(2, client: ClientA, server: ApiA, environment: "Production", ts: now.AddMinutes(-20)),
Ev(3, client: ClientA, server: WkrA, environment: "Production", ts: now.AddMinutes(-10)),
Ev(4, client: ClientB, server: "API-B-01", environment: "Production", ts: now.AddMinutes(-5))
});
var aModel = new IndexModel(repo) { ClientCode = ClientA };
await aModel.OnGetAsync();
var bModel = new IndexModel(repo) { ClientCode = ClientB };
await bModel.OnGetAsync();
// Les statistiques changent réellement selon le client.
Assert.Equal(3, aModel.TotalEvents);
Assert.Equal(1, bModel.TotalEvents);
Assert.NotEqual(aModel.TotalEvents, bModel.TotalEvents);
}
// --------------------------- G31 : Telemetry filtre Client -----------------
[Fact]
public async Task G31_Telemetry_ClientFilterModifiesDataset()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, client: ClientA, server: ApiA, environment: "Production", ts: now.AddMinutes(-30)),
Ev(2, client: ClientA, server: WkrA, environment: "Production", ts: now.AddMinutes(-20)),
Ev(3, client: ClientB, server: "API-B-01", environment: "Production", ts: now.AddMinutes(-10)),
Ev(4, client: ClientC, server: "API-C-DEV-01", environment: "Development", ts: now.AddMinutes(-5))
});
var aModel = new TelemetryModel(repo) { ClientCode = ClientA };
await aModel.OnGetAsync();
var cModel = new TelemetryModel(repo) { ClientCode = ClientC };
await cModel.OnGetAsync();
Assert.Equal(2, aModel.TotalEvents);
Assert.Equal(1, cModel.TotalEvents);
Assert.NotEqual(aModel.TotalEvents, cModel.TotalEvents);
}
// --------------------------- G32 : Telemetry Client + Environment ----------
[Fact]
public async Task G32_Telemetry_ClientAndEnvironmentAreCombinable()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, client: ClientB, server: "API-B-01", environment: "Production", ts: now.AddMinutes(-30)),
Ev(2, client: ClientB, server: "API-B-01", environment: "Production", ts: now.AddMinutes(-20)),
Ev(3, client: ClientB, server: "API-B-STG-01", environment: "Staging", ts: now.AddMinutes(-10))
});
var prodModel = new TelemetryModel(repo) { ClientCode = ClientB, Environment = "Production" };
await prodModel.OnGetAsync();
var stagingModel = new TelemetryModel(repo) { ClientCode = ClientB, Environment = "Staging" };
await stagingModel.OnGetAsync();
Assert.Equal(2, prodModel.TotalEvents);
Assert.Equal(1, stagingModel.TotalEvents);
}
}
@@ -0,0 +1,470 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche T13-G — Tests multi-clients backend (G1-G24) : validation du comportement
/// de filtrage/agrégation du contexte de déploiement sur un jeu de données synthétique
/// représentatif du seed (DEMO-CLIENT-A / B / C, plusieurs ServerInstance, plusieurs
/// environnements, erreurs multi-clients, NULL historiques, synchronisation
/// API/Worker). Le tout sur <see cref="FakeDiagnosticEventRepository"/> (in-memory),
/// miroir du repository PostgreSQL réel.
/// </summary>
public class DiagnosticMultiClientTests
{
private const string ClientA = "DEMO-CLIENT-A";
private const string ClientB = "DEMO-CLIENT-B";
private const string ClientC = "DEMO-CLIENT-C";
private const string ApiA = "API-A-01";
private const string WkrA = "WORKER-A-01";
private const string ApiB = "API-B-01";
private const string WkrB = "WORKER-B-01";
private const string ApiBStg = "API-B-STG-01";
private const string ApiCDev = "API-C-DEV-01";
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset To = From.AddDays(30);
/// <summary>Fabrique un enregistrement avec contexte, version et composant.</summary>
private static DiagnosticEventRecord Ev(
long id,
string eventCode,
string? client,
string? server,
string? environment,
DateTimeOffset? ts = null,
string level = "Info",
string? component = null,
string? errorCode = null,
string? exceptionType = null,
string? appVersion = null,
string? platform = null,
Guid? operationId = null)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(
Guid.NewGuid(), eventCode, ts ?? From, component: component, level: level, errorCode: errorCode);
record.Id = id;
record.ClientCode = client;
record.ServerInstance = server;
record.Environment = environment;
record.ExceptionType = exceptionType;
record.AppVersion = appVersion;
record.Platform = platform;
record.OperationId = operationId;
return record;
}
/// <summary>
/// Insère le jeu de données multi-clients représentatif du seed SQL (T13-G) :
/// volumes décroissants A &gt; B &gt; C, erreur partagée A+B, erreur spécifique C,
/// environnements Production/Staging/Development, NULL historiques, opérations de
/// synchronisation API/Worker, versions par client.
/// </summary>
private static async Task<Guid> InsertSeedAsync(FakeDiagnosticEventRepository repo)
{
Guid opA = Guid.NewGuid();
Guid opB = Guid.NewGuid();
await repo.InsertBatchAsync(new[]
{
// --- Volume de fond (A > B > C) ---
Ev(1, "OMK-API-HTTP-001", ClientA, ApiA, "Production", appVersion: "1.4.0.0", platform: "api", component: "Omika.Api"),
Ev(2, "OMK-API-HTTP-002", ClientA, ApiA, "Production", appVersion: "1.4.1.0", platform: "api", component: "Omika.Api"),
Ev(3, "OMK-WKR-SYNC-001", ClientA, WkrA, "Production", appVersion: "1.4.0.0", platform: "worker", component: "Omika.Worker"),
Ev(4, "OMK-API-HTTP-001", ClientB, ApiB, "Production", appVersion: "1.3.8.0", platform: "api", component: "Omika.Api"),
Ev(5, "OMK-WKR-SYNC-001", ClientB, WkrB, "Production", appVersion: "1.4.1.0", platform: "worker", component: "Omika.Worker"),
Ev(6, "OMK-API-HTTP-001", ClientB, ApiBStg, "Staging", appVersion: "1.4.1.0", platform: "api", component: "Omika.Api"),
Ev(7, "OMK-API-HTTP-001", ClientC, ApiCDev, "Development", appVersion: "1.5.0.0", platform: "api", component: "Omika.Api"),
// --- Erreur partagée A+B : même signature (TIMEOUT / TimeoutException / Omika.Api) ---
Ev(8, "OMK-API-HTTP-003", ClientA, ApiA, "Production", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.4.0.0", platform: "api"),
Ev(9, "OMK-API-HTTP-003", ClientA, ApiA, "Production", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.4.1.0", platform: "api"),
Ev(10, "OMK-API-HTTP-003", ClientB, ApiB, "Production", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.3.8.0", platform: "api"),
Ev(11, "OMK-API-HTTP-003", ClientB, ApiBStg, "Staging", level: "Error", component: "Omika.Api", errorCode: "TIMEOUT", exceptionType: "System.TimeoutException", appVersion: "1.4.1.0", platform: "api"),
// --- Erreur spécifique à C (CRASH / OutOfMemoryException / Omika.Api) ---
Ev(12, "OMK-API-HTTP-003", ClientC, ApiCDev, "Development", level: "Critical", component: "Omika.Api", errorCode: "CRASH", exceptionType: "System.OutOfMemoryException", appVersion: "1.5.0.0", platform: "api"),
// --- NULL historiques (sans contexte) ---
Ev(13, "OMK-MOB-APP-001", null, null, null, appVersion: "1.0.0.0", platform: "Android", component: "Omika.App"),
Ev(14, "OMK-MOB-APP-002", null, null, null, level: "Warning", appVersion: "1.1.0.0", platform: "iOS", component: "Omika.App"),
// --- Synchronisation API/Worker : même OperationId, même Client, serveurs différents ---
Ev(15, "OMK-MOB-SYNC-001", ClientA, ApiA, "Production", operationId: opA, appVersion: "1.4.1.0", platform: "Android", component: "Omika.App"),
Ev(16, "OMK-WKR-SYNC-001", ClientA, WkrA, "Production", operationId: opA, appVersion: "1.4.1.0", platform: "worker", component: "Omika.Worker"),
Ev(17, "OMK-MOB-SYNC-001", ClientB, ApiB, "Production", operationId: opB, appVersion: "1.3.8.0", platform: "iOS", component: "Omika.App"),
Ev(18, "OMK-WKR-SYNC-002", ClientB, WkrB, "Production", operationId: opB, level: "Error", errorCode: "SYNC_FAILED", appVersion: "1.4.1.0", platform: "worker", component: "Omika.Worker")
});
return opA;
}
// --------------------------- G1 : filtre Client A --------------------------
[Fact]
public async Task G1_ClientAFilter_ExcludesBAndC()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA });
Assert.NotEmpty(result.Items);
Assert.All(result.Items, e => Assert.Equal(ClientA, e.ClientCode));
Assert.DoesNotContain(result.Items, e => e.ClientCode is ClientB or ClientC);
}
// --------------------------- G2 : filtre Client B --------------------------
[Fact]
public async Task G2_ClientBFilter_ExcludesAAndC()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientB });
Assert.NotEmpty(result.Items);
Assert.All(result.Items, e => Assert.Equal(ClientB, e.ClientCode));
}
// --------------------------- G3 : Client + Environment ---------------------
[Fact]
public async Task G3_ClientBStagingFilter_ReturnsOnlyThatCross()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.QueryAsync(new DiagnosticEventQuery
{
ClientCode = ClientB,
Environment = "Staging"
});
Assert.NotEmpty(result.Items);
Assert.All(result.Items, e =>
{
Assert.Equal(ClientB, e.ClientCode);
Assert.Equal("Staging", e.Environment);
});
}
// --------------------------- G4 : Client + Server --------------------------
[Fact]
public async Task G4_ClientAWorkerFilter_ReturnsOnlyWorkerEvents()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.QueryAsync(new DiagnosticEventQuery
{
ClientCode = ClientA,
ServerInstance = WkrA
});
Assert.NotEmpty(result.Items);
Assert.All(result.Items, e =>
{
Assert.Equal(ClientA, e.ClientCode);
Assert.Equal(WkrA, e.ServerInstance);
Assert.Equal("Omika.Worker", e.Component);
});
}
// --------------------------- G5 : trois filtres combinés -------------------
[Fact]
public async Task G5_ThreeFiltersCombined_ReturnOnlyMatchingEvents()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.QueryAsync(new DiagnosticEventQuery
{
ClientCode = ClientB,
ServerInstance = ApiBStg,
Environment = "Staging"
});
Assert.NotEmpty(result.Items);
Assert.All(result.Items, e =>
{
Assert.Equal(ClientB, e.ClientCode);
Assert.Equal(ApiBStg, e.ServerInstance);
Assert.Equal("Staging", e.Environment);
});
}
// --------------------------- G6 : NULL historiques -------------------------
[Fact]
public async Task G6_HistoricalNull_VisibleWithoutFilter_ExcludedByClientFilter()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
// Sans filtre : les NULL sont visibles.
DiagnosticQueryResult<DiagnosticEventRecord> all = await repo.QueryAsync(new DiagnosticEventQuery());
Assert.Contains(all.Items, e => e.ClientCode is null && e.ServerInstance is null && e.Environment is null);
// Avec un filtre Client : les NULL ne sont PAS inclus par erreur.
DiagnosticQueryResult<DiagnosticEventRecord> aOnly =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA });
Assert.DoesNotContain(aOnly.Items, e => e.ClientCode is null);
}
// --------------------------- G7/G8/G9 : groupes d'erreurs multi-clients ----
[Fact]
public async Task G7_G8_G9_SharedErrorGroup_CountsPerClient()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticErrorGroupQuery allQuery = new() { FromUtc = From, ToUtc = To, ErrorsOnly = true };
IReadOnlyList<DiagnosticErrorGroup> all = await repo.GroupErrorsAsync(allQuery);
// G7 : sans filtre, le groupe TIMEOUT agrège A + B (4 occurrences).
DiagnosticErrorGroup timeoutGroup = Assert.Single(all, g => g.ErrorCode == "TIMEOUT");
Assert.Equal(4, timeoutGroup.OccurrenceCount);
// G8 : filtre A → mêmes groupe, occurrences = A uniquement (2).
IReadOnlyList<DiagnosticErrorGroup> aGroups = await repo.GroupErrorsAsync(new DiagnosticErrorGroupQuery
{
FromUtc = From, ToUtc = To, ErrorsOnly = true, ClientCode = ClientA
});
DiagnosticErrorGroup aTimeout = Assert.Single(aGroups, g => g.ErrorCode == "TIMEOUT");
Assert.Equal(2, aTimeout.OccurrenceCount);
// G9 : filtre B → occurrences = B uniquement (2).
IReadOnlyList<DiagnosticErrorGroup> bGroups = await repo.GroupErrorsAsync(new DiagnosticErrorGroupQuery
{
FromUtc = From, ToUtc = To, ErrorsOnly = true, ClientCode = ClientB
});
DiagnosticErrorGroup bTimeout = Assert.Single(bGroups, g => g.ErrorCode == "TIMEOUT");
Assert.Equal(2, bTimeout.OccurrenceCount);
}
// --------------------------- G10 : erreur spécifique C ---------------------
[Fact]
public async Task G10_CrashErrorGroup_IsAbsentForClientA()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
IReadOnlyList<DiagnosticErrorGroup> aGroups = await repo.GroupErrorsAsync(new DiagnosticErrorGroupQuery
{
FromUtc = From, ToUtc = To, ErrorsOnly = true, ClientCode = ClientA
});
Assert.DoesNotContain(aGroups, g => g.ErrorCode == "CRASH");
}
// --------------------------- G11/G12/G13 : environnements ------------------
[Fact]
public async Task G11_ProductionEnvironment_ExcludesStagingAndDevelopment()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Production" });
Assert.NotEmpty(result.Items);
Assert.All(result.Items, e => Assert.Equal("Production", e.Environment));
Assert.DoesNotContain(result.Items, e => e.Environment is "Staging" or "Development");
}
[Fact]
public async Task G12_StagingEnvironment_ExcludesProduction()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Staging" });
Assert.NotEmpty(result.Items);
Assert.All(result.Items, e => Assert.Equal("Staging", e.Environment));
}
[Fact]
public async Task G13_UnknownEnvironment_IsTechnicallySupported()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
// Aucune énumération fermée : une valeur inconnue est acceptée et retourne vide.
DiagnosticQueryResult<DiagnosticEventRecord> result =
await repo.QueryAsync(new DiagnosticEventQuery { Environment = "Custom-Test" });
Assert.Empty(result.Items);
Assert.Equal(0, result.TotalCount);
}
// --------------------------- G14/G15 : serveurs ----------------------------
[Fact]
public async Task G14_ApiAndWorkerServers_AreSeparableWithinSameClient()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> api =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, ServerInstance = ApiA });
DiagnosticQueryResult<DiagnosticEventRecord> worker =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, ServerInstance = WkrA });
Assert.NotEmpty(api.Items);
Assert.NotEmpty(worker.Items);
Assert.All(api.Items, e => Assert.Equal(ApiA, e.ServerInstance));
Assert.All(worker.Items, e => Assert.Equal(WkrA, e.ServerInstance));
}
[Fact]
public async Task G15_ServersAreNotSharedBetweenClients()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
// Aucun serveur ne doit être partagé entre deux clients.
string[] aServers = { ApiA, WkrA };
string[] bServers = { ApiB, WkrB, ApiBStg };
string[] cServers = { ApiCDev };
DiagnosticQueryResult<DiagnosticEventRecord> a =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientA, PageSize = 100 });
Assert.All(a.Items, e => Assert.Contains(e.ServerInstance, aServers));
Assert.DoesNotContain(a.Items, e => bServers.Contains(e.ServerInstance) || cServers.Contains(e.ServerInstance));
DiagnosticQueryResult<DiagnosticEventRecord> b =
await repo.QueryAsync(new DiagnosticEventQuery { ClientCode = ClientB, PageSize = 100 });
Assert.All(b.Items, e => Assert.Contains(e.ServerInstance, bServers));
Assert.DoesNotContain(b.Items, e => aServers.Contains(e.ServerInstance) || cServers.Contains(e.ServerInstance));
}
// --------------------------- G16/G17/G18 : synchronisation -----------------
[Fact]
public async Task G16_SharedOperationId_KeepsClientEnvAndDistinctServers()
{
var repo = new FakeDiagnosticEventRepository();
Guid opA = await InsertSeedAsync(repo);
IReadOnlyList<DiagnosticEventRecord> events = await repo.GetByOperationIdAsync(opA);
Assert.Equal(2, events.Count);
DiagnosticEventRecord api = Assert.Single(events, e => e.Component == "Omika.App");
DiagnosticEventRecord worker = Assert.Single(events, e => e.Component == "Omika.Worker");
Assert.Equal(ClientA, api.ClientCode);
Assert.Equal(ClientA, worker.ClientCode);
Assert.Equal("Production", api.Environment);
Assert.Equal("Production", worker.Environment);
Assert.Equal(opA, api.OperationId);
Assert.Equal(opA, worker.OperationId);
Assert.Equal(ApiA, api.ServerInstance);
Assert.Equal(WkrA, worker.ServerInstance);
Assert.NotEqual(api.ServerInstance, worker.ServerInstance);
Assert.NotEqual(api.Component, worker.Component);
}
[Fact]
public async Task G17_ClientFilter_KeepsAllEventsOfSameClientOperation()
{
var repo = new FakeDiagnosticEventRepository();
Guid opA = await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.QueryAsync(new DiagnosticEventQuery
{
ClientCode = ClientA,
SyncOnly = true
});
// Les deux composants de l'opération A (mobile + worker) sont conservés.
Assert.Contains(result.Items, e => e.OperationId == opA && e.Component == "Omika.App");
Assert.Contains(result.Items, e => e.OperationId == opA && e.Component == "Omika.Worker");
}
[Fact]
public async Task G18_ClientFilter_ExcludesOtherClientOperation()
{
var repo = new FakeDiagnosticEventRepository();
Guid opA = await InsertSeedAsync(repo);
DiagnosticQueryResult<DiagnosticEventRecord> bSync = await repo.QueryAsync(new DiagnosticEventQuery
{
ClientCode = ClientB,
SyncOnly = true
});
Assert.NotEmpty(bSync.Items);
Assert.DoesNotContain(bSync.Items, e => e.OperationId == opA);
Assert.All(bSync.Items, e => Assert.Equal(ClientB, e.ClientCode));
}
// --------------------------- G19/G20 : versions ----------------------------
[Fact]
public async Task G19_ClientAFilter_ReturnsOnlyClientAVersions()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
IReadOnlyList<DiagnosticVersionSummary> summaries = await repo.GetVersionSummariesAsync(
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ClientCode = ClientA });
Assert.NotEmpty(summaries);
Assert.All(summaries, s => Assert.Contains(s.AppVersion, new[] { "1.4.0.0", "1.4.1.0" }));
Assert.DoesNotContain(summaries, s => s.AppVersion is "1.3.8.0" or "1.5.0.0");
}
[Fact]
public async Task G20_ClientBStagingFilter_ReducesVersionSet()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
IReadOnlyList<DiagnosticVersionSummary> summaries = await repo.GetVersionSummariesAsync(
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, ClientCode = ClientB, Environment = "Staging" });
// Seul 1.4.1.0 est observé chez B en Staging (1.3.8.0 est en Production uniquement).
DiagnosticVersionSummary summary = Assert.Single(summaries);
Assert.Equal("1.4.1.0", summary.AppVersion);
}
// --------------------------- G21/G22/G23 : options distinctes --------------
[Fact]
public async Task G21_G22_G23_DistinctOptions_AreCompleteAndUnique()
{
var repo = new FakeDiagnosticEventRepository();
await InsertSeedAsync(repo);
IReadOnlyList<string> clients = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ClientCode, From, DiagnosticFilterDefaults.ClientCodeOptionsLimit);
IReadOnlyList<string> servers = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.ServerInstance, From, DiagnosticFilterDefaults.ServerInstanceOptionsLimit);
IReadOnlyList<string> environments = await repo.GetFilterOptionsAsync(
DiagnosticFilterDimension.Environment, From, DiagnosticFilterDefaults.EnvironmentOptionsLimit);
// G21 : trois clients, sans doublon.
Assert.Equal(new[] { ClientA, ClientB, ClientC }, clients);
// G22 : les instances prévues, sans doublon (tri ordinal).
Assert.Equal(new[] { ApiA, ApiB, ApiBStg, ApiCDev, WkrA, WkrB }, servers);
// G23 : les environnements seed utilisés.
Assert.Equal(new[] { "Development", "Production", "Staging" }, environments);
}
// --------------------------- G24 : limites bornées -------------------------
[Fact]
public void G24_DimensionLimits_RemainBounded()
{
// Les limites de T13-F restent bornées et respectent la borne commune.
Assert.InRange(DiagnosticFilterDefaults.ClientCodeOptionsLimit, 1, DiagnosticFilterDefaults.MaxOptionsLimit);
Assert.InRange(DiagnosticFilterDefaults.ServerInstanceOptionsLimit, 1, DiagnosticFilterDefaults.MaxOptionsLimit);
Assert.InRange(DiagnosticFilterDefaults.EnvironmentOptionsLimit, 1, DiagnosticFilterDefaults.MaxOptionsLimit);
Assert.Equal(DiagnosticFilterDefaults.ClientCodeOptionsLimit, DiagnosticFilterDefaults.LimitFor(DiagnosticFilterDimension.ClientCode));
Assert.Equal(DiagnosticFilterDefaults.ServerInstanceOptionsLimit, DiagnosticFilterDefaults.LimitFor(DiagnosticFilterDimension.ServerInstance));
Assert.Equal(DiagnosticFilterDefaults.EnvironmentOptionsLimit, DiagnosticFilterDefaults.LimitFor(DiagnosticFilterDimension.Environment));
}
}
@@ -0,0 +1,156 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche T14-C — Normalisation des dimensions d'agrégation (§9/§10) : les variantes de
/// casse/espaces de <c>component</c> et <c>error_code</c> ne doivent plus produire de
/// dimensions distinctes dans les statistiques. Aucune donnée persistée n'est modifiée :
/// seule la clé d'agrégation est normalisée (déterministe, côté requête).
/// </summary>
public class DiagnosticNormalizationTests
{
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? component = null, string? errorCode = null)
{
return new DiagnosticEventRecord
{
EventId = Guid.NewGuid(),
EventCode = "TEST",
TimestampUtc = From.AddMinutes(1),
Component = component,
ErrorCode = errorCode,
Level = "Error",
Priority = "P1",
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
}
// -------------------------------------------------- T14-C Test 8 : Component --
[Fact]
public async Task Component_NormalizesCaseAndWhitespace()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(component: "Omika.Api"),
Ev(component: "OMIKA.API"),
Ev(component: "omika.api"),
Ev(component: " Omika.Api "),
Ev(component: "Omika.Worker")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.Component,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
// 4 variantes du même composant + 1 autre = 2 dimensions logiques.
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "Omika.Api" && g.Count == 4);
Assert.Contains(result, g => g.Key == "Omika.Worker" && g.Count == 1);
}
// -------------------------------------------------- T14-C Test 9 : ErrorCode --
[Fact]
public async Task ErrorCode_NormalizesCaseAndWhitespace()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(errorCode: "timeout"),
Ev(errorCode: "TIMEOUT"),
Ev(errorCode: "Timeout"),
Ev(errorCode: " timeout "),
Ev(errorCode: "CRASH")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.ErrorCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
// 4 variantes du même code + 1 autre = 2 codes logiques.
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "TIMEOUT" && g.Count == 4);
Assert.Contains(result, g => g.Key == "CRASH" && g.Count == 1);
}
// ------------------------- Null/vide restent un groupe distinct (compatibilité) --
[Fact]
public async Task ErrorCode_NullAndEmpty_RemainGroupedAsNull()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(errorCode: null),
Ev(errorCode: " "),
Ev(errorCode: "TIMEOUT")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.ErrorCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To });
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key is null && g.Count == 2);
Assert.Contains(result, g => g.Key == "TIMEOUT" && g.Count == 1);
}
// ------------------------- T20 §7 : « Codes d'erreur principaux » ----
[Fact]
public async Task ErrorCode_WithHasErrorCode_ExcludesNullAndBlank()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(errorCode: null),
Ev(errorCode: " "),
Ev(errorCode: ""),
Ev(errorCode: "TIMEOUT"),
Ev(errorCode: "CRASH"),
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.ErrorCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, HasErrorCode = true });
// Seuls les ErrorCode réels comptent : jamais de groupe « Non renseigné ».
Assert.Equal(2, result.Count);
Assert.DoesNotContain(result, g => g.Key is null);
Assert.Contains(result, g => g.Key == "TIMEOUT" && g.Count == 1);
Assert.Contains(result, g => g.Key == "CRASH" && g.Count == 1);
}
// --------------------- Équivalent C# documenté (unité, sans repository) --------
[Theory]
[InlineData("Omika.Api", "Omika.Api")]
[InlineData("OMIKA.API", "Omika.Api")]
[InlineData("omika.api", "Omika.Api")]
[InlineData(" omika.api ", "Omika.Api")]
[InlineData("Omika.Diagnostics.Worker", "Omika.Diagnostics.Worker")]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
public void ComponentHelper_NormalizesDeterministically(string? input, string? expected)
{
Assert.Equal(expected, DiagnosticDimensionNormalization.Component(input));
}
[Theory]
[InlineData("timeout", "TIMEOUT")]
[InlineData("TIMEOUT", "TIMEOUT")]
[InlineData("Timeout", "TIMEOUT")]
[InlineData(" timeout ", "TIMEOUT")]
[InlineData("UnhandledException", "UNHANDLEDEXCEPTION")]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
public void ErrorCodeHelper_NormalizesDeterministically(string? input, string? expected)
{
Assert.Equal(expected, DiagnosticDimensionNormalization.ErrorCode(input));
}
}
@@ -0,0 +1,221 @@
using Microsoft.Extensions.Logging.Abstractions;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 13-F — Tests des PageModels (Diagnostics / Erreurs / Traces) : transmission
/// des filtres de contexte (F11/F17), chargement des options dynamiques (F12),
/// conservation de la sélection (F13), pagination (F14), détail (F15), libellé NULL
/// historique (F16) et non-confusion du serveur producteur (§38). Les PageModels
/// n'accèdent pas à HttpContext pour ces chemins (aucun harnais Web requis).
/// </summary>
public class DiagnosticPageModelTests
{
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static DiagnosticEventRecord Ev(
long id,
string eventCode = "TEST",
string? clientCode = null,
string? serverInstance = null,
string? environment = null,
DateTimeOffset? ts = null,
string level = "Info",
Guid? operationId = null,
string? component = null)
{
DiagnosticEventRecord record = DiagnosticTestData.Event(Guid.NewGuid(), eventCode, ts ?? From, component: component, level: level);
record.Id = id;
record.ClientCode = clientCode;
record.ServerInstance = serverInstance;
record.Environment = environment;
record.OperationId = operationId;
return record;
}
// --------------------------- F11 : DiagnosticsModel transmet les filtres -----
[Fact]
public async Task F11_DiagnosticsModel_PassesDeploymentFiltersToQuery()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(1, clientCode: "CLIENT-A", serverInstance: "API-SRV-01", environment: "Production"),
Ev(2, clientCode: "CLIENT-B", serverInstance: "WKR-SRV-01", environment: "Staging")
});
var model = new DiagnosticsModel(repo)
{
ClientCode = "CLIENT-A",
ServerInstance = "API-SRV-01",
Environment = "Production"
};
await model.OnGetAsync();
DiagnosticEventRecord record = Assert.Single(model.Items);
Assert.Equal("CLIENT-A", record.ClientCode);
Assert.Equal("API-SRV-01", record.ServerInstance);
Assert.Equal("Production", record.Environment);
}
// --------------------------- F12 : options dynamiques chargées ---------------
[Fact]
public async Task F12_DiagnosticsModel_LoadsDeploymentFilterOptions()
{
var repo = new FakeDiagnosticEventRepository();
// Les options de filtre sont bornées par une fenêtre glissante (60 jours) :
// les événements doivent être RÉCENTS pour être observés (cf. OptionsWindowDays).
await repo.InsertBatchAsync(new[]
{
Ev(1, clientCode: "CLIENT-A", serverInstance: "API-SRV-01", environment: "Production", ts: DateTimeOffset.UtcNow.AddMinutes(-5)),
Ev(2, clientCode: "CLIENT-B", serverInstance: "WKR-SRV-01", environment: "Staging", ts: DateTimeOffset.UtcNow.AddMinutes(-4))
});
var model = new DiagnosticsModel(repo);
await model.OnGetAsync();
Assert.Contains("CLIENT-A", model.ClientOptions);
Assert.Contains("CLIENT-B", model.ClientOptions);
Assert.Contains("API-SRV-01", model.ServerOptions);
Assert.Contains("WKR-SRV-01", model.ServerOptions);
Assert.Contains("Production", model.EnvironmentOptions);
Assert.Contains("Staging", model.EnvironmentOptions);
}
// --------------------------- F13 : sélection conservée -----------------------
[Fact]
public async Task F13_DiagnosticsModel_SelectedValueRemainsSelected()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[] { Ev(1, clientCode: "CLIENT-A", serverInstance: "SRV-1", environment: "Production") });
var model = new DiagnosticsModel(repo) { ClientCode = "CLIENT-Z", ServerInstance = "SRV-Z", Environment = "Custom" };
await model.OnGetAsync();
// La valeur active reste la valeur du modèle (jamais réécrite)…
Assert.Equal("CLIENT-Z", model.ClientCode);
Assert.Equal("SRV-Z", model.ServerInstance);
Assert.Equal("Custom", model.Environment);
// …et elle est injectée en tête des options (pagination/conservation §24).
Assert.Equal("CLIENT-Z", model.ClientOptions[0]);
Assert.Equal("SRV-Z", model.ServerOptions[0]);
Assert.Equal("Custom", model.EnvironmentOptions[0]);
Assert.True(DiagnosticsModel.IsSelected("CLIENT-Z", model.ClientCode));
}
// --------------------------- F14 : pagination conserve les filtres ----------
[Fact]
public void F14_DiagnosticsModel_RoutePreservesDeploymentFilters()
{
var model = new DiagnosticsModel(new FakeDiagnosticEventRepository())
{
ClientCode = "CLIENT-A",
ServerInstance = "WKR-SRV-01",
Environment = "Production"
};
Dictionary<string, string> route = model.Route(2);
Assert.Equal("2", route["pageNumber"]);
Assert.Equal("CLIENT-A", route["clientCode"]);
Assert.Equal("WKR-SRV-01", route["serverInstance"]);
Assert.Equal("Production", route["environment"]);
}
// --------------------------- F15 : détail affiche les trois valeurs ---------
[Fact]
public async Task F15_DiagnosticsDetail_LoadsDeploymentContext()
{
var repo = new FakeDiagnosticEventRepository();
DiagnosticEventRecord record = Ev(42, clientCode: "CLIENT-A", serverInstance: "WKR-SRV-01", environment: "Production");
await repo.InsertBatchAsync(new[] { record });
var model = new DiagnosticsDetailModel(repo);
Microsoft.AspNetCore.Mvc.IActionResult result = await model.OnGetAsync(42);
Assert.IsType<Microsoft.AspNetCore.Mvc.RazorPages.PageResult>(result);
Assert.NotNull(model.Event);
Assert.Equal("CLIENT-A", model.Event!.ClientCode);
Assert.Equal("WKR-SRV-01", model.Event.ServerInstance);
Assert.Equal("Production", model.Event.Environment);
}
// --------------------------- F16 : NULL historique → « Non renseigné » -------
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void F16_NullContext_RendersNonRenseigne(string? value)
{
Assert.Equal("Non renseigné", UiDeploymentContext.Value(value));
}
[Fact]
public void F16_NonNullContext_IsPreserved()
{
Assert.Equal("CLIENT-A", UiDeploymentContext.Value("CLIENT-A"));
}
// --------------------------- F17/F18 : ErreursModel transmet + pré-agrégation -
[Fact]
public async Task F17_F18_ErreursModel_AppliesFiltersBeforeAggregation()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, "ERR", "CLIENT-A", "SRV-1", "Production", now.AddMinutes(-5), level: "Error", component: "Omika.Api"),
Ev(2, "ERR", "CLIENT-A", "SRV-1", "Production", now.AddMinutes(-4), level: "Error", component: "Omika.Api"),
Ev(3, "ERR", "CLIENT-B", "SRV-1", "Staging", now.AddMinutes(-3), level: "Error", component: "Omika.Api"),
Ev(4, "ERR", "CLIENT-A", "SRV-1", "Staging", now.AddMinutes(-2), level: "Error", component: "Omika.Api")
});
// Sans filtre : un seul groupe logique, 4 occurrences.
var allModel = new ErreursModel(repo, new FakeDiagnosticTaskRepository(), NullLogger<ErreursModel>.Instance);
await allModel.OnGetAsync();
Omika.Diagnostics.Queries.DiagnosticErrorGroup allGroup = Assert.Single(allModel.Groups);
Assert.Equal(4, allGroup.OccurrenceCount);
// Avec filtre Client + Environnement : le regroupement est recalculé sur le périmètre.
var filteredModel = new ErreursModel(repo, new FakeDiagnosticTaskRepository(), NullLogger<ErreursModel>.Instance)
{
ClientCode = "CLIENT-A",
Environment = "Production"
};
await filteredModel.OnGetAsync();
Omika.Diagnostics.Queries.DiagnosticErrorGroup filteredGroup = Assert.Single(filteredModel.Groups);
Assert.Equal(2, filteredGroup.OccurrenceCount);
}
// --------------------------- §38 : Traces sans confusion serveur -------------
[Fact]
public async Task Section38_Traces_KeepProducerServerInstanceDistinct()
{
var repo = new FakeDiagnosticEventRepository();
Guid operationId = Guid.NewGuid();
await repo.InsertBatchAsync(new[]
{
Ev(1, "OMK-API-HTTP-001", "CLIENT-A", "API-SRV-01", "Production", operationId: operationId, component: "Omika.Api"),
Ev(2, "OMK-WKR-SYNC-001", "CLIENT-A", "WKR-SRV-01", "Production", operationId: operationId, component: "Omika.Worker")
});
var model = new TracesModel(repo) { OperationId = operationId.ToString() };
await model.OnGetAsync();
Assert.Equal(2, model.Timeline.Count);
DiagnosticEventRecord api = model.Timeline.Single(e => e.Component == "Omika.Api");
DiagnosticEventRecord worker = model.Timeline.Single(e => e.Component == "Omika.Worker");
// Le serveur affiché est le serveur PRODUCTEUR de chaque événement, jamais mélangé.
Assert.Equal("API-SRV-01", api.ServerInstance);
Assert.Equal("WKR-SRV-01", worker.ServerInstance);
Assert.Equal("CLIENT-A", worker.ClientCode);
Assert.Equal("Production", worker.Environment);
}
}
@@ -0,0 +1,44 @@
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>§56 — Pagination : page size borné, offset calculé, tri timestamp, jamais de requête non bornée.</summary>
public class DiagnosticPaginationTests
{
[Fact]
public void Query_ClampsPageSizeAndPage()
{
var tooLarge = new DiagnosticEventQuery { PageSize = 100_000, Page = 0 };
Assert.Equal(DiagnosticPaging.MaxPageSize, tooLarge.PageSize);
Assert.Equal(1, tooLarge.Page);
Assert.Equal(0, tooLarge.Offset);
var tooSmall = new DiagnosticEventQuery { PageSize = 0 };
Assert.Equal(1, tooSmall.PageSize);
var page3 = new DiagnosticEventQuery { Page = 3, PageSize = 50 };
Assert.Equal(100, page3.Offset);
}
[Fact]
public async Task Repository_AppliesBoundedPagination()
{
var repository = new FakeDiagnosticEventRepository();
DateTimeOffset baseTime = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
for (int i = 0; i < 10; i++)
{
await repository.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "E" + i, baseTime.AddSeconds(i))
});
}
var page = await repository.QueryAsync(new DiagnosticEventQuery { Page = 2, PageSize = 3 });
Assert.Equal(10, page.TotalCount);
Assert.Equal(4, page.TotalPages);
Assert.Equal(3, page.Items.Count);
}
}
@@ -0,0 +1,187 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T17-E — PageModel /Parc : vue générale, répartitions, liste appareils, filtres,
/// pagination, drill-down et réattribution de licence. Testés sans HttpContext
/// (constructeur repository uniquement, comme les autres PageModels).
/// </summary>
public class DiagnosticParcModelTests
{
private static DiagnosticEventRecord Ev(
long id,
string deviceKey,
DateTimeOffset ts,
string? client = null,
string? license = null,
string? platform = "Android",
string? os = "Android",
string? osVersion = "15",
string? manufacturer = null,
string? model = null,
string? appVersion = null)
{
DiagnosticEventRecord e = DiagnosticTestData.Event(Guid.NewGuid(), timestamp: ts);
e.Id = id;
e.DeviceKey = deviceKey;
e.ClientCode = client;
e.LicenseNumber = license;
e.Platform = platform;
e.OperatingSystem = os;
e.OperatingSystemVersion = osVersion;
e.DeviceManufacturer = manufacturer;
e.DeviceModel = model;
e.AppVersion = appVersion;
return e;
}
// ------------------------------- vue générale + répartitions ----
[Fact]
public async Task ParcModel_LoadsOverviewAndDistributions()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, "d1", now.AddMinutes(-60), platform: "Android", osVersion: "16", manufacturer: "Xiaomi"),
Ev(2, "d2", now.AddMinutes(-50), platform: "iOS", os: "iOS", osVersion: "18.5", manufacturer: "Apple"),
Ev(3, "d3", now.AddMinutes(-40), platform: "Android", osVersion: "15", manufacturer: "Samsung")
});
var model = new ParcModel(repo);
await model.OnGetAsync();
Assert.True(model.PostgreSqlAvailable);
Assert.True(model.HasData);
Assert.Equal(3, model.ActiveDevices);
Assert.Equal(2, model.AndroidDevices);
Assert.Equal(1, model.IosDevices);
Assert.Equal(0, model.DesktopDevices);
Assert.Equal(3, model.Devices.Count);
Assert.NotEmpty(model.ManufacturerDistribution);
Assert.NotEmpty(model.OsVersionDistribution);
}
// ------------------------------- réattribution de licence ----
[Fact]
public async Task ParcModel_LicenseReassigned_ShowsTwoDevices()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, "dev-a", now.AddDays(-20), license: "LIC-001", manufacturer: "Samsung", model: "SM-DEMO"),
Ev(2, "dev-b", now.AddDays(-1), license: "LIC-001", manufacturer: "Apple", model: "iPhoneDemo", os: "iOS", osVersion: "18")
});
var model = new ParcModel(repo);
await model.OnGetAsync();
Assert.Equal(2, model.ActiveDevices);
Assert.Equal(2, model.Devices.Count);
Assert.Contains(model.Devices, d => d.DeviceKey == "dev-a");
Assert.Contains(model.Devices, d => d.DeviceKey == "dev-b");
// « Appareils observés par licence » : LIC-001 → 2 appareils distincts, jamais 1.
Omika.Diagnostics.Queries.DeviceDistribution lic = Assert.Single(model.LicenseDistribution);
Assert.Equal("LIC-001", lic.Key);
Assert.Equal(2, lic.DeviceCount);
}
// ------------------------------- filtre licence ----
[Fact]
public async Task ParcModel_LicenseFilter_ReturnsOnlyMatchingDevices()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, "dev-a", now.AddMinutes(-30), license: "LIC-A"),
Ev(2, "dev-b", now.AddMinutes(-20), license: "LIC-A"),
Ev(3, "dev-c", now.AddMinutes(-10), license: "LIC-B")
});
var model = new ParcModel(repo) { LicenseNumber = "LIC-A" };
await model.OnGetAsync();
Assert.Equal(2, model.ActiveDevices);
Assert.All(model.Devices, d => Assert.Equal("LIC-A", d.LicenseNumber));
}
// ------------------------------- filtre client + plateforme ----
[Fact]
public async Task ParcModel_ClientAndPlatformFilters_Combine()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, "d1", now.AddMinutes(-60), client: "CLIENT-A", platform: "Android"),
Ev(2, "d2", now.AddMinutes(-50), client: "CLIENT-A", platform: "iOS", os: "iOS"),
Ev(3, "d3", now.AddMinutes(-40), client: "CLIENT-B", platform: "Android")
});
var model = new ParcModel(repo) { ClientCode = "CLIENT-A", Platform = "Android" };
await model.OnGetAsync();
Assert.Equal(1, model.ActiveDevices);
Omika.Diagnostics.Queries.DeviceFleetRecord device = Assert.Single(model.Devices);
Assert.Equal("d1", device.DeviceKey);
}
// ------------------------------- pagination / Route ----
[Fact]
public void ParcModel_Route_PreservesFilters()
{
var model = new ParcModel(new FakeDiagnosticEventRepository())
{
ClientCode = "CLIENT-A",
LicenseNumber = "LIC-A",
Platform = "Android",
Manufacturer = "Samsung"
};
Dictionary<string, string> route = model.Route(2);
Assert.Equal("2", route["pageNumber"]);
Assert.Equal("CLIENT-A", route["clientCode"]);
Assert.Equal("LIC-A", route["licenseNumber"]);
Assert.Equal("Android", route["platform"]);
Assert.Equal("Samsung", route["manufacturer"]);
}
// ------------------------------- zéro appareil ----
[Fact]
public async Task ParcModel_NoDevices_HasDataFalse()
{
var repo = new FakeDiagnosticEventRepository();
var model = new ParcModel(repo);
await model.OnGetAsync();
Assert.False(model.HasData);
Assert.Equal(0, model.ActiveDevices);
Assert.Empty(model.Devices);
}
// ------------------------------- pourcentage formaté ----
[Fact]
public async Task ParcModel_FormatPercentage_NoNaN()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(new[]
{
Ev(1, "d1", now.AddMinutes(-60), platform: "Android"),
Ev(2, "d2", now.AddMinutes(-50), platform: "iOS", os: "iOS")
});
var model = new ParcModel(repo);
await model.OnGetAsync();
Assert.Equal(2, model.ActiveDevices);
Assert.Equal("50 %", model.FormatPercentage(1));
}
}
@@ -0,0 +1,351 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Prompt;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T24-A Partie A — génération LOCALE du prompt IA. Aucun appel IA externe : le builder
/// transforme un diagnostic en texte lisible. Tests : précision /dev/throw, corrélation
/// (TraceId/OperationId), données NULL, bornes, et sécurité (0 fuite de valeurs interdites,
/// réutilisation de <see cref="Omika.Core.Observability.Outbox.DiagnosticForbiddenData"/>).
/// </summary>
public class DiagnosticPromptBuilderTests
{
private static readonly DateTimeOffset Base = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
private static DiagnosticEventRecord Ev(
string eventCode = "TEST",
string level = "Error",
string priority = "P2",
string? errorCode = null,
string? message = null,
string? exceptionType = null,
string? stackTrace = null,
Guid? traceId = null,
Guid? operationId = null,
Guid? sessionId = null,
string? actorKey = null,
string? clientCode = null,
string? environment = null,
string? serverInstance = null,
string? source = null,
string? component = null,
string? platform = null,
string? operatingSystem = null,
string? operatingSystemVersion = null,
string? manufacturer = null,
string? model = null,
string? appVersion = null,
string? appBuild = null,
string? screenCode = null,
string? featureCode = null,
string? licenseNumber = null,
string? deviceKey = null,
long? durationMs = null,
string? metadataJson = null) =>
new()
{
Id = 1,
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = Base,
Level = level,
Priority = priority,
ErrorCode = errorCode,
Message = message,
ExceptionType = exceptionType,
StackTrace = stackTrace,
TraceId = traceId,
OperationId = operationId,
SessionId = sessionId,
ActorKey = actorKey,
ClientCode = clientCode,
Environment = environment,
ServerInstance = serverInstance,
Source = source ?? DiagnosticSources.Mobile,
Component = component,
Platform = platform,
OperatingSystem = operatingSystem,
OperatingSystemVersion = operatingSystemVersion,
DeviceManufacturer = manufacturer,
DeviceModel = model,
AppVersion = appVersion,
AppBuild = appBuild,
ScreenCode = screenCode,
FeatureCode = featureCode,
LicenseNumber = licenseNumber,
DeviceKey = deviceKey,
DurationMs = durationMs,
MetadataJson = metadataJson,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticEventRecord Correlated(long id, DateTimeOffset ts, string eventCode, string level, string component, string message) =>
new()
{
Id = id,
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts,
Level = level,
Priority = "P2",
Component = component,
Message = message,
Source = DiagnosticSources.Api,
IngestedAtUtc = DateTimeOffset.UtcNow
};
// ------------------------------------------- A : /dev/throw (volontaire/test) ----
[Fact]
public void DevThrow_PromptContainsEnoughContextToInferVoluntaryTest()
{
DiagnosticEventRecord ev = Ev(
eventCode: "API_HTTP_ERROR",
errorCode: "TELEMETRY_TEST_ERROR",
exceptionType: "InvalidOperationException",
environment: "Development",
serverInstance: "API-A-01",
source: DiagnosticSources.Api,
component: "Omika.Api",
metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
// Aucune règle codée en dur « /dev/throw » : le résultat vient des données.
Assert.Contains("API_HTTP_ERROR", prompt, StringComparison.Ordinal);
Assert.Contains("InvalidOperationException", prompt, StringComparison.Ordinal);
Assert.Contains("/dev/throw", prompt, StringComparison.Ordinal);
Assert.Contains("TELEMETRY_TEST_ERROR", prompt, StringComparison.Ordinal);
Assert.Contains("Development", prompt, StringComparison.Ordinal);
Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- B : TraceId + corrélés ----
[Fact]
public void TraceId_PromptIncludesCorrelatedSection()
{
Guid traceId = Guid.NewGuid();
DiagnosticEventRecord ev = Ev(traceId: traceId, eventCode: "ERR", exceptionType: "System.Exception");
var correlated = new[]
{
Correlated(1, Base.AddMinutes(-1), "OMK-API-HTTP-001", "Info", "Omika.Api", "api request"),
Correlated(2, Base, "ERR", "Error", "Omika.Api", "boom")
};
string prompt = DiagnosticPromptBuilder.Build(ev, correlated);
Assert.Contains(traceId.ToString("D"), prompt, StringComparison.Ordinal);
Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
Assert.Contains("OMK-API-HTTP-001", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- C : OperationId ----
[Fact]
public void OperationId_PromptIncludesOperationId()
{
Guid operationId = Guid.NewGuid();
DiagnosticEventRecord ev = Ev(operationId: operationId);
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains(operationId.ToString("D"), prompt, StringComparison.Ordinal);
}
// ------------------------------------------- D : mobile ScreenCode/DeviceInfo ----
[Fact]
public void MobileEvent_PromptIncludesScreenAndDeviceInfo()
{
DiagnosticEventRecord ev = Ev(
screenCode: "AGENDA",
featureCode: "INTERVENTION_SEARCH_RUN",
platform: "Android",
manufacturer: "Samsung",
model: "SM-S928B",
operatingSystem: "Android",
operatingSystemVersion: "15",
appVersion: "1.1.0",
deviceKey: "devk-pseudonymized-abcdef");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains("AGENDA", prompt, StringComparison.Ordinal);
Assert.Contains("INTERVENTION_SEARCH_RUN", prompt, StringComparison.Ordinal);
Assert.Contains("Samsung", prompt, StringComparison.Ordinal);
Assert.Contains("SM-S928B", prompt, StringComparison.Ordinal);
Assert.Contains("Android", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- E : beaucoup de NULL ----
[Fact]
public void AllNull_PromptStillBuildsWithoutException()
{
DiagnosticEventRecord ev = Ev(eventCode: "OLD", level: "Info");
ev.TraceId = null;
ev.OperationId = null;
ev.SessionId = null;
ev.StackTrace = null;
ev.Message = null;
ev.MetadataJson = null;
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.StartsWith("Analyse cette erreur Omika", prompt, StringComparison.Ordinal);
Assert.Contains("DEMANDE :", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- F : sécurité métadonnées (0 fuite) ----
[Fact]
public void ForbiddenMetadata_IsNeverLeakedInPrompt()
{
DiagnosticEventRecord ev = Ev(
eventCode: "ERR",
metadataJson: """{"password":"secret123","token":"tok-abc","apiKey":"key-xyz","connectionString":"Server=x;Password=y","deviceId":"dev-raw-1","method":"POST","statusCode":500,"routeTemplate":"/api/v1/x"}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.DoesNotContain("secret123", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("tok-abc", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("key-xyz", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Password=y", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dev-raw-1", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("password", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("apiKey", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("connectionString", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("deviceId", prompt, StringComparison.OrdinalIgnoreCase);
// Les clés whitelistées et saines restent présentes.
Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal);
Assert.Contains("/api/v1/x", prompt, StringComparison.Ordinal);
}
[Fact]
public void ForbiddenValueUnderWhitelistedKey_IsDropped()
{
DiagnosticEventRecord ev = Ev(
eventCode: "ERR",
metadataJson: """{"method":"ConnectionString=Server=x;Password=hidden","statusCode":200}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.DoesNotContain("ConnectionString", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Password=hidden", prompt, StringComparison.OrdinalIgnoreCase);
Assert.Contains("statusCode", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- §48 : sans corrélation ----
[Fact]
public void NoTraceOperationSession_PromptStillBuilds()
{
DiagnosticEventRecord ev = Ev();
ev.TraceId = null;
ev.OperationId = null;
ev.SessionId = null;
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains("Analyse cette erreur Omika", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("TraceId", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- §49 : sans stack trace ----
[Fact]
public void NoStackTrace_PromptStillBuilds()
{
DiagnosticEventRecord ev = Ev(exceptionType: "System.Exception", stackTrace: null);
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains("ExceptionType", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("StackTrace", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- §50 : bornes corrélés ----
[Fact]
public void CorrelatedEvents_AreCappedAtMax()
{
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
var many = new List<DiagnosticEventRecord>();
for (int i = 0; i < 60; i++)
{
many.Add(Correlated(i + 1, Base.AddSeconds(i), "E", "Info", "C", "m" + i));
}
string prompt = DiagnosticPromptBuilder.Build(ev, many);
Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
// 60 événements fournis, mais la section est bornée à MaxCorrelatedEvents.
int count = prompt.Split("- ", StringSplitOptions.None).Length - 1;
Assert.Equal(DiagnosticPromptBuilder.MaxCorrelatedEvents, count);
Assert.DoesNotContain("m59", prompt, StringComparison.Ordinal); // au-delà de la borne.
}
[Fact]
public void CorrelatedEvents_SelectionneLesPlusProchesDeLErreur()
{
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
var many = new List<DiagnosticEventRecord>();
// 30 événements ANTÉRIEURS (de 30 à 1 minutes avant l'erreur) : ce sont eux que
// la logique « les N premiers de la liste » reproduisait, sans jamais montrer
// ce qui s'est passé juste après l'incident.
for (int i = 30; i >= 1; i--)
{
many.Add(Correlated(100 + i, Base.AddMinutes(-i), "E", "Info", "C", $"avant{i:00}"));
}
// 5 événements POSTÉRIEURS : le contexte immédiat de l'erreur.
for (int i = 1; i <= 5; i++)
{
many.Add(Correlated(200 + i, Base.AddMinutes(i), "E", "Info", "C", $"apres{i:00}"));
}
string prompt = DiagnosticPromptBuilder.Build(ev, many);
// Retenus : les 5 postérieurs + les 20 antérieurs les plus proches.
Assert.Contains("apres05", prompt, StringComparison.Ordinal);
Assert.Contains("avant01", prompt, StringComparison.Ordinal);
Assert.Contains("avant20", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("avant21", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("avant30", prompt, StringComparison.Ordinal);
// Restitution chronologique : le passé avant le futur.
Assert.True(
prompt.IndexOf("avant01", StringComparison.Ordinal)
< prompt.IndexOf("apres01", StringComparison.Ordinal));
}
[Fact]
public void CorrelatedEvents_PromptIndiqueQueLaListeNEstPasExhaustive()
{
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
string prompt = DiagnosticPromptBuilder.Build(
ev,
[Correlated(1, Base.AddMinutes(-1), "E", "Info", "C", "m")]);
// Le lecteur (humain ou IA) ne doit pas conclure à une absence d'activité :
// les événements Info sont remontés après les erreurs (file prioritaire).
Assert.Contains("NON exhaustive", prompt, StringComparison.Ordinal);
Assert.Contains("remontés APRÈS les erreurs", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- longueur raisonnable ----
[Fact]
public void Prompt_IsReasonablySized()
{
DiagnosticEventRecord ev = Ev(
eventCode: "API_HTTP_ERROR",
exceptionType: "InvalidOperationException",
stackTrace: new string('x', 10000),
message: new string('y', 5000),
metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.True(prompt.Length < 9000, $"Prompt trop grand : {prompt.Length}");
// La stack trace est tronquée, pas reproduite intégralement.
Assert.DoesNotContain(new string('x', 10000), prompt, StringComparison.Ordinal);
}
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T24-A — handlers serveur du prompt IA (OnGetPromptAsync) : vérifie que le clic renvoie
/// bien le texte du prompt (text/plain), et 404 pour un id inconnu. Ne teste pas la
/// génération (déjà couverte par DiagnosticPromptBuilderTests) mais le contrat HTTP.
/// </summary>
public class DiagnosticPromptHandlerTests
{
private static DiagnosticEventRecord Ev(long id, string eventCode = "ERR") => new()
{
Id = id,
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero),
Level = "Error",
Priority = "P2",
ExceptionType = "System.InvalidOperationException",
Source = DiagnosticSources.Api,
IngestedAtUtc = DateTimeOffset.UtcNow
};
[Fact]
public async Task DiagnosticsDetail_Prompt_ReturnsTextPrompt()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[] { Ev(42) });
var model = new DiagnosticsDetailModel(repo);
IActionResult result = await model.OnGetPromptAsync(42);
ContentResult content = Assert.IsType<ContentResult>(result);
Assert.StartsWith("Analyse cette erreur Omika", content.Content, StringComparison.Ordinal);
Assert.Contains("ERR", content.Content, StringComparison.Ordinal);
Assert.Equal("text/plain; charset=utf-8", content.ContentType);
}
[Fact]
public async Task DiagnosticsDetail_Prompt_UnknownId_ReturnsNotFound()
{
var repo = new FakeDiagnosticEventRepository();
var model = new DiagnosticsDetailModel(repo);
IActionResult result = await model.OnGetPromptAsync(999);
Assert.IsType<NotFoundResult>(result);
}
}
@@ -0,0 +1,118 @@
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 12 — Traces / opérations récentes : exclusion des NULL (C10), groupement
/// distinct (C11/C13), tri dernière activité DESC (C12) et limite (C14).
/// </summary>
public class DiagnosticRecentCorrelationTests
{
private static readonly DateTimeOffset From = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
// ------------------------------------------- C10 : traces récentes : null exclu ----
[Fact]
public async Task RecentTraces_ExcludesNullTraceIds()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), traceId: Guid.NewGuid()),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(2), traceId: null), // exclu
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(3), traceId: Guid.NewGuid()),
});
IReadOnlyList<DiagnosticRecentCorrelation> result = await repo.GetRecentCorrelationsAsync(
DiagnosticCorrelationDimension.Trace, From, 10);
Assert.Equal(2, result.Count);
Assert.All(result, r => Assert.NotEqual(Guid.Empty, r.CorrelationId));
}
// ---------------------------------- C11 : traces récentes : groupement distinct ----
[Fact]
public async Task RecentTraces_GroupsDistinctTraceIds()
{
Guid traceA = Guid.NewGuid();
Guid traceB = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), traceId: traceA),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(2), traceId: traceA),
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(3), traceId: traceB),
});
IReadOnlyList<DiagnosticRecentCorrelation> result = await repo.GetRecentCorrelationsAsync(
DiagnosticCorrelationDimension.Trace, From, 10);
Assert.Equal(2, result.Count);
DiagnosticRecentCorrelation groupA = Assert.Single(result, r => r.CorrelationId == traceA);
Assert.Equal(2, groupA.EventCount);
Assert.Equal(1, Assert.Single(result, r => r.CorrelationId == traceB).EventCount);
}
// ------------------------------ C12 : traces récentes : tri dernière activité DESC ----
[Fact]
public async Task RecentTraces_OrdersByLastActivityDescending()
{
Guid older = Guid.NewGuid();
Guid newer = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), traceId: older),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(10), traceId: newer),
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(2), traceId: older),
});
IReadOnlyList<DiagnosticRecentCorrelation> result = await repo.GetRecentCorrelationsAsync(
DiagnosticCorrelationDimension.Trace, From, 10);
Assert.Equal(2, result.Count);
Assert.Equal(newer, result[0].CorrelationId); // dernière activité la plus récente
Assert.Equal(older, result[1].CorrelationId);
}
// ------------------------------- C13 : opérations récentes : distinct correct ----
[Fact]
public async Task RecentOperations_GroupsDistinctOperationIds()
{
Guid opA = Guid.NewGuid();
Guid opB = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "A", From.AddMinutes(1), operationId: opA),
DiagnosticTestData.Event(Guid.NewGuid(), "B", From.AddMinutes(2), operationId: opB),
DiagnosticTestData.Event(Guid.NewGuid(), "C", From.AddMinutes(3), operationId: opA),
});
IReadOnlyList<DiagnosticRecentCorrelation> result = await repo.GetRecentCorrelationsAsync(
DiagnosticCorrelationDimension.Operation, From, 10);
Assert.Equal(2, result.Count);
Assert.Contains(result, r => r.CorrelationId == opA && r.EventCount == 2);
Assert.Contains(result, r => r.CorrelationId == opB && r.EventCount == 1);
}
// ------------------------------------------------- C14 : limite respectée ----
[Fact]
public async Task RecentTraces_RespectsLimit()
{
var repo = new FakeDiagnosticEventRepository();
for (int i = 0; i < 5; i++)
{
await repo.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), $"E{i}", From.AddMinutes(i), traceId: Guid.NewGuid())
});
}
IReadOnlyList<DiagnosticRecentCorrelation> result = await repo.GetRecentCorrelationsAsync(
DiagnosticCorrelationDimension.Trace, From, 2);
Assert.Equal(2, result.Count);
}
}
@@ -0,0 +1,223 @@
using Omika.Diagnostics.Referential;
using Omika.Diagnostics.Security;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T24-A Partie B — PageModels du référentiel (/Referentiel + /Referentiel/Admin) et
/// libellés des dropdowns Diagnostics. Les handlers n'accèdent pas à HttpContext (aucun
/// harnais Web requis) ; l'autorisation est portée par la convention Program.cs.
/// </summary>
public class DiagnosticReferencePageModelTests
{
private static DiagnosticReferenceRecord Ref(
long id,
string type,
string code,
string label,
bool enabled = true) => new()
{
Id = id,
ReferenceType = type,
Code = code,
Label = label,
Enabled = enabled,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow
};
private static ReferentielAdminModel NewAdminModel(
FakeDiagnosticReferenceRepository references) =>
new(new FakeDiagnosticEventRepository(), references);
// ------------------------------------------- création admin ----
[Fact]
public async Task AdminCreate_StoresNormalizedEntry()
{
var references = new FakeDiagnosticReferenceRepository();
var model = NewAdminModel(references);
model.NewType = DiagnosticReferenceTypes.ScreenCode;
model.NewCode = " facture_detail ";
model.NewLabel = " Détail de facture ";
model.NewDescription = "Fiche détaillée d'une facture.";
await model.OnPostCreateAsync();
DiagnosticReferenceRecord? created = await references.GetByTypeAndCodeAsync(
DiagnosticReferenceTypes.ScreenCode, "FACTURE_DETAIL");
Assert.NotNull(created);
Assert.Equal("FACTURE_DETAIL", created!.Code);
Assert.Equal("Détail de facture", created.Label);
Assert.Contains("FACTURE_DETAIL", model.SuccessMessage);
}
[Fact]
public async Task AdminCreate_Duplicate_ShowsError()
{
var references = new FakeDiagnosticReferenceRepository();
references.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
var model = NewAdminModel(references);
model.NewType = DiagnosticReferenceTypes.ScreenCode;
model.NewCode = "agenda";
model.NewLabel = "Agenda bis";
await model.OnPostCreateAsync();
Assert.Contains("existe déjà", model.ErrorMessage);
Assert.Single(await references.ListAsync());
}
[Fact]
public async Task AdminCreate_EmptyLabel_ShowsError()
{
var references = new FakeDiagnosticReferenceRepository();
var model = NewAdminModel(references);
model.NewType = DiagnosticReferenceTypes.ScreenCode;
model.NewCode = "X";
model.NewLabel = " ";
await model.OnPostCreateAsync();
Assert.Contains("libellé", model.ErrorMessage, StringComparison.OrdinalIgnoreCase);
Assert.Empty(await references.ListAsync());
}
// ------------------------------------------- modification / archivage ----
[Fact]
public async Task AdminUpdate_ChangesLabelDescriptionAndState()
{
var references = new FakeDiagnosticReferenceRepository();
references.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
var model = NewAdminModel(references);
await model.OnPostUpdateAsync(1, "Nouvel agenda", "Description", enabled: false, sortOrder: 5);
DiagnosticReferenceRecord? record = await references.GetByIdAsync(1);
Assert.Equal("Nouvel agenda", record!.Label);
Assert.Equal("Description", record.Description);
Assert.False(record.Enabled);
Assert.Equal(5, record.SortOrder);
}
[Fact]
public async Task AdminArchive_DisablesWithoutDelete()
{
var references = new FakeDiagnosticReferenceRepository();
references.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
var model = NewAdminModel(references);
await model.OnPostArchiveAsync(1);
Assert.False((await references.GetByIdAsync(1))!.Enabled);
Assert.Single(await references.ListAsync()); // jamais de DELETE.
Assert.Contains("archivée", model.SuccessMessage);
}
[Fact]
public async Task AdminEnable_ReactivatesArchived()
{
var references = new FakeDiagnosticReferenceRepository();
references.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda", enabled: false));
var model = NewAdminModel(references);
await model.OnPostEnableAsync(1);
Assert.True((await references.GetByIdAsync(1))!.Enabled);
}
// ------------------------------------------- ajout rapide non référencé ----
[Fact]
public async Task AdminAddUnreferenced_CreatesReference()
{
var references = new FakeDiagnosticReferenceRepository();
DateTimeOffset last = DateTimeOffset.UtcNow.AddDays(-1);
references.SeedObserved(DiagnosticReferenceTypes.ScreenCode, "FACTURE_DETAIL", 128, last);
var model = NewAdminModel(references);
await model.OnGetAsync();
DiagnosticUnreferencedCode unreferenced = Assert.Single(model.Unreferenced);
Assert.Equal("FACTURE_DETAIL", unreferenced.Code);
await model.OnPostAddUnreferencedAsync(
DiagnosticReferenceTypes.ScreenCode, "FACTURE_DETAIL", "Détail de facture", "Fiche détaillée.");
Assert.NotNull(await references.GetByTypeAndCodeAsync(DiagnosticReferenceTypes.ScreenCode, "FACTURE_DETAIL"));
// Après ajout, le code n'est plus « non référencé ».
await model.OnGetAsync();
Assert.Empty(model.Unreferenced);
}
// ------------------------------------------- consultation /Referentiel ----
[Fact]
public async Task ReferentielModel_LoadsScreenAndFeatureReferences()
{
var references = new FakeDiagnosticReferenceRepository();
references.Seed(
Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"),
Ref(2, DiagnosticReferenceTypes.FeatureCode, "EMAIL_SEND", "Envoi de courriel"));
var model = new ReferentielModel(new FakeDiagnosticEventRepository(), references);
await model.OnGetAsync();
Assert.True(model.PostgreSqlAvailable);
DiagnosticReferenceRecord screen = Assert.Single(model.ScreenReferences);
Assert.Equal("AGENDA", screen.Code);
DiagnosticReferenceRecord feature = Assert.Single(model.FeatureReferences);
Assert.Equal("EMAIL_SEND", feature.Code);
}
// ------------------------------------------- dropdowns Diagnostics (§33/§37) ----
[Fact]
public async Task DiagnosticsDropdown_ReferencedCode_UsesDbLabel()
{
var references = new FakeDiagnosticReferenceRepository();
references.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "FACTURE_DETAIL", "Détail de facture"));
var model = new DiagnosticsModel(new FakeDiagnosticEventRepository(), references);
await model.OnGetAsync();
// Référence DB → « Libellé (CODE) ».
Assert.Equal("Détail de facture (FACTURE_DETAIL)", model.ScreenCodeOptionLabel("FACTURE_DETAIL"));
}
[Fact]
public async Task DiagnosticsDropdown_UnreferencedCode_ShowsNotReferenced()
{
var references = new FakeDiagnosticReferenceRepository();
var model = new DiagnosticsModel(new FakeDiagnosticEventRepository(), references);
await model.OnGetAsync();
// Aucune référence → « CODE (non référencé) ». La valeur envoyée reste le CODE.
Assert.Equal("FACTURE_DETAIL (non référencé)", model.ScreenCodeOptionLabel("FACTURE_DETAIL"));
}
[Fact]
public async Task DiagnosticsDropdown_CatalogCodeWithoutDbEntry_FallsBackToCatalog()
{
var references = new FakeDiagnosticReferenceRepository();
var model = new DiagnosticsModel(new FakeDiagnosticEventRepository(), references);
await model.OnGetAsync();
// Code du catalogue applicatif mais sans entrée DB → repli catalogue (résilience).
Assert.Equal("AGENDA — Agenda", model.ScreenCodeOptionLabel("AGENDA"));
}
// ------------------------------------------- rôles (§40/§46) ----
[Fact]
public void Roles_SupportCanConsultButNotAdmin()
{
Assert.True(DiagnosticAuthorization.IsSupport([DiagnosticRoles.Support]));
Assert.False(DiagnosticAuthorization.IsAdmin([DiagnosticRoles.Support]));
}
[Fact]
public void Roles_AdminCanConsultAndAdmin()
{
Assert.True(DiagnosticAuthorization.IsSupport([DiagnosticRoles.Admin]));
Assert.True(DiagnosticAuthorization.IsAdmin([DiagnosticRoles.Admin]));
}
}
@@ -0,0 +1,168 @@
using Omika.Diagnostics.Referential;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T24-A Partie B — référentiel administrable : normalisation TRIM+UPPER, unicité
/// (type, code), code immuable, archivage sans DELETE, libellés et codes non référencés.
/// S'appuie sur le <see cref="FakeDiagnosticReferenceRepository"/> (même contrat que le
/// repository Npgsql).
/// </summary>
public class DiagnosticReferenceTests
{
private static DiagnosticReferenceRecord Ref(
long id,
string type,
string code,
string label,
bool enabled = true,
int sortOrder = 0) => new()
{
Id = id,
ReferenceType = type,
Code = code,
Label = label,
Enabled = enabled,
SortOrder = sortOrder,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow
};
// ------------------------------------------- normalisation ----
[Theory]
[InlineData("agenda", "AGENDA")]
[InlineData(" agenda ", "AGENDA")]
[InlineData("Facture_Detail", "FACTURE_DETAIL")]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
public void NormalizeCode_TrimsAndUppers(string? input, string? expected)
{
Assert.Equal(expected, DiagnosticReferenceNormalization.NormalizeCode(input));
}
// ------------------------------------------- création + normalisation ----
[Fact]
public async Task Create_NormalizesCodeAndLabel()
{
var repo = new FakeDiagnosticReferenceRepository();
DiagnosticReferenceRecord? created = await repo.CreateAsync(
DiagnosticReferenceTypes.ScreenCode, " facture_detail ", " Détail de facture ", null, true, 0);
Assert.NotNull(created);
Assert.Equal("FACTURE_DETAIL", created!.Code);
Assert.Equal("Détail de facture", created.Label);
}
// ------------------------------------------- unicité (type, code) ----
[Fact]
public async Task Create_DuplicateTypeCode_CaseInsensitive_ReturnsNull()
{
var repo = new FakeDiagnosticReferenceRepository();
repo.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
DiagnosticReferenceRecord? duplicate = await repo.CreateAsync(
DiagnosticReferenceTypes.ScreenCode, "agenda", "Agenda bis", null, true, 0);
Assert.Null(duplicate);
Assert.Single(await repo.ListAsync());
}
[Fact]
public async Task Create_SameCodeDifferentType_IsAllowed()
{
var repo = new FakeDiagnosticReferenceRepository();
repo.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
// Même code mais type différent → autorisé (unicité = type + code).
DiagnosticReferenceRecord? feature = await repo.CreateAsync(
DiagnosticReferenceTypes.FeatureCode, "agenda", "Agenda (fonctionnalité)", null, true, 0);
Assert.NotNull(feature);
Assert.Equal(2, (await repo.ListAsync()).Count);
}
// ------------------------------------------- type invalide ----
[Fact]
public async Task Create_UnsupportedType_ReturnsNull()
{
var repo = new FakeDiagnosticReferenceRepository();
DiagnosticReferenceRecord? created = await repo.CreateAsync(
"EVENT_CODE", "X", "Libellé", null, true, 0);
Assert.Null(created);
}
// ------------------------------------------- code immuable ----
[Fact]
public async Task Update_ChangesLabelAndState_ButNeverCode()
{
var repo = new FakeDiagnosticReferenceRepository();
repo.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
bool updated = await repo.UpdateAsync(1, "Nouvel Agenda", "Description", false, 7);
Assert.True(updated);
DiagnosticReferenceRecord? record = await repo.GetByIdAsync(1);
Assert.NotNull(record);
Assert.Equal("AGENDA", record!.Code); // code immuable.
Assert.Equal("Nouvel Agenda", record.Label);
Assert.False(record.Enabled);
Assert.Equal(7, record.SortOrder);
}
// ------------------------------------------- archivage sans DELETE ----
[Fact]
public async Task Archive_DisablesWithoutDelete()
{
var repo = new FakeDiagnosticReferenceRepository();
repo.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
bool updated = await repo.UpdateAsync(1, "Agenda", null, enabled: false, sortOrder: 0);
Assert.True(updated);
Assert.False((await repo.GetByIdAsync(1))!.Enabled);
// Aucun DELETE : l'entrée reste listée (état Archivé affiché, jamais masqué).
Assert.Single(await repo.ListAsync());
}
// ------------------------------------------- libellés dropdown ----
[Fact]
public async Task GetLabels_ReturnsCodeToLabelIncludingArchived()
{
var repo = new FakeDiagnosticReferenceRepository();
repo.Seed(
Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"),
Ref(2, DiagnosticReferenceTypes.ScreenCode, "SITE_LIST", "Sites", enabled: false),
Ref(3, DiagnosticReferenceTypes.FeatureCode, "EMAIL_SEND", "Envoi de courriel"));
IReadOnlyDictionary<string, string> labels = await repo.GetLabelsAsync(DiagnosticReferenceTypes.ScreenCode);
Assert.Equal("Agenda", labels["AGENDA"]);
Assert.Equal("Sites", labels["SITE_LIST"]); // archivé inclus (libellé historique utile).
Assert.False(labels.ContainsKey("EMAIL_SEND"));
}
// ------------------------------------------- codes non référencés ----
[Fact]
public async Task GetUnreferenced_ReturnsObservedWithoutReference()
{
var repo = new FakeDiagnosticReferenceRepository();
repo.Seed(Ref(1, DiagnosticReferenceTypes.ScreenCode, "AGENDA", "Agenda"));
DateTimeOffset last = DateTimeOffset.UtcNow.AddDays(-1);
repo.SeedObserved(DiagnosticReferenceTypes.ScreenCode, "AGENDA", 40, last); // référencé → exclu.
repo.SeedObserved(DiagnosticReferenceTypes.ScreenCode, "FACTURE_DETAIL", 128, last); // non référencé.
repo.SeedObserved(DiagnosticReferenceTypes.FeatureCode, "FACTURE_EXPORT_PDF", 12, last);
IReadOnlyList<DiagnosticUnreferencedCode> unreferenced = await repo.GetUnreferencedAsync(100);
Assert.Equal(2, unreferenced.Count);
Assert.Contains(unreferenced, u => u.ReferenceType == DiagnosticReferenceTypes.ScreenCode && u.Code == "FACTURE_DETAIL");
Assert.Contains(unreferenced, u => u.ReferenceType == DiagnosticReferenceTypes.FeatureCode && u.Code == "FACTURE_EXPORT_PDF");
Assert.DoesNotContain(unreferenced, u => u.Code == "AGENDA");
}
}
@@ -0,0 +1,121 @@
using System.Reflection;
using Omika.Core.Observability;
using Omika.Diagnostics.Referential;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 12 — Référentiels : libellés EventCode/ErrorCode, fallbacks « non documenté »,
/// couverture du catalogue producteur (C5), alignement niveaux (C6) et priorités (C7).
/// </summary>
public class DiagnosticReferentialTests
{
// ------------------------------------------------------- C1 : EventCode connu ----
[Fact]
public void EventCode_Known_ReturnsFrenchLabel()
{
DiagnosticEventCodeDefinition? def = DiagnosticEventCodeCatalog.TryGet("OMK-WKR-SYNC-002");
Assert.NotNull(def);
Assert.Equal("Échec de synchronisation Worker", def.LibelleFrancais);
Assert.Equal("Synchronisation", def.Famille);
Assert.Equal("Omika.Worker", def.Producteur);
}
// ------------------------------------------------- C2 : EventCode inconnu → fallback ----
[Fact]
public void EventCode_Unknown_ReturnsFallbackWithoutThrowing()
{
Assert.Null(DiagnosticEventCodeCatalog.TryGet("NEW_EVENT_001"));
DiagnosticEventCodeDefinition resolved = DiagnosticEventCodeCatalog.Resolve("NEW_EVENT_001");
Assert.Equal("NEW_EVENT_001", resolved.Code);
Assert.Equal("Code événement non documenté", resolved.DescriptionFrancais);
}
// ------------------------------------------------------ C3 : ErrorCode connu ----
[Fact]
public void ErrorCode_Known_ReturnsDefinition()
{
DiagnosticErrorCodeDefinition? unhandled = DiagnosticErrorCodeCatalog.TryGet("UnhandledException");
Assert.NotNull(unhandled);
Assert.Equal("Exception non gérée", unhandled.LibelleFrancais);
Assert.Equal(DiagnosticErrorCodeCatalog.OrigineProduction, unhandled.Origine);
// Indisponibilité réseau mobile : erreur la plus fréquente en production, elle
// doit être lisible par le support (et non « Code erreur non documenté »).
DiagnosticErrorCodeDefinition? reseau = DiagnosticErrorCodeCatalog.TryGet("HttpRequestException");
Assert.NotNull(reseau);
Assert.Equal("Réseau indisponible", reseau.LibelleFrancais);
Assert.Equal(DiagnosticErrorCodeCatalog.OrigineProduction, reseau.Origine);
// Code de seed local signalé comme tel.
DiagnosticErrorCodeDefinition? timeout = DiagnosticErrorCodeCatalog.TryGet("TIMEOUT");
Assert.NotNull(timeout);
Assert.Equal("Délai dépassé", timeout.LibelleFrancais);
Assert.Equal(DiagnosticErrorCodeCatalog.OrigineTestLocal, timeout.Origine);
}
// ------------------------------------------------ C4 : ErrorCode inconnu → fallback ----
[Fact]
public void ErrorCode_Unknown_ReturnsFallbackWithoutThrowing()
{
Assert.Null(DiagnosticErrorCodeCatalog.TryGet("NEW_ERR_001"));
DiagnosticErrorCodeDefinition resolved = DiagnosticErrorCodeCatalog.Resolve("NEW_ERR_001");
Assert.Equal("NEW_ERR_001", resolved.Code);
Assert.Equal("Code erreur non documenté", resolved.DescriptionFrancais);
}
// ------------------- C5 : tous les EventCodes du catalogue producteur sont couverts ----
[Fact]
public void EveryProducerEventCode_HasCatalogEntry()
{
IReadOnlyList<string> producerCodes = typeof(TelemetryEventCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(EventCode))
.Select(f => ((EventCode)f.GetValue(null)!).Value)
.Where(v => v is not null)
.Select(v => v!)
.ToList();
Assert.NotEmpty(producerCodes);
foreach (string code in producerCodes)
{
Assert.True(
DiagnosticEventCodeCatalog.TryGet(code) is not null,
$"EventCode producteur non documenté dans le catalogue : {code}");
}
}
// ---------- Les codes middleware (hors TelemetryEventCodes) sont documentés ----
[Fact]
public void MiddlewareEventCodes_AreDocumented()
{
Assert.NotNull(DiagnosticEventCodeCatalog.TryGet("API_HTTP_ERROR"));
Assert.NotNull(DiagnosticEventCodeCatalog.TryGet("API_HTTP_4XX"));
}
// -------------------------------------- C6 : niveaux UI ↔ valeurs backend ----
[Fact]
public void LevelCatalog_MatchesTelemetryLevelEnum()
{
string[] technical = DiagnosticLevelCatalog.All.Select(l => l.ValeurTechnique).ToArray();
string[] producer = Enum.GetNames<TelemetryLevel>();
Assert.Equal(producer, technical);
}
// ------------------------------------------- C7 : priorités P0–P4 ----
[Fact]
public void PriorityCatalog_MatchesP0ToP4()
{
string[] priorities = DiagnosticPriorityCatalog.All.Select(p => p.Valeur).ToArray();
Assert.Equal(new[] { "P0", "P1", "P2", "P3", "P4" }, priorities);
Assert.Equal(Enum.GetNames<TelemetryPriority>(), priorities);
}
}
@@ -0,0 +1,317 @@
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);
}
}
@@ -0,0 +1,168 @@
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Contrat du schéma PostgreSQL (§15-§17, §28) : vérifie que le fichier SQL déclare
/// les tables, la clé unique <c>event_id</c> (déduplication) et la table des métriques.
/// </summary>
public class DiagnosticSchemaContractTests
{
[Fact]
public void Schema_DeclaresEventTable_WithUniqueEventId()
{
string sql = ReadSchema();
Assert.Contains("CREATE TABLE IF NOT EXISTS diagnostic_event", sql);
Assert.Contains("event_id", sql);
Assert.Contains("UNIQUE", sql);
Assert.Contains("ux_diagnostic_event_event_id", sql); // ON CONFLICT (event_id) DO NOTHING
}
[Fact]
public void Schema_DeclaresMetricTable_AndRequiredIndexes()
{
string sql = ReadSchema();
Assert.Contains("CREATE TABLE IF NOT EXISTS telemetry_metric_hourly", sql);
Assert.Contains("metric_name", sql);
Assert.Contains("ux_telemetry_metric_hourly", sql);
}
[Fact]
public void ActorKeyMigration_IsIdempotent_AndNonDestructive()
{
string sql = ReadMigration("002_actor_key.sql");
Assert.Contains("ALTER TABLE diagnostic_event", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS actor_key", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS actor_key_version", sql);
Assert.Contains("CREATE INDEX IF NOT EXISTS ix_diagnostic_event_actor_key_timestamp", sql);
Assert.DoesNotContain("DROP TABLE", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP COLUMN", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP INDEX", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DELETE FROM", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ClientContextMigration_IsIdempotent_AndDeclaresNullableColumns()
{
string sql = ReadMigration("005_diagnostics_client_context.sql");
Assert.Contains("ALTER TABLE diagnostic_event", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS client_code", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS server_instance", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS environment", sql);
Assert.Contains("CREATE INDEX IF NOT EXISTS ix_diagnostic_event_client_code_timestamp", sql);
Assert.DoesNotContain("DROP TABLE", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP COLUMN", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP INDEX", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DELETE FROM", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("UPDATE", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DeviceLicenseMigration_IsIdempotent_AndDeclaresNullableColumns()
{
string sql = ReadMigration("006_diagnostics_device_license.sql");
Assert.Contains("ALTER TABLE diagnostic_event", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS license_number", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS device_key", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS device_key_version", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS device_manufacturer", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS device_model", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS operating_system", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS operating_system_version", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS app_build", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS screen_width", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS screen_height", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS logical_width", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS logical_height", sql);
Assert.Contains("ADD COLUMN IF NOT EXISTS display_scale", sql);
Assert.Contains("CREATE INDEX IF NOT EXISTS ix_diagnostic_event_device_key_timestamp", sql);
Assert.Contains("CREATE INDEX IF NOT EXISTS ix_diagnostic_event_license_number_timestamp", sql);
// Types : dimensions écran entières, display_scale réel.
Assert.Contains("screen_width INTEGER", sql);
Assert.Contains("display_scale DOUBLE PRECISION", sql);
// Non destructif : aucune suppression/réécriture.
Assert.DoesNotContain("DROP TABLE", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP COLUMN", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP INDEX", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DELETE FROM", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("UPDATE", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void TasksMigration_IsIdempotent_AndNonDestructive()
{
string sql = ReadMigration("012_diagnostics_tasks.sql");
Assert.Contains("CREATE TABLE IF NOT EXISTS diagnostics_tasks", sql);
Assert.Contains("group_url_identifier", sql);
Assert.Contains("ux_diagnostics_tasks_group_url_identifier", sql);
Assert.Contains("GRANT SELECT ON TABLE diagnostics_tasks TO omika_diagnostics_web", sql);
Assert.DoesNotContain("DROP TABLE", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP COLUMN", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DROP INDEX", sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DELETE FROM", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void RetentionUntreatedMigration_IsIdempotent_AndColumnScoped()
{
string sql = ReadMigration("015_diagnostics_retention_untreated.sql");
// Moindre privilège : SELECT UNIQUEMENT sur group_url_identifier + status (le
// rapprochement « traité/non traité »), jamais sur title/description/cause ni
// les usernames. Aucun droit d'écriture accordé au worker.
Assert.Contains("GRANT SELECT (group_url_identifier, status)", sql);
Assert.Contains("ON TABLE diagnostics_tasks TO omika_diagnostics_worker", sql);
Assert.DoesNotContain("GRANT INSERT", sql);
Assert.DoesNotContain("GRANT UPDATE", sql);
Assert.DoesNotContain("GRANT DELETE", sql);
Assert.DoesNotContain("DROP TABLE", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void EventDeleteMigration_GrantsDeleteToWebOnly_AndIsNonDestructive()
{
string sql = ReadMigration("016_diagnostics_event_delete.sql");
// Moindre privilège : DELETE sur diagnostic_event accordé au SEUL rôle Web
// (suppression d'un groupe d'erreurs depuis /Erreurs). Aucun GRANT accordé au
// Worker ni à l'API (le Worker a déjà son propre périmètre via la migration 004).
Assert.Contains("GRANT DELETE ON TABLE diagnostic_event TO omika_diagnostics_web", sql);
Assert.DoesNotContain("TO omika_diagnostics_worker", sql);
Assert.DoesNotContain("TO omika_diagnostics_api", sql);
Assert.DoesNotContain("GRANT INSERT", sql);
Assert.DoesNotContain("GRANT UPDATE", sql);
Assert.DoesNotContain("DROP TABLE", sql, StringComparison.OrdinalIgnoreCase);
}
private static string ReadSchema()
{
string root = FindRepoRoot();
return File.ReadAllText(Path.Combine(root, "infra", "postgresql", "001_diagnostics_schema.sql"));
}
private static string ReadMigration(string fileName)
{
string root = FindRepoRoot();
return File.ReadAllText(Path.Combine(root, "infra", "postgresql", fileName));
}
private static string FindRepoRoot()
{
DirectoryInfo? dir = new(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
{
return dir.FullName;
}
dir = dir.Parent;
}
throw new InvalidOperationException("Racine du dépôt introuvable (Omika.slnx absent).");
}
}
@@ -0,0 +1,225 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 2 — Recherche texte bornée. Les tests s'appuient sur le
/// <see cref="FakeDiagnosticEventRepository"/> (in-memory) ; l'implémentation
/// PostgreSQL de production exécute les mêmes contrats via ILIKE paramétré.
/// </summary>
public class DiagnosticSearchTests
{
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? message = null,
string? exceptionType = null,
string? stackTrace = null,
string eventCode = "ERR",
DateTimeOffset? ts = null,
string? component = null,
string? errorCode = null,
string level = "Error",
string priority = "P2",
string? appVersion = null,
string? platform = null,
string? actorKey = null) =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
Component = component,
ErrorCode = errorCode,
Message = message,
ExceptionType = exceptionType,
StackTrace = stackTrace,
Level = level,
Priority = priority,
AppVersion = appVersion,
Platform = platform,
ActorKey = actorKey,
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
// ------------------------------------------------------- R1 : message ----
[Fact]
public async Task Search_Message_ContainsTerm_IsFound()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(message: "Connexion serveur impossible")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery { FromUtc = From, ToUtc = To, SearchText = "serveur" });
DiagnosticEventRecord item = Assert.Single(result.Items);
Assert.Equal("Connexion serveur impossible", item.Message);
Assert.Equal(1, result.TotalCount);
}
// ------------------------------------------------- R2 : insensible casse ----
[Fact]
public async Task Search_IsCaseInsensitive()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(exceptionType: "System.NullReferenceException")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery { FromUtc = From, ToUtc = To, SearchText = "nullreference" });
Assert.Single(result.Items);
}
// ------------------------------------------------------ R3 : hors période ----
[Fact]
public async Task Search_OutOfPeriod_IsExcluded()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(message: "serveur", ts: From.AddMinutes(-5)), // avant FromUtc
Ev(message: "serveur", ts: To.AddMinutes(5)) // après ToUtc (borne exclusive)
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery { FromUtc = From, ToUtc = To, SearchText = "serveur" });
Assert.Empty(result.Items);
Assert.Equal(0, result.TotalCount);
}
// ----------------------------------------------------- R4 : filtres ----
[Fact]
public async Task Search_RespectsOtherFilters()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(message: "serveur", component: "Omika.Api"),
Ev(message: "serveur", component: "Omika.Worker")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery
{
FromUtc = From,
ToUtc = To,
SearchText = "serveur",
Component = "Omika.Api"
});
DiagnosticEventRecord item = Assert.Single(result.Items);
Assert.Equal("Omika.Api", item.Component);
}
// -------------------------------------- R5 : recherche vide/trop courte ----
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("a")] // < MinSearchLength (2) → recherche désactivée
public async Task Search_EmptyOrTooShort_DisablesTextFilter(string? searchText)
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(message: "Connexion serveur impossible"),
Ev(message: "Autre message")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery { FromUtc = From, ToUtc = To, SearchText = searchText });
// Pas de filtre texte : tous les événements de la période sont retournés
// (jamais un ILIKE '%%').
Assert.Equal(2, result.TotalCount);
}
// ----------------------------------------- exception_type recherchable ----
[Fact]
public async Task Search_MatchesExceptionType()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(message: "échec", exceptionType: "System.TimeoutException")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery { FromUtc = From, ToUtc = To, SearchText = "timeout" });
Assert.Single(result.Items);
}
// ------------------------------------------------ pagination serveur ----
[Fact]
public async Task Search_RespectsPagination()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(message: "serveur A", ts: From.AddMinutes(1)),
Ev(message: "serveur B", ts: From.AddMinutes(2)),
Ev(message: "serveur C", ts: From.AddMinutes(3))
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery
{
FromUtc = From,
ToUtc = To,
SearchText = "serveur",
Page = 2,
PageSize = 1
});
Assert.Equal(3, result.TotalCount);
DiagnosticEventRecord item = Assert.Single(result.Items);
Assert.Equal("serveur B", item.Message); // tri timestamp DESC → page 2 = 2e plus récent
}
// ------------------------------------------ validation période obligatoire ----
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Search_Validate_RejectsMissingPeriod(bool missingFrom, bool missingTo)
{
var repo = new FakeDiagnosticEventRepository();
var query = new DiagnosticSearchQuery
{
FromUtc = missingFrom ? null : From,
ToUtc = missingTo ? null : To,
SearchText = "serveur"
};
await Assert.ThrowsAsync<ArgumentException>(() => repo.SearchAsync(query));
}
// ---------------------------------------- joker % littéral (pas un joker) ----
[Fact]
public async Task Search_PercentIsLiteral()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev(message: "100% réussi"),
Ev(message: "sans pourcent")
});
DiagnosticQueryResult<DiagnosticEventRecord> result = await repo.SearchAsync(
new DiagnosticSearchQuery { FromUtc = From, ToUtc = To, SearchText = "100%" });
DiagnosticEventRecord item = Assert.Single(result.Items);
Assert.Equal("100% réussi", item.Message);
}
}
@@ -0,0 +1,129 @@
using System.Text.Json;
using Omika.Core.Observability.External;
using Omika.Core.Observability.Outbox;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Security;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>§54 — Sécurité : aucune donnée interdite dans le modèle persistable.</summary>
public class DiagnosticSecurityTests
{
[Fact]
public void Mapper_DropsForbiddenMetadataKeysAndValues()
{
var metadata = new Dictionary<string, object?>
{
["password"] = "secret123", // clé interdite
["Authorization"] = "Bearer abc", // clé interdite
["method"] = "Bearer token", // clé autorisée mais valeur interdite
["statusCode"] = 200,
["routeTemplate"] = "/api/test"
};
RemoteDiagnosticRecord record = DiagnosticTestData.Record(Guid.NewGuid(), "TEST", metadata: metadata);
Models.DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.NotNull(mapped!.MetadataJson);
string json = mapped.MetadataJson!;
Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Authorization", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Bearer", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret123", json, StringComparison.OrdinalIgnoreCase);
var persisted = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
Assert.NotNull(persisted);
Assert.False(persisted!.ContainsKey("password"));
Assert.False(persisted.ContainsKey("Authorization"));
Assert.False(persisted.ContainsKey("method")); // valeur interdite → clé supprimée
Assert.True(persisted.ContainsKey("statusCode"));
Assert.True(persisted.ContainsKey("routeTemplate"));
}
[Fact]
public void Policy_RejectsForbiddenKeys()
{
Assert.False(DiagnosticMetadataPolicy.IsAllowedKey("password"));
Assert.False(DiagnosticMetadataPolicy.IsAllowedKey("ConnectionString"));
Assert.False(DiagnosticMetadataPolicy.IsAllowedKey("RequestBody"));
Assert.False(DiagnosticMetadataPolicy.IsAllowedKey("cookie"));
Assert.True(DiagnosticMetadataPolicy.IsAllowedKey("method"));
Assert.True(DiagnosticMetadataPolicy.IsAllowedKey("statusCode"));
}
[Fact]
public void Mapper_DoesNotPersistBusinessIdentityFields()
{
// DeviceId/UserId/ClientId/CollaborateurId/EntityId sont volontairement
// ABSENTS du modèle persistable (confidentialité, §30/§31).
Assert.Null(typeof(Models.DiagnosticEventRecord).GetProperty("DeviceId"));
Assert.Null(typeof(Models.DiagnosticEventRecord).GetProperty("UserId"));
Assert.Null(typeof(Models.DiagnosticEventRecord).GetProperty("ClientId"));
Assert.Null(typeof(Models.DiagnosticEventRecord).GetProperty("CollaborateurId"));
Assert.Null(typeof(Models.DiagnosticEventRecord).GetProperty("EntityId"));
}
[Theory]
[InlineData("CollaborateurId")]
[InlineData("collaborateur_id")]
[InlineData("collaborateur-id")]
[InlineData("USER_ID")]
[InlineData("deviceId")]
[InlineData("client.id")]
[InlineData("email")]
[InlineData("e-mail")]
[InlineData("phone")]
[InlineData("telephone")]
public void ForbiddenData_RejectsIdentityMetadataKeys(string key)
{
Assert.True(DiagnosticForbiddenData.IsForbiddenKey(key));
}
[Theory]
[InlineData("CollaborateurId")]
[InlineData("collaborateur_id")]
[InlineData("collaborateur-id")]
[InlineData("USER_ID")]
[InlineData("deviceId")]
[InlineData("client.id")]
[InlineData("email")]
[InlineData("e-mail")]
[InlineData("phone")]
[InlineData("telephone")]
public void MetadataPolicy_RejectsIdentityMetadataKeys(string key)
{
Assert.False(DiagnosticMetadataPolicy.IsAllowedKey(key));
}
[Fact]
public void Mapper_DropsIdentityMetadataKeys()
{
var metadata = new Dictionary<string, object?>
{
["CollaborateurId"] = "42",
["UserId"] = "user-1",
["ClientId"] = "BATIK.01",
["DeviceId"] = "device-1",
["email"] = "jean@example.com",
["phone"] = "0123456789"
};
RemoteDiagnosticRecord record = DiagnosticTestData.Record(Guid.NewGuid(), "TEST", metadata: metadata);
Models.DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Null(mapped!.MetadataJson); // toutes les clés d'identité sont rejetées.
}
[Fact]
public void ForbiddenValue_DetectsEmailAndPhoneLiterals()
{
Assert.True(DiagnosticForbiddenData.ContainsForbiddenValue("email=jean@example.com"));
Assert.True(DiagnosticForbiddenData.ContainsForbiddenValue("phone=0123456789"));
Assert.False(DiagnosticForbiddenData.ContainsForbiddenValue("composant: télémétrie"));
}
}
@@ -0,0 +1,245 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 8 — Synchronisation : résumé sync et filtres par liste fermée d'EventCodes.
/// Tests sur <see cref="FakeDiagnosticEventRepository"/> ; la production exécute la même
/// sémantique via COUNT/COUNT(DISTINCT)/FILTER/AVG côté PostgreSQL.
/// </summary>
public class DiagnosticSyncTests
{
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,
DateTimeOffset? ts = null,
Guid? operationId = null,
string level = "Info",
long? durationMs = null) =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
OperationId = operationId,
Level = level,
Priority = "P2",
DurationMs = durationMs,
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticAggregateQuery All() => new() { FromUtc = From, ToUtc = To };
// ------------------------------------------------- S1 : summary multi ----
[Fact]
public async Task GetSyncSummary_MultipleEvents_ReturnsSummary()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001", operationId: Guid.NewGuid()),
Ev("OMK-WKR-SYNC-001", operationId: Guid.NewGuid())
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(2, summary.EventCount);
}
// ------------------------------------------------- S2 : EventCount ----
[Fact]
public async Task GetSyncSummary_EventCount_CountsOnlySync()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001"),
Ev("OMK-WKR-SYNC-001"),
Ev("NOT-SYNC")
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(2, summary.EventCount);
}
// ------------------------------------------------- S3 : OperationCount distinct ----
[Fact]
public async Task GetSyncSummary_OperationCount_IsDistinct()
{
Guid opA = Guid.NewGuid();
Guid opB = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001", operationId: opA),
Ev("OMK-MOB-SYNC-001", operationId: opA), // même opération
Ev("OMK-WKR-SYNC-001", operationId: opB)
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(2, summary.OperationCount);
}
// ------------------------------------------------- S4 : OperationId null exclu ----
[Fact]
public async Task GetSyncSummary_NullOperationId_NotCounted()
{
Guid opA = Guid.NewGuid();
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001", operationId: opA),
Ev("OMK-MOB-SYNC-001", operationId: null) // sans OperationId
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(1, summary.OperationCount);
Assert.Equal(2, summary.EventCount); // l'événement sans OperationId reste compté
}
// ------------------------------------------------- S5 : ErrorCount casse mixte ----
[Fact]
public async Task GetSyncSummary_ErrorCount_MixedCase()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-WKR-SYNC-002", level: "Error"),
Ev("OMK-WKR-SYNC-002", level: "error"),
Ev("OMK-WKR-SYNC-002", level: "Critical"),
Ev("OMK-WKR-SYNC-001", level: "Info")
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(3, summary.ErrorCount);
}
// ------------------------------------------------- S6 : durée avg/min/max ----
[Fact]
public async Task GetSyncSummary_DurationStats_AreCorrect()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-WKR-SYNC-001", durationMs: 100),
Ev("OMK-WKR-SYNC-001", durationMs: 300)
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(2, summary.DurationCount);
Assert.Equal(200, summary.AverageMs);
Assert.Equal(100, summary.MinMs);
Assert.Equal(300, summary.MaxMs);
}
// ------------------------------------------------- S7 : durée NULL ignorée ----
[Fact]
public async Task GetSyncSummary_NullDurations_Ignored()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-WKR-SYNC-001", durationMs: 100),
Ev("OMK-WKR-SYNC-001", durationMs: null)
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(1, summary.DurationCount);
Assert.Equal(100, summary.AverageMs);
}
[Fact]
public async Task GetSyncSummary_NoDurations_NullStats()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001", durationMs: null)
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(0, summary.DurationCount);
Assert.Null(summary.AverageMs);
Assert.Null(summary.MinMs);
Assert.Null(summary.MaxMs);
}
// ------------------------------------------------- S8 : période ----
[Fact]
public async Task GetSyncSummary_RespectsPeriod()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001", From.AddMinutes(-5)), // avant
Ev("OMK-MOB-SYNC-001", From.AddMinutes(5)), // dans
Ev("OMK-MOB-SYNC-001", To.AddMinutes(5)) // après
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(1, summary.EventCount);
}
// ------------------------------------------------- S9 : non-sync exclu ----
[Fact]
public async Task GetSyncSummary_NonSyncExcluded()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001"),
Ev("OMK-MOB-APP-001") // non-sync
});
DiagnosticSyncSummary summary = await repo.GetSyncSummaryAsync(All());
Assert.Equal(1, summary.EventCount);
}
// ------------------------------------------------- S10 : reconnaissance ----
[Theory]
[InlineData("OMK-MOB-SYNC-001", true)]
[InlineData("OMK-WKR-SYNC-001", true)]
[InlineData("OMK-WKR-SYNC-002", true)]
[InlineData("OMK-MOB-APP-001", false)]
[InlineData(null, false)]
public void SyncEventCodes_Recognition(string? code, bool expected)
{
Assert.Equal(expected, DiagnosticSyncEventCodes.IsSyncEventCode(code));
}
// ------------------------------------------------- S11 : distribution EventCode ----
[Fact]
public async Task CountGrouped_EventCode_SyncOnly_ReturnsOnlySyncCodes()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("OMK-MOB-SYNC-001"),
Ev("OMK-MOB-SYNC-001"),
Ev("OMK-WKR-SYNC-001"),
Ev("OMK-MOB-APP-001") // non-sync → exclu
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.EventCode,
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, SyncOnly = true });
Assert.Equal(2, result.Count);
Assert.DoesNotContain(result, g => g.Key == "OMK-MOB-APP-001");
Assert.Contains(result, g => g.Key == "OMK-MOB-SYNC-001" && g.Count == 2);
}
}
@@ -0,0 +1,137 @@
using Omika.Diagnostics.Tasks;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T25 — Tâches de traitement : statuts, mapping d'affichage « traité / non traité »
/// (<see cref="UiTaskContext"/>) et contrat du repository (fake, miroir du Npgsql).
/// </summary>
public class DiagnosticTaskTests
{
private static DiagnosticTaskRecord MakeTask(string status) => new()
{
Id = 1,
GroupUrlIdentifier = "group",
Title = "T",
Status = status,
CreatedByUsername = "alice",
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow
};
// ------------------------------------------------------ statuts ----------------
[Theory]
[InlineData("Todo", DiagnosticTaskStatuses.Todo)]
[InlineData("todo", DiagnosticTaskStatuses.Todo)]
[InlineData("inprogress", DiagnosticTaskStatuses.InProgress)]
[InlineData("RESOLVED", DiagnosticTaskStatuses.Resolved)]
[InlineData("rejected", DiagnosticTaskStatuses.Rejected)]
public void Status_Normalize_IsCaseInsensitive(string raw, string expected) =>
Assert.Equal(expected, DiagnosticTaskStatuses.Normalize(raw));
[Fact]
public void Status_Unknown_FallsBackToTodo() =>
Assert.Equal(DiagnosticTaskStatuses.Todo, DiagnosticTaskStatuses.Normalize("Inconnu"));
[Fact]
public void Status_TerminalAndOpen()
{
Assert.True(DiagnosticTaskStatuses.IsTerminal(DiagnosticTaskStatuses.Resolved));
Assert.True(DiagnosticTaskStatuses.IsTerminal(DiagnosticTaskStatuses.Rejected));
Assert.False(DiagnosticTaskStatuses.IsTerminal(DiagnosticTaskStatuses.Todo));
Assert.False(DiagnosticTaskStatuses.IsTerminal(DiagnosticTaskStatuses.InProgress));
Assert.True(DiagnosticTaskStatuses.IsOpen(DiagnosticTaskStatuses.Todo));
Assert.True(DiagnosticTaskStatuses.IsOpen(DiagnosticTaskStatuses.InProgress));
Assert.False(DiagnosticTaskStatuses.IsOpen(DiagnosticTaskStatuses.Resolved));
}
// -------------------------------------- mapping « traité / non traité » --------
[Fact]
public void Treatment_NoTask_IsATraiter()
{
Assert.Equal("À traiter", UiTaskContext.TreatmentLabel(null));
Assert.Equal("badge badge--danger", UiTaskContext.TreatmentClass(null));
}
[Fact]
public void Treatment_OpenTask_IsEnCours()
{
Assert.Equal("En cours", UiTaskContext.TreatmentLabel(MakeTask(DiagnosticTaskStatuses.Todo)));
Assert.Equal("En cours", UiTaskContext.TreatmentLabel(MakeTask(DiagnosticTaskStatuses.InProgress)));
Assert.Equal("badge badge--warning", UiTaskContext.TreatmentClass(MakeTask(DiagnosticTaskStatuses.InProgress)));
}
[Fact]
public void Treatment_Resolved_IsTraite()
{
Assert.Equal("Traité", UiTaskContext.TreatmentLabel(MakeTask(DiagnosticTaskStatuses.Resolved)));
Assert.Equal("badge badge--success", UiTaskContext.TreatmentClass(MakeTask(DiagnosticTaskStatuses.Resolved)));
}
[Fact]
public void Treatment_Rejected_IsRejete()
{
Assert.Equal("Rejeté", UiTaskContext.TreatmentLabel(MakeTask(DiagnosticTaskStatuses.Rejected)));
Assert.Equal("badge badge--muted", UiTaskContext.TreatmentClass(MakeTask(DiagnosticTaskStatuses.Rejected)));
}
// ------------------------------------------------------ repository -------------
[Fact]
public async Task Repository_Create_AndUnique()
{
var repo = new FakeDiagnosticTaskRepository();
DiagnosticTaskRecord? created = await repo.CreateAsync(
"grp-1", "Titre", null, null, DiagnosticTaskStatuses.Todo, "alice", null);
Assert.NotNull(created);
Assert.Equal(1, created.Id);
Assert.Equal(DiagnosticTaskStatuses.Todo, created.Status);
Assert.Null(created.ResolvedAtUtc);
// Doublon : même signature → null.
DiagnosticTaskRecord? duplicate = await repo.CreateAsync(
"grp-1", "Autre", null, null, DiagnosticTaskStatuses.Todo, "alice", null);
Assert.Null(duplicate);
}
[Fact]
public async Task Repository_CreateTerminal_SetsResolvedAt()
{
var repo = new FakeDiagnosticTaskRepository();
DiagnosticTaskRecord? created = await repo.CreateAsync(
"grp-1", "Titre", null, "cause", DiagnosticTaskStatuses.Resolved, "alice", "bob");
Assert.NotNull(created);
Assert.Equal(DiagnosticTaskStatuses.Resolved, created.Status);
Assert.NotNull(created.ResolvedAtUtc);
Assert.Equal("bob", created.AssigneeUsername);
}
[Fact]
public async Task Repository_Update_SetsAndClearsResolvedAt()
{
var repo = new FakeDiagnosticTaskRepository();
await repo.CreateAsync("grp-1", "Titre", null, null, DiagnosticTaskStatuses.Todo, "alice", null);
bool resolved = await repo.UpdateAsync(
1, "Titre", null, "cause", DiagnosticTaskStatuses.Resolved, null);
Assert.True(resolved);
DiagnosticTaskRecord? task = await repo.GetByIdAsync(1);
Assert.NotNull(task);
Assert.Equal(DiagnosticTaskStatuses.Resolved, task!.Status);
Assert.Equal("cause", task.Cause);
Assert.NotNull(task.ResolvedAtUtc);
// Retour en statut ouvert → la date de résolution est effacée.
await repo.UpdateAsync(1, "Titre", null, "cause", DiagnosticTaskStatuses.InProgress, null);
task = await repo.GetByIdAsync(1);
Assert.Equal(DiagnosticTaskStatuses.InProgress, task!.Status);
Assert.Null(task.ResolvedAtUtc);
}
}
@@ -0,0 +1,167 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Omika.Diagnostics.Configuration;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Repositories;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Worker;
namespace Omika.Diagnostics.Tests;
/// <summary>Rétention télémétrie (telemetry_metric_hourly) : 6 mois, lots bornés, fail-open.</summary>
public class DiagnosticTelemetryRetentionTests
{
private static TelemetryMetricHourly At(DateTimeOffset bucket) =>
new()
{
BucketUtc = bucket,
MetricName = "test.metric",
Component = string.Empty,
Value = 1,
Count = 1,
MinValue = 1,
MaxValue = 1,
TagsJson = "{}"
};
// ---------------------------------------------------------------- repo ----
[Fact]
public async Task DeleteOlderThan_RemovesOnlyOlderThanCutoff()
{
var repo = new FakeTelemetryMetricRepository();
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-180); // 6 mois
await repo.UpsertHourlyAsync([
At(cutoff.AddDays(-10)),
At(cutoff.AddDays(-1)),
At(DateTimeOffset.UtcNow)
]);
int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100);
Assert.Equal(2, deleted);
Assert.Single(repo.All);
}
[Fact]
public async Task DeleteOlderThan_RespectsBatchSize()
{
var repo = new FakeTelemetryMetricRepository();
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-180);
var old = new List<TelemetryMetricHourly>();
for (int i = 0; i < 5; i++)
{
old.Add(At(cutoff.AddDays(-i - 1)));
}
await repo.UpsertHourlyAsync(old);
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_NonPositiveBatchSize_ReturnsZero()
{
var repo = new FakeTelemetryMetricRepository();
await repo.UpsertHourlyAsync([At(DateTimeOffset.UtcNow.AddDays(-200))]);
int deleted = await repo.DeleteOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-180), batchSize: 0);
Assert.Equal(0, deleted);
Assert.Single(repo.All);
}
// -------------------------------------------------------------- worker ----
private static DiagnosticTelemetryRetentionWorker CreateWorker(
ITelemetryMetricRepository repo,
DiagnosticStoreOptions options) =>
new(repo, Options.Create(options), NullLogger<DiagnosticTelemetryRetentionWorker>.Instance);
[Fact]
public async Task TelemetryRetentionWorker_PurgesOlderAggregates()
{
var repo = new FakeTelemetryMetricRepository();
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-180);
await repo.UpsertHourlyAsync([
At(cutoff.AddDays(-5)),
At(cutoff.AddDays(-1)),
At(DateTimeOffset.UtcNow)
]);
var worker = CreateWorker(repo, new DiagnosticStoreOptions
{
TelemetryRetentionDays = 180,
RetentionBatchSize = 100,
RetentionIntervalHours = 1
});
using var cts = new CancellationTokenSource();
await worker.StartAsync(cts.Token);
int remaining = 3;
for (int i = 0; i < 50; i++)
{
remaining = repo.All.Count;
if (remaining == 1)
{
break;
}
await Task.Delay(100);
}
cts.Cancel();
await worker.StopAsync(cts.Token);
Assert.Equal(1, remaining); // seul l'agrégat récent subsiste
}
[Fact]
public async Task TelemetryRetentionWorker_Disabled_DoesNotPurge()
{
var repo = new FakeTelemetryMetricRepository();
await repo.UpsertHourlyAsync([At(DateTimeOffset.UtcNow.AddDays(-200))]);
var worker = CreateWorker(repo, new DiagnosticStoreOptions
{
TelemetryRetentionDays = 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);
Assert.Single(repo.All); // rien de purgé
}
[Fact]
public async Task TelemetryRetentionWorker_DbDown_DoesNotCrash()
{
var worker = CreateWorker(new ThrowingTelemetryMetricRepository(), new DiagnosticStoreOptions
{
TelemetryRetentionDays = 180,
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
}
}
@@ -0,0 +1,251 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 6 — Télémétrie : nouvelles dimensions de groupement (Platform/Level normalisé/
/// Priority/Source) et statistiques de durée (AVG/MIN/MAX en SQL, NULL ignorés). Tests
/// sur <see cref="FakeDiagnosticEventRepository"/> ; la production exécute les mêmes
/// contrats via GROUP BY / INITCAP(LOWER(...)) / AVG/MAX.
/// </summary>
public class DiagnosticTelemetryTests
{
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 = "TEST",
DateTimeOffset? ts = null,
string? component = null,
string level = "Info",
string priority = "P2",
string? platform = null,
string? source = null,
long? durationMs = null) =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
Component = component,
Level = level,
Priority = priority,
Platform = platform,
Source = source ?? DiagnosticSources.Mobile,
DurationMs = durationMs,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticAggregateQuery All(int limit = DiagnosticAggregateDefaults.MaxLimit) =>
new() { FromUtc = From, ToUtc = To, Limit = limit };
// ------------------------------------------------- T1 : groupement Platform ----
[Fact]
public async Task CountGrouped_Platform_ReturnsGroups()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", platform: "Android"),
Ev("B", platform: "Android"),
Ev("C", platform: "iOS")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.Platform, All());
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "Android" && g.Count == 2);
Assert.Contains(result, g => g.Key == "iOS" && g.Count == 1);
}
// ---------------------------------- T2 : groupement Level normalisé (casse) ----
[Fact]
public async Task CountGrouped_Level_IsCaseInsensitive()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", level: "Error"),
Ev("B", level: "error"), // minuscule (ServerDiagnosticMiddleware)
Ev("C", level: "ERROR"),
Ev("D", level: "Info")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.Level, All());
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "Error" && g.Count == 3);
Assert.Contains(result, g => g.Key == "Info" && g.Count == 1);
}
// ----------------------------------------------- T3 : groupement Priority ----
[Fact]
public async Task CountGrouped_Priority_ReturnsGroups()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", priority: "P0"),
Ev("B", priority: "P0"),
Ev("C", priority: "P1")
});
IReadOnlyList<DiagnosticGroupCount> result = await repo.CountGroupedAsync(
DiagnosticGroupDimension.Priority, All());
Assert.Equal(2, result.Count);
Assert.Contains(result, g => g.Key == "P0" && g.Count == 2);
Assert.Contains(result, g => g.Key == "P1" && g.Count == 1);
}
// ---------------------------------------------- T4 : durée moyenne ----
[Fact]
public async Task GetDurationStats_Average_IsCorrect()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", durationMs: 100),
Ev("A", durationMs: 200)
});
DiagnosticDurationStats? stats = await repo.GetDurationStatsAsync(All());
Assert.NotNull(stats);
Assert.Equal(2, stats.Count);
Assert.Equal(150, stats.AverageMs);
}
// ---------------------------------------------- T5 : durée max ----
[Fact]
public async Task GetDurationStats_Max_IsCorrect()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", durationMs: 100),
Ev("A", durationMs: 350)
});
DiagnosticDurationStats? stats = await repo.GetDurationStatsAsync(All());
Assert.NotNull(stats);
Assert.Equal(350, stats.MaxMs);
Assert.Equal(100, stats.MinMs);
}
// ---------------------------------------------- T6 : durée NULL ignorée ----
[Fact]
public async Task GetDurationStats_NullDurations_AreIgnored()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", durationMs: 100),
Ev("B", durationMs: null), // sans durée
Ev("C", durationMs: 200)
});
DiagnosticDurationStats? stats = await repo.GetDurationStatsAsync(All());
Assert.NotNull(stats);
Assert.Equal(2, stats.Count);
Assert.Equal(150, stats.AverageMs);
Assert.Equal(100, stats.MinMs);
Assert.Equal(200, stats.MaxMs);
}
// ---------------------------------------------- T7 : aucune durée → null ----
[Fact]
public async Task GetDurationStats_NoDurations_ReturnsNull()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", durationMs: null),
Ev("B", durationMs: null)
});
DiagnosticDurationStats? stats = await repo.GetDurationStatsAsync(All());
Assert.Null(stats);
}
// ---------------------------------------------- T8 : période appliquée ----
[Fact]
public async Task GetDurationStats_RespectsPeriod()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(-5), durationMs: 999), // avant → exclu
Ev("A", From.AddMinutes(5), durationMs: 100), // dans
Ev("A", To.AddMinutes(5), durationMs: 999) // après → exclu
});
DiagnosticDurationStats? stats = await repo.GetDurationStatsAsync(All());
Assert.NotNull(stats);
Assert.Equal(1, stats.Count);
Assert.Equal(100, stats.AverageMs);
}
// ---------------------------------------------- T9 : filtre EventCode ----
[Fact]
public async Task GetDurationStats_RespectsEventCodeFilter()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", durationMs: 100),
Ev("B", durationMs: 900)
});
DiagnosticDurationStats? stats = await repo.GetDurationStatsAsync(
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, EventCode = "A" });
Assert.NotNull(stats);
Assert.Equal(1, stats.Count);
Assert.Equal(100, stats.AverageMs);
}
// ---------------------------------------------- T10 : top lentes limité ----
[Fact]
public async Task GetTopDurationByEventCode_RespectsLimitAndSort()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("FAST", durationMs: 10),
Ev("SLOW", durationMs: 500),
Ev("MEDIUM", durationMs: 250)
});
IReadOnlyList<DiagnosticDurationByEventCode> result = await repo.GetTopDurationByEventCodeAsync(
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Limit = 2 });
Assert.Equal(2, result.Count);
Assert.Equal("SLOW", result[0].EventCode); // moyenne décroissante
Assert.Equal("MEDIUM", result[1].EventCode);
}
[Fact]
public async Task GetTopDurationByEventCode_ExcludesNullDurations()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", durationMs: 100),
Ev("B", durationMs: null) // sans durée → ne doit pas apparaître
});
IReadOnlyList<DiagnosticDurationByEventCode> result = await repo.GetTopDurationByEventCodeAsync(All());
DiagnosticDurationByEventCode item = Assert.Single(result);
Assert.Equal("A", item.EventCode);
}
}
@@ -0,0 +1,100 @@
using Omika.Core.Observability;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T18 — PageModel /Telemetry : section « Utilisation de l'application » (écrans +
/// fonctionnalités) et filtres d'usage. Testé sans HttpContext.
/// </summary>
public class DiagnosticTelemetryUsageModelTests
{
private static readonly string ScreenView = TelemetryEventCodes.MobileScreenView.Value!;
private static readonly string FeatureUse = TelemetryEventCodes.MobileFeatureUse.Value!;
[Fact]
public async Task TelemetryModel_LoadsScreenAndFeatureUsage()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(
[
Screen("AGENDA", "D-A", Guid.NewGuid(), now.AddMinutes(-10)),
Screen("AGENDA", "D-B", Guid.NewGuid(), now.AddMinutes(-9)),
Screen("SITE_DETAIL", "D-A", Guid.NewGuid(), now.AddMinutes(-8)),
Feature("EMAIL_SEND", "D-A", now.AddMinutes(-7)),
Feature("EMAIL_SEND", "D-B", now.AddMinutes(-6)),
Feature("REPORT_GENERATE", "D-A", now.AddMinutes(-5))
]);
var model = new TelemetryModel(repo) { Period = "30d" };
await model.OnGetAsync();
Assert.True(model.PostgreSqlAvailable);
Assert.True(model.HasUsageData);
ScreenUsageRecord agenda = model.ScreenUsage.Single(s => s.ScreenCode == "AGENDA");
Assert.Equal(2, agenda.Views);
Assert.Equal(2, agenda.DistinctDevices);
FeatureUsageRecord email = model.FeatureUsage.Single(f => f.FeatureCode == "EMAIL_SEND");
Assert.Equal(2, email.Uses);
Assert.Equal(2, email.DistinctDevices);
}
[Fact]
public async Task TelemetryModel_UsageFiltersByScreenAndPlatform()
{
var repo = new FakeDiagnosticEventRepository();
DateTimeOffset now = DateTimeOffset.UtcNow;
await repo.InsertBatchAsync(
[
Screen("AGENDA", "D-A", Guid.NewGuid(), now.AddMinutes(-10), platform: "Android"),
Screen("SITE_DETAIL", "D-A", Guid.NewGuid(), now.AddMinutes(-9), platform: "Android"),
Screen("SITE_DETAIL", "D-B", Guid.NewGuid(), now.AddMinutes(-8), platform: "iOS")
]);
var model = new TelemetryModel(repo) { Period = "30d", ScreenCode = "SITE_DETAIL" };
await model.OnGetAsync();
ScreenUsageRecord item = Assert.Single(model.ScreenUsage);
Assert.Equal("SITE_DETAIL", item.ScreenCode);
Assert.Equal(2, item.Views);
}
private static DiagnosticEventRecord Screen(
string screenCode,
string deviceKey,
Guid sessionId,
DateTimeOffset ts,
string? platform = null) =>
new()
{
EventId = Guid.NewGuid(),
EventCode = ScreenView,
TimestampUtc = ts,
ScreenCode = screenCode,
DeviceKey = deviceKey,
SessionId = sessionId,
Platform = platform,
Source = "mobile",
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticEventRecord Feature(string featureCode, string deviceKey, DateTimeOffset ts) =>
new()
{
EventId = Guid.NewGuid(),
EventCode = FeatureUse,
TimestampUtc = ts,
FeatureCode = featureCode,
DeviceKey = deviceKey,
Source = "mobile",
IngestedAtUtc = DateTimeOffset.UtcNow
};
}
@@ -0,0 +1,47 @@
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>§55 — Traces : timeline chronologique pour un TraceId partagé (Mobile → API → Worker).</summary>
public class DiagnosticTraceTests
{
[Fact]
public async Task GetByTraceId_ReturnsChronologicalTimeline()
{
Guid traceId = Guid.NewGuid();
DateTimeOffset baseTime = new(2025, 1, 1, 10, 0, 0, TimeSpan.Zero);
var repository = new FakeDiagnosticEventRepository();
await repository.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "MOBILE", baseTime.AddMinutes(2), traceId, component: "Mobile"),
DiagnosticTestData.Event(Guid.NewGuid(), "API", baseTime, traceId, component: "Api"),
DiagnosticTestData.Event(Guid.NewGuid(), "WORKER", baseTime.AddMinutes(1), traceId, component: "Worker")
});
var timeline = await repository.GetByTraceIdAsync(traceId);
Assert.Equal(3, timeline.Count);
Assert.Equal(new[] { "API", "WORKER", "MOBILE" }, timeline.Select(e => e.EventCode).ToArray());
}
[Fact]
public async Task GetByOperationId_ReturnsChronologicalTimeline()
{
Guid operationId = Guid.NewGuid();
DateTimeOffset baseTime = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
var repository = new FakeDiagnosticEventRepository();
await repository.InsertBatchAsync(new[]
{
DiagnosticTestData.Event(Guid.NewGuid(), "SYNC_START", baseTime, operationId: operationId),
DiagnosticTestData.Event(Guid.NewGuid(), "SYNC_END", baseTime.AddSeconds(30), operationId: operationId)
});
var timeline = await repository.GetByOperationIdAsync(operationId);
Assert.Equal(2, timeline.Count);
Assert.Equal("SYNC_START", timeline[0].EventCode);
Assert.Equal("SYNC_END", timeline[1].EventCode);
}
}
@@ -0,0 +1,279 @@
using Omika.Core.Observability;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T18 : statistiques d'USAGE (événementiel) — vues/écrans, appareils distincts,
/// sessions distinctes, fonctionnalités. Unité = événements ScreenView/FeatureUse,
/// jamais la dernière observation d'un appareil (§54).
/// </summary>
public class DiagnosticUsageTests
{
private static readonly string ScreenView = TelemetryEventCodes.MobileScreenView.Value!;
private static readonly string FeatureUse = TelemetryEventCodes.MobileFeatureUse.Value!;
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);
[Fact]
public async Task GetScreenUsage_CountsViewsDistinctDevicesDistinctSessions()
{
var repo = new FakeDiagnosticEventRepository();
Guid s1 = Guid.NewGuid();
Guid s2 = Guid.NewGuid();
Guid s3 = Guid.NewGuid();
await repo.InsertBatchAsync(
[
Screen("AGENDA", "D-A", s1, 1),
Screen("AGENDA", "D-A", s1, 2),
Screen("AGENDA", "D-A", s2, 3),
Screen("AGENDA", "D-B", s1, 4),
Screen("AGENDA", "D-B", s3, 5),
Screen("AGENDA", "D-B", s3, 6),
Screen("AGENDA", "D-B", s3, 7),
Screen("AGENDA", "D-B", s3, 8),
Screen("AGENDA", "D-B", s3, 9),
Screen("AGENDA", "D-B", s3, 10)
]);
IReadOnlyList<ScreenUsageRecord> result = await repo.GetScreenUsageAsync(Query());
ScreenUsageRecord item = Assert.Single(result);
Assert.Equal("AGENDA", item.ScreenCode);
Assert.Equal(10, item.Views);
Assert.Equal(2, item.DistinctDevices);
Assert.Equal(3, item.DistinctSessions);
}
[Fact]
public async Task GetScreenUsage_DeviceKeyNull_IgnoredInDistinctDevices()
{
var repo = new FakeDiagnosticEventRepository();
Guid session = Guid.NewGuid();
var events = new List<DiagnosticEventRecord>();
for (int i = 0; i < 5; i++)
{
events.Add(Screen("AGENDA", null, session, 1 + i));
}
events.Add(Screen("AGENDA", "D-A", session, 10));
events.Add(Screen("AGENDA", "D-A", session, 11));
events.Add(Screen("AGENDA", "D-A", session, 12));
events.Add(Screen("AGENDA", "D-A", session, 13));
events.Add(Screen("AGENDA", "D-A", session, 14));
await repo.InsertBatchAsync(events);
ScreenUsageRecord item = Assert.Single(await repo.GetScreenUsageAsync(Query()));
Assert.Equal(10, item.Views);
Assert.Equal(1, item.DistinctDevices);
}
[Fact]
public async Task GetScreenUsage_SessionNull_IgnoredInDistinctSessions()
{
var repo = new FakeDiagnosticEventRepository();
Guid session = Guid.NewGuid();
await repo.InsertBatchAsync(
[
Screen("AGENDA", "D-A", null, 1),
Screen("AGENDA", "D-A", null, 2),
Screen("AGENDA", "D-A", session, 3)
]);
ScreenUsageRecord item = Assert.Single(await repo.GetScreenUsageAsync(Query()));
Assert.Equal(3, item.Views);
Assert.Equal(1, item.DistinctSessions);
}
[Fact]
public async Task GetScreenUsage_NormalizesCaseAndTrims()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(
[
Screen("AGENDA", "D-A", Guid.NewGuid(), 1),
Screen("agenda", "D-A", Guid.NewGuid(), 2),
Screen(" AGENDA ", "D-A", Guid.NewGuid(), 3),
Screen("SITE_DETAIL", "D-A", Guid.NewGuid(), 4)
]);
IReadOnlyList<ScreenUsageRecord> result = await repo.GetScreenUsageAsync(Query());
Assert.Equal(2, result.Count);
Assert.Equal("AGENDA", result[0].ScreenCode);
Assert.Equal(3, result[0].Views);
Assert.Equal("SITE_DETAIL", result[1].ScreenCode);
}
[Fact]
public async Task GetScreenUsage_ExcludesNullOrEmptyScreenCode()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(
[
Screen(null, "D-A", Guid.NewGuid(), 1),
Screen("", "D-A", Guid.NewGuid(), 2),
Screen("AGENDA", "D-A", Guid.NewGuid(), 3)
]);
ScreenUsageRecord item = Assert.Single(await repo.GetScreenUsageAsync(Query()));
Assert.Equal("AGENDA", item.ScreenCode);
Assert.Equal(1, item.Views);
}
[Fact]
public async Task GetScreenUsage_AppliesFilters()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(
[
Screen("AGENDA", "D-A", Guid.NewGuid(), 1, client: "CLIENT-A", license: "LIC-1", platform: "Android", os: "Android", version: "1.4.0"),
Screen("AGENDA", "D-B", Guid.NewGuid(), 2, client: "CLIENT-B", license: "LIC-2", platform: "iOS", os: "iOS", version: "1.4.1"),
Screen("SITE_DETAIL", "D-A", Guid.NewGuid(), 3, client: "CLIENT-A", license: "LIC-1", platform: "Android", os: "Android", version: "1.4.0")
]);
ScreenUsageRecord byClient = Assert.Single(await repo.GetScreenUsageAsync(Query(clientCode: "CLIENT-B")));
Assert.Equal("AGENDA", byClient.ScreenCode);
// LIC-1 est observée sur AGENDA ET SITE_DETAIL (même DeviceKey, deux écrans).
IReadOnlyList<ScreenUsageRecord> byLicense = await repo.GetScreenUsageAsync(Query(licenseNumber: "LIC-1"));
Assert.Equal(2, byLicense.Count);
Assert.Contains(byLicense, s => s.ScreenCode == "AGENDA" && s.Views == 1);
Assert.Contains(byLicense, s => s.ScreenCode == "SITE_DETAIL" && s.Views == 1);
ScreenUsageRecord byPlatform = Assert.Single(await repo.GetScreenUsageAsync(Query(platform: "iOS")));
Assert.Equal("AGENDA", byPlatform.ScreenCode);
ScreenUsageRecord byScreen = Assert.Single(await repo.GetScreenUsageAsync(Query(screenCode: "SITE_DETAIL")));
Assert.Equal("SITE_DETAIL", byScreen.ScreenCode);
Assert.Equal(1, byScreen.Views);
}
[Fact]
public async Task GetScreenUsage_LicenseReassigned_CountsBothDevicesWithoutMerging()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(
[
Screen("AGENDA", "D-A", Guid.NewGuid(), 1, license: "LIC-REASSIGNED"),
Screen("AGENDA", "D-B", Guid.NewGuid(), 2, license: "LIC-REASSIGNED")
]);
IReadOnlyList<ScreenUsageRecord> result = await repo.GetScreenUsageAsync(Query(licenseNumber: "LIC-REASSIGNED"));
ScreenUsageRecord item = Assert.Single(result);
Assert.Equal("AGENDA", item.ScreenCode);
Assert.Equal(2, item.Views);
Assert.Equal(2, item.DistinctDevices); // jamais fusionné en 1 appareil
}
[Fact]
public async Task GetFeatureUsage_CountsUsesAndDistinctDevices()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(
[
Feature("EMAIL_SEND", "D-A", 1),
Feature("EMAIL_SEND", "D-A", 2),
Feature("EMAIL_SEND", "D-B", 3),
Feature("REPORT_GENERATE", "D-A", 4)
]);
IReadOnlyList<FeatureUsageRecord> result = await repo.GetFeatureUsageAsync(Query());
Assert.Equal(2, result.Count);
Assert.Equal("EMAIL_SEND", result[0].FeatureCode);
Assert.Equal(3, result[0].Uses);
Assert.Equal(2, result[0].DistinctDevices);
Assert.Equal("REPORT_GENERATE", result[1].FeatureCode);
Assert.Equal(1, result[1].Uses);
}
[Fact]
public async Task GetFeatureUsage_FiltersByFeatureCode()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(
[
Feature("EMAIL_SEND", "D-A", 1),
Feature("REPORT_GENERATE", "D-A", 2)
]);
FeatureUsageRecord item = Assert.Single(await repo.GetFeatureUsageAsync(Query(featureCode: "REPORT_GENERATE")));
Assert.Equal("REPORT_GENERATE", item.FeatureCode);
Assert.Equal(1, item.Uses);
}
[Fact]
public void UsageTelemetryQuery_Validate_RequiresPeriod()
{
Assert.Throws<ArgumentException>(() => new UsageTelemetryQuery().Validate());
Assert.Throws<ArgumentException>(() => new UsageTelemetryQuery { FromUtc = To, ToUtc = From }.Validate());
}
private static DiagnosticEventRecord Screen(
string? screenCode,
string? deviceKey,
Guid? sessionId,
long id,
string? client = null,
string? license = null,
string? platform = null,
string? os = null,
string? version = null) =>
new()
{
Id = id,
EventId = Guid.NewGuid(),
EventCode = ScreenView,
TimestampUtc = From.AddMinutes(id),
ScreenCode = screenCode,
DeviceKey = deviceKey,
SessionId = sessionId,
ClientCode = client,
LicenseNumber = license,
Platform = platform,
OperatingSystem = os,
AppVersion = version,
Source = "mobile",
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticEventRecord Feature(string featureCode, string deviceKey, long id) =>
new()
{
Id = id,
EventId = Guid.NewGuid(),
EventCode = FeatureUse,
TimestampUtc = From.AddMinutes(id),
FeatureCode = featureCode,
DeviceKey = deviceKey,
Source = "mobile",
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static UsageTelemetryQuery Query(
string? clientCode = null,
string? licenseNumber = null,
string? platform = null,
string? screenCode = null,
string? featureCode = null) =>
new()
{
FromUtc = From,
ToUtc = To,
ClientCode = clientCode,
LicenseNumber = licenseNumber,
Platform = platform,
ScreenCode = screenCode,
FeatureCode = featureCode,
Limit = 50
};
}
@@ -0,0 +1,83 @@
using Omika.Core.Observability;
using Omika.Core.Observability.External;
using Omika.Core.Observability.Outbox;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Models;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T18 (§14/§84) : ScreenCode/FeatureCode traversent le transport (OmikaEvent → DTO →
/// JSONL/Record) ; les données anciennes (NULL) restent compatibles partout.
/// </summary>
public class DiagnosticUsageTransportTests
{
[Fact]
public void Mapper_ToDto_CarriesScreenAndFeatureCode()
{
var @event = new TelemetryEvent
{
EventCode = TelemetryEventCodes.MobileScreenView,
ScreenCode = DiagnosticScreenCodes.Agenda,
FeatureCode = DiagnosticFeatureCodes.EmailSend
};
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(@event, maxStringLength: 256);
Assert.NotNull(dto);
Assert.Equal(DiagnosticScreenCodes.Agenda, dto!.ScreenCode);
Assert.Equal(DiagnosticFeatureCodes.EmailSend, dto.FeatureCode);
}
[Fact]
public void Mapper_ToDto_NullUsage_StaysNull()
{
var @event = new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted };
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(@event, maxStringLength: 256);
Assert.NotNull(dto);
Assert.Null(dto!.ScreenCode);
Assert.Null(dto.FeatureCode);
}
[Fact]
public void RecordMapper_CarriesScreenAndFeatureCode()
{
var record = new RemoteDiagnosticRecord
{
Event = new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = TelemetryEventCodes.MobileScreenView.Value!,
ScreenCode = DiagnosticScreenCodes.SiteDetail,
FeatureCode = DiagnosticFeatureCodes.ReportGenerate
}
};
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Equal(DiagnosticScreenCodes.SiteDetail, mapped!.ScreenCode);
Assert.Equal(DiagnosticFeatureCodes.ReportGenerate, mapped.FeatureCode);
}
[Fact]
public void RecordMapper_OldDataNullUsage_StaysNull()
{
var record = new RemoteDiagnosticRecord
{
Event = new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001"
}
};
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
Assert.NotNull(mapped);
Assert.Null(mapped!.ScreenCode);
Assert.Null(mapped.FeatureCode);
}
}
@@ -0,0 +1,158 @@
using Omika.Diagnostics.Security;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Users;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T23 — Authentification hybride : utilisateurs PostgreSQL en priorité, sinon opérateurs
/// bootstrap. Échec générique (jamais « inconnu » vs « désactivé »). DB autoritaire.
/// </summary>
public class DiagnosticUserAuthenticatorTests
{
private const string Password = "correct-password-123";
private static DiagnosticUserRecord DbUser(string username, string password, string role = DiagnosticRoles.Support, bool enabled = true) =>
new()
{
Id = 1,
Username = username,
PasswordHash = DiagnosticPasswordHasher.Hash(password),
Role = role,
Enabled = enabled
};
private static DiagnosticOperator Operator(string username, string password, params string[] roles) =>
new(username, DiagnosticPasswordHasher.Hash(password), roles);
// -------------------------------------------------- DB prioritaire ----
[Fact]
public async Task DbUser_CorrectPassword_Succeeds()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(DbUser("alice", Password, DiagnosticRoles.Admin));
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo, Array.Empty<DiagnosticOperator>(), "alice", Password);
Assert.True(result.Success);
Assert.True(result.IsDatabaseUser);
Assert.Equal("alice", result.Username);
Assert.Contains(DiagnosticRoles.Admin, result.Roles);
}
[Fact]
public async Task DbUser_WrongPassword_Fails()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(DbUser("alice", Password));
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo, Array.Empty<DiagnosticOperator>(), "alice", "wrong-password-123");
Assert.False(result.Success);
}
[Fact]
public async Task DbUser_Disabled_Fails_EvenWithCorrectPassword()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(DbUser("alice", Password, enabled: false));
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo, Array.Empty<DiagnosticOperator>(), "alice", Password);
Assert.False(result.Success);
}
// -------------------------------------------------- fallback opérateurs ----
[Fact]
public async Task UnknownUser_OperatorFallback_Succeeds()
{
var repo = new FakeDiagnosticUserRepository();
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo,
new[] { Operator("bootstrap", Password, DiagnosticRoles.Admin) },
"bootstrap",
Password);
Assert.True(result.Success);
Assert.False(result.IsDatabaseUser);
Assert.Equal("bootstrap", result.Username);
Assert.Contains(DiagnosticRoles.Admin, result.Roles);
}
[Fact]
public async Task UnknownUser_NoOperators_Fails()
{
var repo = new FakeDiagnosticUserRepository();
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo, Array.Empty<DiagnosticOperator>(), "inconnu", Password);
Assert.False(result.Success);
}
[Fact]
public async Task Operator_WrongPassword_Fails()
{
var repo = new FakeDiagnosticUserRepository();
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo,
new[] { Operator("bootstrap", Password, DiagnosticRoles.Admin) },
"bootstrap",
"wrong-password-123");
Assert.False(result.Success);
}
// -------------------------------------------------- priorité DB (§36) ----
[Fact]
public async Task DbIsAuthoritative_DisabledDbUser_DoesNotFallbackToOperator()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(DbUser("dup", Password, enabled: false));
// Même username existe aussi en configuration (bootstrap) avec un mot de passe valide :
// la DB doit rester autoritaire → échec, PAS de fallback.
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo,
new[] { Operator("dup", Password, DiagnosticRoles.Admin) },
"dup",
Password);
Assert.False(result.Success);
}
// -------------------------------------------------- cas limites ----
[Theory]
[InlineData("", "password-123")]
[InlineData(" ", "password-123")]
[InlineData("alice", "")]
[InlineData("alice", " ")]
public async Task BlankInput_Fails(string username, string password)
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(DbUser("alice", Password));
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo, Array.Empty<DiagnosticOperator>(), username, password);
Assert.False(result.Success);
}
[Fact]
public async Task DbLookup_CaseInsensitive()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(DbUser("Alice.Dupont", Password, DiagnosticRoles.Support));
DiagnosticLoginResult result = await DiagnosticUserAuthenticator.AuthenticateAsync(
repo, Array.Empty<DiagnosticOperator>(), "alice.dupont", Password);
Assert.True(result.Success);
Assert.Equal("Alice.Dupont", result.Username);
}
}
@@ -0,0 +1,320 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Omika.Diagnostics.Security;
using Omika.Diagnostics.Tests.Fakes;
using Omika.Diagnostics.Users;
using Omika.Diagnostics.Web.Pages;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T23 — PageModels /Users (création, doublon, désactivation, rétrogradation, reset,
/// dernier Admin, auto-*) et /Account (changement de son propre mot de passe).
/// </summary>
public class DiagnosticUserPageModelTests
{
private const string Password = "correct-password-123";
private const string NewPassword = "nouveau-password-456";
private static DiagnosticUserRecord User(long id, string username, string role, bool enabled = true) =>
new()
{
Id = id,
Username = username,
PasswordHash = DiagnosticPasswordHasher.Hash(Password),
Role = role,
Enabled = enabled,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
PasswordChangedAtUtc = DateTimeOffset.UtcNow
};
private static UsersModel NewUsersModel(FakeDiagnosticUserRepository repo, string actor) =>
WithActor(new UsersModel(repo), actor);
private static T WithActor<T>(T model, string actor) where T : PageModel
{
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, actor) }, "test");
var principal = new ClaimsPrincipal(identity);
model.PageContext = new PageContext { HttpContext = new DefaultHttpContext { User = principal } };
return model;
}
// -------------------------------------------------- création ----
[Fact]
public async Task Create_StoresHash_NotPlaintext()
{
var repo = new FakeDiagnosticUserRepository();
var model = NewUsersModel(repo, "admin1");
model.NewUsername = "jean.dupont";
model.NewPassword = Password;
model.NewPasswordConfirmation = Password;
model.NewRole = DiagnosticRoles.Support;
await model.OnPostCreateAsync();
DiagnosticUserRecord? created = await repo.GetByUsernameAsync("jean.dupont");
Assert.NotNull(created);
Assert.NotEqual(Password, created!.PasswordHash);
Assert.DoesNotContain(Password, created.PasswordHash, StringComparison.Ordinal);
Assert.True(DiagnosticPasswordHasher.Verify(Password, created.PasswordHash));
Assert.Equal(DiagnosticRoles.Support, created.Role);
}
[Fact]
public async Task Create_DuplicateCaseInsensitive_Refused()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "Jean.Dupont", DiagnosticRoles.Support));
var model = NewUsersModel(repo, "admin1");
model.NewUsername = "jean.dupont";
model.NewPassword = Password;
model.NewPasswordConfirmation = Password;
model.NewRole = DiagnosticRoles.Support;
await model.OnPostCreateAsync();
Assert.Equal("Ce nom d'utilisateur existe déjà.", model.ErrorMessage);
Assert.Single(await repo.ListAsync());
}
[Theory]
[InlineData("mauvais username")]
[InlineData("")]
[InlineData("accentué")]
public async Task Create_InvalidUsername_Refused(string username)
{
var repo = new FakeDiagnosticUserRepository();
var model = NewUsersModel(repo, "admin1");
model.NewUsername = username;
model.NewPassword = Password;
model.NewPasswordConfirmation = Password;
model.NewRole = DiagnosticRoles.Support;
await model.OnPostCreateAsync();
Assert.Contains("Nom d'utilisateur invalide", model.ErrorMessage);
Assert.Empty(await repo.ListAsync());
}
[Fact]
public async Task Create_ShortPassword_Refused()
{
var repo = new FakeDiagnosticUserRepository();
var model = NewUsersModel(repo, "admin1");
model.NewUsername = "jean";
model.NewPassword = "court";
model.NewPasswordConfirmation = "court";
model.NewRole = DiagnosticRoles.Support;
await model.OnPostCreateAsync();
Assert.Contains("au moins 12 caractères", model.ErrorMessage);
Assert.Empty(await repo.ListAsync());
}
[Fact]
public async Task Create_PasswordMismatch_Refused()
{
var repo = new FakeDiagnosticUserRepository();
var model = NewUsersModel(repo, "admin1");
model.NewUsername = "jean";
model.NewPassword = Password;
model.NewPasswordConfirmation = "different-password";
model.NewRole = DiagnosticRoles.Support;
await model.OnPostCreateAsync();
Assert.Contains("confirmation", model.ErrorMessage);
Assert.Empty(await repo.ListAsync());
}
[Fact]
public async Task Create_InvalidRole_Refused()
{
var repo = new FakeDiagnosticUserRepository();
var model = NewUsersModel(repo, "admin1");
model.NewUsername = "jean";
model.NewPassword = Password;
model.NewPasswordConfirmation = Password;
model.NewRole = "Owner";
await model.OnPostCreateAsync();
Assert.Contains("Rôle invalide", model.ErrorMessage);
Assert.Empty(await repo.ListAsync());
}
// -------------------------------------------------- désactivation ----
[Fact]
public async Task Disable_LastActiveAdmin_Refused()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-db", DiagnosticRoles.Admin, enabled: true));
var model = NewUsersModel(repo, "bootstrap-admin"); // acteur opérateur (hors DB)
await model.OnPostToggleAsync(1, enabled: false);
Assert.Equal(DiagnosticUserGuard.LastAdminMessage, model.ErrorMessage);
Assert.True((await repo.GetByUsernameAsync("admin-db"))!.Enabled);
}
[Fact]
public async Task Disable_NonLastAdmin_Succeeds()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-a", DiagnosticRoles.Admin), User(2, "admin-b", DiagnosticRoles.Admin));
var model = NewUsersModel(repo, "admin-a");
await model.OnPostToggleAsync(2, enabled: false);
Assert.Null(model.ErrorMessage);
Assert.False((await repo.GetByUsernameAsync("admin-b"))!.Enabled);
}
[Fact]
public async Task Disable_Self_Refused()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-a", DiagnosticRoles.Admin), User(2, "admin-b", DiagnosticRoles.Admin));
var model = NewUsersModel(repo, "admin-a");
await model.OnPostToggleAsync(1, enabled: false);
Assert.Equal(DiagnosticUserGuard.SelfDisableMessage, model.ErrorMessage);
Assert.True((await repo.GetByUsernameAsync("admin-a"))!.Enabled);
}
[Fact]
public async Task Enable_DisabledUser_Succeeds()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "support-a", DiagnosticRoles.Support, enabled: false));
var model = NewUsersModel(repo, "admin-a");
await model.OnPostToggleAsync(1, enabled: true);
Assert.Null(model.ErrorMessage);
Assert.True((await repo.GetByUsernameAsync("support-a"))!.Enabled);
}
// -------------------------------------------------- changement de rôle ----
[Fact]
public async Task Demote_LastActiveAdmin_Refused()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-db", DiagnosticRoles.Admin, enabled: true));
var model = NewUsersModel(repo, "bootstrap-admin");
await model.OnPostSetRoleAsync(1, DiagnosticRoles.Support);
Assert.Equal(DiagnosticUserGuard.LastAdminMessage, model.ErrorMessage);
Assert.Equal(DiagnosticRoles.Admin, (await repo.GetByUsernameAsync("admin-db"))!.Role);
}
[Fact]
public async Task Demote_Self_Refused()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-a", DiagnosticRoles.Admin), User(2, "admin-b", DiagnosticRoles.Admin));
var model = NewUsersModel(repo, "admin-a");
await model.OnPostSetRoleAsync(1, DiagnosticRoles.Support);
Assert.Equal(DiagnosticUserGuard.SelfDemoteMessage, model.ErrorMessage);
Assert.Equal(DiagnosticRoles.Admin, (await repo.GetByUsernameAsync("admin-a"))!.Role);
}
[Fact]
public async Task Promote_SupportToAdmin_Succeeds()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-a", DiagnosticRoles.Admin), User(2, "support-a", DiagnosticRoles.Support));
var model = NewUsersModel(repo, "admin-a");
await model.OnPostSetRoleAsync(2, DiagnosticRoles.Admin);
Assert.Null(model.ErrorMessage);
Assert.Equal(DiagnosticRoles.Admin, (await repo.GetByUsernameAsync("support-a"))!.Role);
}
[Fact]
public async Task SetRole_InvalidRole_Refused()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-a", DiagnosticRoles.Admin), User(2, "support-a", DiagnosticRoles.Support));
var model = NewUsersModel(repo, "admin-a");
await model.OnPostSetRoleAsync(2, "Owner");
Assert.Contains("Rôle invalide", model.ErrorMessage);
Assert.Equal(DiagnosticRoles.Support, (await repo.GetByUsernameAsync("support-a"))!.Role);
}
// -------------------------------------------------- reset mot de passe ----
[Fact]
public async Task ResetPassword_OldRejected_NewAccepted()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "support-a", DiagnosticRoles.Support));
var model = NewUsersModel(repo, "admin-a");
model.ResetPassword = NewPassword;
model.ResetPasswordConfirmation = NewPassword;
await model.OnPostResetPasswordAsync(1);
DiagnosticUserRecord? user = await repo.GetByUsernameAsync("support-a");
Assert.False(DiagnosticPasswordHasher.Verify(Password, user!.PasswordHash)); // ancien refusé
Assert.True(DiagnosticPasswordHasher.Verify(NewPassword, user.PasswordHash)); // nouveau accepté
}
// -------------------------------------------------- Mon compte ----
[Fact]
public async Task Account_ChangeOwnPassword_CorrectCurrent_Succeeds()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-a", DiagnosticRoles.Admin));
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, "admin-a"),
new Claim(Omika.Diagnostics.Web.Authorization.DiagnosticClaimTypes.UserSource, "db")
}, "test");
var model = new AccountModel(repo);
model.CurrentPassword = Password;
model.NewPassword = NewPassword;
model.NewPasswordConfirmation = NewPassword;
model.PageContext = new PageContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } };
await model.OnPostAsync();
DiagnosticUserRecord? user = await repo.GetByUsernameAsync("admin-a");
Assert.Equal("Mot de passe modifié.", model.SuccessMessage);
Assert.True(DiagnosticPasswordHasher.Verify(NewPassword, user!.PasswordHash));
}
[Fact]
public async Task Account_WrongCurrentPassword_HashUnchanged()
{
var repo = new FakeDiagnosticUserRepository();
repo.Seed(User(1, "admin-a", DiagnosticRoles.Admin));
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, "admin-a"),
new Claim(Omika.Diagnostics.Web.Authorization.DiagnosticClaimTypes.UserSource, "db")
}, "test");
var model = new AccountModel(repo);
model.CurrentPassword = "mauvais-mot-de-passe";
model.NewPassword = NewPassword;
model.NewPasswordConfirmation = NewPassword;
model.PageContext = new PageContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } };
await model.OnPostAsync();
DiagnosticUserRecord? user = await repo.GetByUsernameAsync("admin-a");
Assert.Equal("Mot de passe actuel incorrect.", model.ErrorMessage);
Assert.True(DiagnosticPasswordHasher.Verify(Password, user!.PasswordHash)); // hash inchangé
}
}
@@ -0,0 +1,139 @@
using Omika.Diagnostics.Security;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T23 — Règles de validation des utilisateurs (username/password/rôle) + protections
/// d'administration (dernier Admin, auto-désactivation, auto-rétrogradation) + hash factice.
/// </summary>
public class DiagnosticUserPolicyTests
{
// -------------------------------------------------- username ----
[Theory]
[InlineData("jean.dupont")]
[InlineData("admin-local")]
[InlineData("a_b-c.d")]
[InlineData("ABC123")]
public void Username_Valid(string username) =>
Assert.True(DiagnosticUserRules.IsValidUsername(username));
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("avec espace")]
[InlineData("accentué")]
[InlineData("a/b")]
[InlineData("a@b")]
[InlineData("a;b")]
public void Username_Invalid(string username) =>
Assert.False(DiagnosticUserRules.IsValidUsername(username));
[Fact]
public void Username_TooLong_Rejected()
{
string tooLong = new string('a', DiagnosticUserRules.MaxUsernameLength + 1);
Assert.False(DiagnosticUserRules.IsValidUsername(tooLong));
Assert.True(DiagnosticUserRules.IsValidUsername(new string('a', DiagnosticUserRules.MaxUsernameLength)));
}
[Fact]
public void NormalizeUsername_Trims()
{
Assert.Equal("jean", DiagnosticUserRules.NormalizeUsername(" jean "));
Assert.Null(DiagnosticUserRules.NormalizeUsername(" "));
Assert.Null(DiagnosticUserRules.NormalizeUsername(null));
}
// -------------------------------------------------- password ----
[Fact]
public void Password_Minimum12()
{
Assert.True(DiagnosticUserRules.IsValidPassword("123456789012"));
Assert.True(DiagnosticUserRules.IsValidPassword("abcdefghijkl"));
Assert.False(DiagnosticUserRules.IsValidPassword("12345678901")); // 11
Assert.False(DiagnosticUserRules.IsValidPassword(""));
Assert.False(DiagnosticUserRules.IsValidPassword(null));
}
// -------------------------------------------------- role ----
[Fact]
public void Role_OnlySupportOrAdmin()
{
Assert.True(DiagnosticUserRules.IsValidRole("Support"));
Assert.True(DiagnosticUserRules.IsValidRole("Admin"));
Assert.True(DiagnosticUserRules.IsValidRole("admin"));
Assert.False(DiagnosticUserRules.IsValidRole("Owner"));
Assert.False(DiagnosticUserRules.IsValidRole(""));
Assert.False(DiagnosticUserRules.IsValidRole(null));
}
// -------------------------------------------------- guard : dernier Admin ----
[Fact]
public void Guard_Disable_LastActiveAdmin_Refused()
{
bool ok = DiagnosticUserGuard.CanDisable("admin1", "admin2", targetIsActiveAdmin: true, activeAdminCount: 1, out string? error);
Assert.False(ok);
Assert.Equal(DiagnosticUserGuard.LastAdminMessage, error);
}
[Fact]
public void Guard_Disable_NonLastAdmin_Allowed()
{
bool ok = DiagnosticUserGuard.CanDisable("admin1", "admin2", targetIsActiveAdmin: true, activeAdminCount: 2, out string? error);
Assert.True(ok);
Assert.Null(error);
}
[Fact]
public void Guard_Disable_SupportUser_Allowed()
{
bool ok = DiagnosticUserGuard.CanDisable("admin1", "support1", targetIsActiveAdmin: false, activeAdminCount: 1, out string? error);
Assert.True(ok);
Assert.Null(error);
}
// -------------------------------------------------- guard : auto-désactivation ----
[Fact]
public void Guard_Disable_Self_Refused()
{
bool ok = DiagnosticUserGuard.CanDisable("admin1", "admin1", targetIsActiveAdmin: true, activeAdminCount: 2, out string? error);
Assert.False(ok);
Assert.Equal(DiagnosticUserGuard.SelfDisableMessage, error);
}
// -------------------------------------------------- guard : auto-rétrogradation ----
[Fact]
public void Guard_Demote_LastActiveAdmin_Refused()
{
bool ok = DiagnosticUserGuard.CanDemoteFromAdmin("admin1", "admin2", activeAdminCount: 1, out string? error);
Assert.False(ok);
Assert.Equal(DiagnosticUserGuard.LastAdminMessage, error);
}
[Fact]
public void Guard_Demote_Self_Refused()
{
bool ok = DiagnosticUserGuard.CanDemoteFromAdmin("admin1", "admin1", activeAdminCount: 2, out string? error);
Assert.False(ok);
Assert.Equal(DiagnosticUserGuard.SelfDemoteMessage, error);
}
[Fact]
public void Guard_Demote_NonLastAdmin_Allowed()
{
bool ok = DiagnosticUserGuard.CanDemoteFromAdmin("admin1", "admin2", activeAdminCount: 2, out string? error);
Assert.True(ok);
Assert.Null(error);
}
// -------------------------------------------------- hash factice ----
[Fact]
public void DummyHash_ValidFormatAndDoesNotMatchArbitraryPassword()
{
Assert.False(DiagnosticPasswordHasher.Verify("un-mot-de-passe-arbitraire", DiagnosticPasswordHasher.DummyHash));
// Format valide : 3 parties, itérations positives (compatible Verify).
string[] parts = DiagnosticPasswordHasher.DummyHash.Split('.');
Assert.Equal(3, parts.Length);
Assert.True(int.TryParse(parts[0], out int iterations) && iterations > 0);
}
}
@@ -0,0 +1,249 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 7 — Versions applicatives : résumés par version et totaux. Tests sur
/// <see cref="FakeDiagnosticEventRepository"/> ; la production exécute la même sémantique
/// via COUNT/FILTER/MIN/MAX/COUNT(DISTINCT) côté PostgreSQL.
/// </summary>
public class DiagnosticVersionTests
{
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 = "TEST",
DateTimeOffset? ts = null,
string? appVersion = null,
string level = "Info",
string? platform = null) =>
new()
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts ?? From.AddMinutes(1),
AppVersion = appVersion,
Level = level,
Priority = "P2",
Platform = platform,
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticAggregateQuery All(int limit = DiagnosticAggregateDefaults.MaxLimit) =>
new() { FromUtc = From, ToUtc = To, Limit = limit };
// ------------------------------------------------- V1 : deux versions ----
[Fact]
public async Task GetVersionSummaries_TwoVersions_TwoSummaries()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0"),
Ev("B", appVersion: "2.0.0")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
Assert.Equal(2, result.Count);
}
// ------------------------------------------------- V2 : EventCount ----
[Fact]
public async Task GetVersionSummaries_EventCount_IsCorrect()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0"),
Ev("B", appVersion: "1.0.0"),
Ev("C", appVersion: "1.0.0"),
Ev("D", appVersion: "2.0.0")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
DiagnosticVersionSummary v1 = Assert.Single(result, x => x.AppVersion == "1.0.0");
Assert.Equal(3, v1.EventCount);
}
// --------------------------------- V3 : ErrorCount casse mixte ----
[Fact]
public async Task GetVersionSummaries_ErrorCount_HandlesMixedCase()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0", level: "Error"),
Ev("B", appVersion: "1.0.0", level: "error"), // minuscule
Ev("C", appVersion: "1.0.0", level: "Critical"),
Ev("D", appVersion: "1.0.0", level: "Info")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
DiagnosticVersionSummary v1 = Assert.Single(result);
Assert.Equal(3, v1.ErrorCount); // Error + error + Critical ; Info exclu
}
// --------------------------------- V4 : FirstSeen/LastSeen ----
[Fact]
public async Task GetVersionSummaries_FirstAndLastSeen_AreCorrect()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(10), appVersion: "1.0.0"),
Ev("B", From.AddMinutes(5), appVersion: "1.0.0"),
Ev("C", From.AddMinutes(20), appVersion: "1.0.0")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
DiagnosticVersionSummary v1 = Assert.Single(result);
Assert.Equal(From.AddMinutes(5), v1.FirstSeenUtc);
Assert.Equal(From.AddMinutes(20), v1.LastSeenUtc);
}
// --------------------------------- V5 : PlatformCount distinct ----
[Fact]
public async Task GetVersionSummaries_PlatformCount_IsDistinct()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0", platform: "Android"),
Ev("B", appVersion: "1.0.0", platform: "Android"),
Ev("C", appVersion: "1.0.0", platform: "iOS")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
DiagnosticVersionSummary v1 = Assert.Single(result);
Assert.Equal(2, v1.PlatformCount); // Android + iOS (distinct)
}
// --------------------------------- V6 : version null exclue ----
[Fact]
public async Task GetVersionSummaries_NullVersion_IsExcluded()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0"),
Ev("B", appVersion: null),
Ev("C", appVersion: string.Empty)
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
DiagnosticVersionSummary v1 = Assert.Single(result);
Assert.Equal("1.0.0", v1.AppVersion);
}
// --------------------------------- V7 : période appliquée ----
[Fact]
public async Task GetVersionSummaries_RespectsPeriod()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", From.AddMinutes(-5), appVersion: "1.0.0"), // avant → exclu
Ev("B", From.AddMinutes(5), appVersion: "2.0.0"), // dans
Ev("C", To.AddMinutes(5), appVersion: "3.0.0") // après → exclu
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
DiagnosticVersionSummary v = Assert.Single(result);
Assert.Equal("2.0.0", v.AppVersion);
}
// --------------------------------- V8 : limit appliqué ----
[Fact]
public async Task GetVersionSummaries_RespectsLimit()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0"),
Ev("B", appVersion: "2.0.0"),
Ev("C", appVersion: "3.0.0")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(
new DiagnosticAggregateQuery { FromUtc = From, ToUtc = To, Limit = 2 });
Assert.Equal(2, result.Count);
}
// --------------------------------- V9 : tri déterministe ----
[Fact]
public async Task GetVersionSummaries_SortsByEventCountThenVersion()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0"),
Ev("B", appVersion: "2.0.0"),
Ev("C", appVersion: "2.0.0")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
Assert.Equal(new[] { "2.0.0", "1.0.0" }, result.Select(x => x.AppVersion).ToArray());
}
[Fact]
public async Task GetVersionSummaries_EqualCounts_TieBreaksByVersion()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0"),
Ev("B", appVersion: "2.0.0")
});
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
Assert.Equal(new[] { "1.0.0", "2.0.0" }, result.Select(x => x.AppVersion).ToArray());
}
// --------------------------------- V10 : aucune donnée ----
[Fact]
public async Task GetVersionSummaries_NoData_ReturnsEmpty()
{
var repo = new FakeDiagnosticEventRepository();
IReadOnlyList<DiagnosticVersionSummary> result = await repo.GetVersionSummariesAsync(All());
Assert.Empty(result);
}
// --------------------------------- Totals ----
[Fact]
public async Task GetVersionTotals_ReturnsAllCounters()
{
var repo = new FakeDiagnosticEventRepository();
await repo.InsertBatchAsync(new[]
{
Ev("A", appVersion: "1.0.0"),
Ev("B", appVersion: "1.0.0"),
Ev("C", appVersion: "2.0.0"),
Ev("D", appVersion: "2.0.0", level: "Error"),
Ev("E", appVersion: null),
Ev("F", appVersion: string.Empty)
});
DiagnosticVersionTotals totals = await repo.GetVersionTotalsAsync(All());
Assert.Equal(2, totals.DistinctVersionCount);
Assert.Equal(4, totals.VersionedEventCount);
Assert.Equal(1, totals.VersionedErrorCount);
Assert.Equal(2, totals.MissingVersionCount);
}
}
@@ -0,0 +1,123 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Omika.Diagnostics.Configuration;
using Omika.Diagnostics.Data;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Worker;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Vérifie que le conteneur DI du Diagnostics Worker construit réellement
/// <see cref="DiagnosticImporter"/> et les hosted services (régression de
/// l'incident production « Unable to resolve service for type
/// DiagnosticImportOptions »). Le host est construit avec
/// <c>ValidateOnBuild</c> + <c>ValidateScopes</c> pour détecter ce type d'erreur
/// à la construction (le test échouait avec l'ancien code).
/// </summary>
public class DiagnosticWorkerDependencyInjectionTests
{
[Fact]
public void WorkerHost_ServiceProviderBuilds_WithValidation()
{
// Avec l'ancien constructeur (DiagnosticImportOptions concret), ce Build()
// levait « Unable to resolve service for type DiagnosticImportOptions ».
using IHost host = BuildWorkerHost();
Assert.NotNull(host.Services);
}
[Fact]
public void WorkerHost_DiagnosticImporter_IsResolvable()
{
using IHost host = BuildWorkerHost();
DiagnosticImporter importer = host.Services.GetRequiredService<DiagnosticImporter>();
Assert.NotNull(importer);
}
[Fact]
public void WorkerHost_DiagnosticImportOptions_AreResolvedAndBound()
{
using IHost host = BuildWorkerHost();
DiagnosticImportOptions options = host.Services
.GetRequiredService<IOptions<DiagnosticImportOptions>>()
.Value;
Assert.NotNull(options);
// La configuration in-memory prime sur les valeurs par défaut (BatchSize=100).
Assert.Equal(50, options.BatchSize);
Assert.Equal("import-checkpoint.json", options.CheckpointFileName);
Assert.Equal(@"C:\Omika\Data\DiagnosticsWorker\Checkpoint", options.CheckpointDirectory);
}
[Fact]
public void WorkerHost_DiagnosticStoreOptions_ConnectionStringOverride_Wins()
{
using IHost host = BuildWorkerHost();
DiagnosticStoreOptions store = host.Services
.GetRequiredService<IOptions<DiagnosticStoreOptions>>()
.Value;
// La surcharge standard ConnectionStrings:DiagnosticsDatabase (variable
// d'environnement serveur) prime sur Diagnostics:ConnectionString.
Assert.Equal(
"Host=localhost;Database=omika_diagnostics;Username=diagnostics_worker",
store.ConnectionString);
}
[Fact]
public void WorkerHost_HostedServices_AreResolvable()
{
using IHost host = BuildWorkerHost();
List<IHostedService> hosted = host.Services.GetServices<IHostedService>().ToList();
Assert.Contains(hosted, s => s is DiagnosticImportWorker);
Assert.Contains(hosted, s => s is DiagnosticRetentionWorker);
Assert.Contains(hosted, s => s is DiagnosticTelemetryRetentionWorker);
}
private static IHost BuildWorkerHost()
{
// Configuration in-memory : aucune valeur secrète. La chaîne PostgreSQL
// est un placeholder sans mot de passe (jamais ouverte : les repositories
// sont construits sans connexion).
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Diagnostics:ConnectionString"] = "Host=ignored;Database=ignored",
["Diagnostics:Import:BatchSize"] = "50",
["Diagnostics:Import:CheckpointFileName"] = "import-checkpoint.json",
["Diagnostics:Import:CheckpointDirectory"] = @"C:\Omika\Data\DiagnosticsWorker\Checkpoint",
["Observability:Remote:Directory"] = @"C:\Omika\Data\Api\Diagnostics\Jsonl",
// Surcharge standard (serveur) : prime sur Diagnostics:ConnectionString.
["ConnectionStrings:DiagnosticsDatabase"] = "Host=localhost;Database=omika_diagnostics;Username=diagnostics_worker"
})
.Build();
// Reproduit Program.cs du Diagnostics Worker, avec validation DI stricte
// (ValidateOnBuild + ValidateScopes) pour piéger les dépendances non
// enregistrées.
return new HostBuilder()
.UseDefaultServiceProvider(options =>
{
options.ValidateOnBuild = true;
options.ValidateScopes = true;
})
.ConfigureServices(services =>
{
services.AddLogging();
services.AddOmikaDiagnosticData(configuration);
services.AddHostedService<DiagnosticImportWorker>();
services.AddHostedService<DiagnosticRetentionWorker>();
services.AddHostedService<DiagnosticTelemetryRetentionWorker>();
})
.Build();
}
}
@@ -0,0 +1,95 @@
using System.Text.Json;
using Omika.Core.Observability.External;
using Omika.Core.Observability.Outbox;
using Omika.Diagnostics.Models;
namespace Omika.Diagnostics.Tests.Fakes;
/// <summary>Fabriques d'événements et d'enregistrements JSONL pour les tests.</summary>
public static class DiagnosticTestData
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static RemoteDiagnosticRecord Record(
Guid eventId,
string eventCode = "TEST",
DateTimeOffset? timestamp = null,
Guid? traceId = null,
Guid? sessionId = null,
Guid? operationId = null,
string? component = null,
string level = "Info",
string priority = "P2",
string? errorCode = null,
long? durationMs = null,
Dictionary<string, object?>? metadata = null,
string? actorKey = null,
int? actorKeyVersion = null,
string? clientCode = null,
string? serverInstance = null,
string? environment = null)
{
DateTimeOffset ts = timestamp ?? DateTimeOffset.UtcNow;
return new RemoteDiagnosticRecord
{
ReceivedAtUtc = ts,
BatchId = Guid.NewGuid(),
IngestionTraceId = Guid.NewGuid().ToString("D"),
ActorKey = actorKey,
ActorKeyVersion = actorKeyVersion,
ClientCode = clientCode,
ServerInstance = serverInstance,
Environment = environment,
Event = new DiagnosticEventDto
{
EventId = eventId,
EventCode = eventCode,
TimestampUtc = ts,
Level = level,
Priority = priority,
Component = component,
TraceId = traceId,
SessionId = sessionId,
OperationId = operationId,
ErrorCode = errorCode,
DurationMs = durationMs,
Metadata = metadata
}
};
}
public static DiagnosticEventRecord Event(
Guid eventId,
string eventCode = "TEST",
DateTimeOffset? timestamp = null,
Guid? traceId = null,
Guid? operationId = null,
string? component = null,
string level = "Info",
string? errorCode = null,
long? durationMs = null,
string? actorKey = null,
int? actorKeyVersion = null)
{
return new DiagnosticEventRecord
{
EventId = eventId,
EventCode = eventCode,
TimestampUtc = timestamp ?? DateTimeOffset.UtcNow,
TraceId = traceId,
OperationId = operationId,
Component = component,
Level = level,
Priority = "P2",
ErrorCode = errorCode,
DurationMs = durationMs,
ActorKey = actorKey,
ActorKeyVersion = actorKeyVersion,
Source = DiagnosticSources.Mobile,
IngestedAtUtc = DateTimeOffset.UtcNow
};
}
public static string SerializeLine(RemoteDiagnosticRecord record) =>
JsonSerializer.Serialize(record, JsonOptions);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Omika.Core.Observability.External;
using Omika.Diagnostics.Import;
namespace Omika.Diagnostics.Tests.Fakes;
/// <summary>
/// Source JSONL en mémoire : modèle un flux de <see cref="RemoteDiagnosticRecord"/>
/// avec un checkpoint (LineIndex = index du dernier enregistrement lu). Permet de
/// tester l'importeur et son avancement de checkpoint sans PostgreSQL.
/// </summary>
public sealed class FakeDiagnosticJsonlSource : IDiagnosticJsonlSource
{
private readonly List<RemoteDiagnosticRecord> _records;
private DiagnosticImportCheckpoint? _checkpoint;
public FakeDiagnosticJsonlSource(IReadOnlyList<RemoteDiagnosticRecord> records)
{
_records = records.ToList();
}
public int CommitCount { get; private set; }
public DiagnosticImportCheckpoint? LastCommitted { get; private set; }
public DiagnosticImportCheckpoint? LoadCheckpoint() => _checkpoint;
public DiagnosticJsonlReadResult ReadNext(DiagnosticImportCheckpoint? after, int maxCount)
{
int start = after is null ? 0 : after.LineIndex + 1;
if (start >= _records.Count)
{
return new DiagnosticJsonlReadResult { ReachedEnd = true };
}
int count = Math.Min(maxCount, _records.Count - start);
List<RemoteDiagnosticRecord> records = _records.GetRange(start, count);
var next = new DiagnosticImportCheckpoint
{
FileName = "fake.jsonl",
LineIndex = start + count - 1
};
return new DiagnosticJsonlReadResult
{
Records = records,
NextPosition = next,
ReachedEnd = start + count >= _records.Count
};
}
public void Commit(DiagnosticImportCheckpoint position)
{
_checkpoint = position;
LastCommitted = position;
CommitCount++;
}
}
@@ -0,0 +1,158 @@
using Omika.Diagnostics.Referential;
namespace Omika.Diagnostics.Tests.Fakes;
/// <summary>
/// Fake en mémoire de <see cref="IDiagnosticReferenceRepository"/> pour les tests sans
/// PostgreSQL. Reproduit la sémantique du repository Npgsql : normalisation TRIM+UPPER,
/// unicité (type, code), code immuable, archivage (enabled=false) sans DELETE. Les codes
/// « observés » (pour <see cref="GetUnreferencedAsync"/>) sont injectés via
/// <see cref="SeedObserved"/> (miroir du DISTINCT diagnostic_event, sans base réelle).
/// </summary>
public sealed class FakeDiagnosticReferenceRepository : IDiagnosticReferenceRepository
{
private readonly List<DiagnosticReferenceRecord> _references = [];
private readonly List<(string Type, string Code, long Count, DateTimeOffset LastSeen)> _observed = [];
private long _nextId = 1;
public IReadOnlyList<DiagnosticReferenceRecord> Seed(params DiagnosticReferenceRecord[] records)
{
foreach (DiagnosticReferenceRecord r in records)
{
if (r.Id == 0)
{
r.Id = _nextId++;
}
else
{
_nextId = Math.Max(_nextId, r.Id + 1);
}
_references.Add(r);
}
return _references;
}
public void SeedObserved(string type, string code, long count, DateTimeOffset lastSeen) =>
_observed.Add((type, code, count, lastSeen));
public Task<IReadOnlyList<DiagnosticReferenceRecord>> ListAsync(
CancellationToken cancellationToken = default)
{
IReadOnlyList<DiagnosticReferenceRecord> ordered = _references
.OrderBy(r => r.ReferenceType, StringComparer.Ordinal)
.ThenBy(r => r.SortOrder)
.ThenBy(r => r.Code, StringComparer.Ordinal)
.ToList();
return Task.FromResult(ordered);
}
public Task<DiagnosticReferenceRecord?> GetByTypeAndCodeAsync(
string referenceType,
string code,
CancellationToken cancellationToken = default)
{
string? normalized = DiagnosticReferenceNormalization.NormalizeCode(code);
DiagnosticReferenceRecord? found = _references.FirstOrDefault(r =>
r.ReferenceType == referenceType && r.Code == normalized);
return Task.FromResult(found);
}
public Task<DiagnosticReferenceRecord?> GetByIdAsync(
long id,
CancellationToken cancellationToken = default)
{
return Task.FromResult(_references.FirstOrDefault(r => r.Id == id));
}
public Task<DiagnosticReferenceRecord?> CreateAsync(
string referenceType,
string code,
string label,
string? description,
bool enabled,
int sortOrder,
CancellationToken cancellationToken = default)
{
string? normalizedCode = DiagnosticReferenceNormalization.NormalizeCode(code);
string? normalizedLabel = DiagnosticReferenceNormalization.NormalizeLabel(label);
if (!DiagnosticReferenceTypes.IsSupported(referenceType) || normalizedCode is null || normalizedLabel is null)
{
return Task.FromResult<DiagnosticReferenceRecord?>(null);
}
if (_references.Any(r => r.ReferenceType == referenceType && r.Code == normalizedCode))
{
return Task.FromResult<DiagnosticReferenceRecord?>(null); // doublon.
}
DateTimeOffset now = DateTimeOffset.UtcNow;
var record = new DiagnosticReferenceRecord
{
Id = _nextId++,
ReferenceType = referenceType,
Code = normalizedCode,
Label = normalizedLabel,
Description = DiagnosticReferenceNormalization.NormalizeLabel(description),
Enabled = enabled,
SortOrder = sortOrder,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
_references.Add(record);
return Task.FromResult<DiagnosticReferenceRecord?>(record);
}
public Task<bool> UpdateAsync(
long id,
string label,
string? description,
bool enabled,
int sortOrder,
CancellationToken cancellationToken = default)
{
string? normalizedLabel = DiagnosticReferenceNormalization.NormalizeLabel(label);
DiagnosticReferenceRecord? record = _references.FirstOrDefault(r => r.Id == id);
if (record is null || normalizedLabel is null)
{
return Task.FromResult(false);
}
record.Label = normalizedLabel;
record.Description = DiagnosticReferenceNormalization.NormalizeLabel(description);
record.Enabled = enabled;
record.SortOrder = sortOrder;
record.UpdatedAtUtc = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
public Task<IReadOnlyDictionary<string, string>> GetLabelsAsync(
string referenceType,
CancellationToken cancellationToken = default)
{
var labels = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (DiagnosticReferenceRecord r in _references.Where(r => r.ReferenceType == referenceType))
{
labels[r.Code] = r.Label;
}
return Task.FromResult<IReadOnlyDictionary<string, string>>(labels);
}
public Task<IReadOnlyList<DiagnosticUnreferencedCode>> GetUnreferencedAsync(
int limit,
CancellationToken cancellationToken = default)
{
var result = _observed
.Where(o => !_references.Any(r => r.ReferenceType == o.Type && r.Code == o.Code))
.Select(o => new DiagnosticUnreferencedCode(o.Type, o.Code, o.Count, o.LastSeen))
.OrderByDescending(x => x.ObservedCount)
.ThenBy(x => x.ReferenceType, StringComparer.Ordinal)
.ThenBy(x => x.Code, StringComparer.Ordinal)
.Take(Math.Clamp(limit, 1, 200))
.ToList();
return Task.FromResult<IReadOnlyList<DiagnosticUnreferencedCode>>(result);
}
}
@@ -0,0 +1,135 @@
using Omika.Diagnostics.Tasks;
namespace Omika.Diagnostics.Tests.Fakes;
/// <summary>
/// Fake en mémoire de <see cref="IDiagnosticTaskRepository"/> pour les tests sans
/// PostgreSQL. Reproduit la sémantique du repository Npgsql : unicité par signature
/// (group_url_identifier), statut normalisé, date de résolution dérivée du statut.
/// </summary>
public sealed class FakeDiagnosticTaskRepository : IDiagnosticTaskRepository
{
private readonly List<DiagnosticTaskRecord> _tasks = [];
private long _nextId = 1;
public IReadOnlyList<DiagnosticTaskRecord> Seed(params DiagnosticTaskRecord[] tasks)
{
foreach (DiagnosticTaskRecord t in tasks)
{
if (t.Id == 0)
{
t.Id = _nextId++;
}
else
{
_nextId = Math.Max(_nextId, t.Id + 1);
}
_tasks.Add(t);
}
return _tasks;
}
public Task<IReadOnlyList<DiagnosticTaskRecord>> ListAsync(
CancellationToken cancellationToken = default)
{
IReadOnlyList<DiagnosticTaskRecord> ordered = _tasks
.OrderByDescending(t => t.UpdatedAtUtc)
.ThenByDescending(t => t.Id)
.ToList();
return Task.FromResult(ordered);
}
public Task<DiagnosticTaskRecord?> GetByIdAsync(
long id,
CancellationToken cancellationToken = default) =>
Task.FromResult(_tasks.FirstOrDefault(t => t.Id == id));
public Task<DiagnosticTaskRecord?> GetByGroupAsync(
string groupUrlIdentifier,
CancellationToken cancellationToken = default) =>
Task.FromResult(_tasks.FirstOrDefault(t => t.GroupUrlIdentifier == groupUrlIdentifier));
public Task<DiagnosticTaskRecord?> CreateAsync(
string groupUrlIdentifier,
string title,
string? description,
string? cause,
string status,
string createdByUsername,
string? assigneeUsername,
CancellationToken cancellationToken = default)
{
string? group = Normalize(groupUrlIdentifier);
string? normalizedTitle = Normalize(title);
if (group is null || normalizedTitle is null)
{
return Task.FromResult<DiagnosticTaskRecord?>(null);
}
if (_tasks.Any(t => t.GroupUrlIdentifier == group))
{
return Task.FromResult<DiagnosticTaskRecord?>(null); // doublon.
}
string normalizedStatus = DiagnosticTaskStatuses.Normalize(status);
DateTimeOffset now = DateTimeOffset.UtcNow;
var record = new DiagnosticTaskRecord
{
Id = _nextId++,
GroupUrlIdentifier = group,
Title = normalizedTitle,
Description = Normalize(description),
Cause = Normalize(cause),
Status = normalizedStatus,
CreatedByUsername = createdByUsername,
AssigneeUsername = Normalize(assigneeUsername),
CreatedAtUtc = now,
UpdatedAtUtc = now,
ResolvedAtUtc = DiagnosticTaskStatuses.IsTerminal(normalizedStatus) ? now : null
};
_tasks.Add(record);
return Task.FromResult<DiagnosticTaskRecord?>(record);
}
public Task<bool> UpdateAsync(
long id,
string title,
string? description,
string? cause,
string status,
string? assigneeUsername,
CancellationToken cancellationToken = default)
{
DiagnosticTaskRecord? task = _tasks.FirstOrDefault(t => t.Id == id);
string? normalizedTitle = Normalize(title);
if (task is null || normalizedTitle is null)
{
return Task.FromResult(false);
}
string normalizedStatus = DiagnosticTaskStatuses.Normalize(status);
task.Title = normalizedTitle;
task.Description = Normalize(description);
task.Cause = Normalize(cause);
task.Status = normalizedStatus;
task.AssigneeUsername = Normalize(assigneeUsername);
task.UpdatedAtUtc = DateTimeOffset.UtcNow;
task.ResolvedAtUtc = DiagnosticTaskStatuses.IsTerminal(normalizedStatus)
? task.ResolvedAtUtc ?? DateTimeOffset.UtcNow
: null;
return Task.FromResult(true);
}
public Task<bool> DeleteAsync(
long id,
CancellationToken cancellationToken = default)
{
int removed = _tasks.RemoveAll(t => t.Id == id);
return Task.FromResult(removed > 0);
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,149 @@
using Omika.Diagnostics.Users;
namespace Omika.Diagnostics.Tests.Fakes;
/// <summary>
/// Fake en mémoire de <see cref="IDiagnosticUserRepository"/> pour les tests sans PostgreSQL.
/// Reproduit la sémantique du repository Npgsql : unicité insensible à la casse, tri par
/// username, hash stocké (jamais de mot de passe en clair).
/// </summary>
public sealed class FakeDiagnosticUserRepository : IDiagnosticUserRepository
{
private readonly List<DiagnosticUserRecord> _users = [];
private long _nextId = 1;
public IReadOnlyList<DiagnosticUserRecord> Seed(params DiagnosticUserRecord[] users)
{
foreach (DiagnosticUserRecord u in users)
{
if (u.Id == 0)
{
u.Id = _nextId++;
}
else
{
_nextId = Math.Max(_nextId, u.Id + 1);
}
_users.Add(u);
}
return _users;
}
public Task<DiagnosticUserRecord?> GetByUsernameAsync(
string username,
CancellationToken cancellationToken = default)
{
string? normalized = username?.Trim();
DiagnosticUserRecord? found = _users.FirstOrDefault(u =>
string.Equals(u.Username, normalized, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(found);
}
public Task<IReadOnlyList<DiagnosticUserRecord>> ListAsync(
CancellationToken cancellationToken = default)
{
IReadOnlyList<DiagnosticUserRecord> ordered = _users
.OrderBy(u => u.Username, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult(ordered);
}
public Task<DiagnosticUserRecord?> CreateAsync(
string username,
string passwordHash,
string role,
CancellationToken cancellationToken = default)
{
string? normalized = username?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return Task.FromResult<DiagnosticUserRecord?>(null);
}
if (_users.Any(u => string.Equals(u.Username, normalized, StringComparison.OrdinalIgnoreCase)))
{
return Task.FromResult<DiagnosticUserRecord?>(null); // doublon.
}
DateTimeOffset now = DateTimeOffset.UtcNow;
var record = new DiagnosticUserRecord
{
Id = _nextId++,
Username = normalized,
PasswordHash = passwordHash,
Role = role,
Enabled = true,
CreatedAtUtc = now,
UpdatedAtUtc = now,
PasswordChangedAtUtc = now,
LastLoginAtUtc = null
};
_users.Add(record);
return Task.FromResult<DiagnosticUserRecord?>(record);
}
public Task<bool> SetEnabledAsync(
long id,
bool enabled,
CancellationToken cancellationToken = default)
{
DiagnosticUserRecord? user = _users.FirstOrDefault(u => u.Id == id);
if (user is null)
{
return Task.FromResult(false);
}
user.Enabled = enabled;
user.UpdatedAtUtc = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
public Task<bool> SetRoleAsync(
long id,
string role,
CancellationToken cancellationToken = default)
{
DiagnosticUserRecord? user = _users.FirstOrDefault(u => u.Id == id);
if (user is null)
{
return Task.FromResult(false);
}
user.Role = role;
user.UpdatedAtUtc = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
public Task<bool> UpdatePasswordHashAsync(
long id,
string passwordHash,
CancellationToken cancellationToken = default)
{
DiagnosticUserRecord? user = _users.FirstOrDefault(u => u.Id == id);
if (user is null)
{
return Task.FromResult(false);
}
user.PasswordHash = passwordHash;
user.PasswordChangedAtUtc = DateTimeOffset.UtcNow;
user.UpdatedAtUtc = DateTimeOffset.UtcNow;
return Task.FromResult(true);
}
public Task UpdateLastLoginAsync(
string username,
CancellationToken cancellationToken = default)
{
DiagnosticUserRecord? user = _users.FirstOrDefault(u =>
string.Equals(u.Username, username?.Trim(), StringComparison.OrdinalIgnoreCase));
if (user is not null)
{
user.LastLoginAtUtc = DateTimeOffset.UtcNow;
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,88 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Repositories;
namespace Omika.Diagnostics.Tests.Fakes;
/// <summary>
/// Repository télémétrie in-memory (sans PostgreSQL) : reproduit le contrat
/// <c>ITelemetryMetricRepository</c> (upsert + purge bornée par bucket horaire).
/// </summary>
public sealed class FakeTelemetryMetricRepository : ITelemetryMetricRepository
{
private readonly List<TelemetryMetricHourly> _all = new();
private readonly object _sync = new();
private long _nextId = 1;
public IReadOnlyList<TelemetryMetricHourly> All
{
get
{
lock (_sync)
{
return _all.ToList();
}
}
}
public Task UpsertHourlyAsync(
IReadOnlyList<TelemetryMetricHourly> metrics,
CancellationToken cancellationToken = default)
{
lock (_sync)
{
foreach (TelemetryMetricHourly m in metrics)
{
m.Id = _nextId++;
_all.Add(m);
}
}
return Task.CompletedTask;
}
public Task<int> DeleteOlderThanAsync(
DateTimeOffset cutoff,
int batchSize,
CancellationToken cancellationToken = default)
{
if (batchSize <= 0)
{
return Task.FromResult(0);
}
lock (_sync)
{
List<TelemetryMetricHourly> toDelete = _all
.Where(m => m.BucketUtc < cutoff)
.OrderBy(m => m.BucketUtc)
.ThenBy(m => m.Id)
.Take(batchSize)
.ToList();
foreach (TelemetryMetricHourly m in toDelete)
{
_all.Remove(m);
}
return Task.FromResult(toDelete.Count);
}
}
}
/// <summary>
/// Simule PostgreSQL DOWN pour la télémétrie : toutes les opérations lèvent.
/// La boucle de rétention doit survivre (fail-open).
/// </summary>
public sealed class ThrowingTelemetryMetricRepository : ITelemetryMetricRepository
{
public Task UpsertHourlyAsync(
IReadOnlyList<TelemetryMetricHourly> metrics,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<int> DeleteOlderThanAsync(
DateTimeOffset cutoff,
int batchSize,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
}
@@ -0,0 +1,205 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Queries;
using Omika.Diagnostics.Repositories;
namespace Omika.Diagnostics.Tests.Fakes;
/// <summary>
/// Simule PostgreSQL DOWN (§52) : toutes les opérations lèvent, la sonde retourne
/// <c>false</c>. L'importeur doit survivre (résultat retryable), laisser le JSONL
/// intact et ne pas avancer le checkpoint.
/// </summary>
public sealed class ThrowingDiagnosticRepository : IDiagnosticEventRepository
{
public Task<int> InsertBatchAsync(
IReadOnlyList<DiagnosticEventRecord> events,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DiagnosticQueryResult<DiagnosticEventRecord>> QueryAsync(
DiagnosticEventQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticEventRecord>> GetByTraceIdAsync(
Guid traceId,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticEventRecord>> GetByOperationIdAsync(
Guid operationId,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(false);
public Task<int> DeleteOlderThanAsync(
DateTimeOffset cutoff,
int batchSize,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<long> DeleteErrorGroupAsync(
DiagnosticErrorGroupIdentity identity,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticGroupCount>> CountGroupedAsync(
DiagnosticGroupDimension dimension,
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticBucketCount>> CountByTimeBucketAsync(
DiagnosticTimeBucket bucket,
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticBucketClientCount>> CountByTimeBucketAndClientAsync(
DiagnosticTimeBucket bucket,
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticLicenseErrorCount>> CountLicensesByErrorsAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticGroupCount>> CountErrorsByClientAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DiagnosticQueryResult<DiagnosticEventRecord>> SearchAsync(
DiagnosticSearchQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticErrorGroup>> GroupErrorsAsync(
DiagnosticErrorGroupQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<long> CountAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<long> CountErrorGroupsAsync(
DiagnosticErrorGroupQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DiagnosticDurationStats?> GetDurationStatsAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticDurationByEventCode>> GetTopDurationByEventCodeAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticVersionSummary>> GetVersionSummariesAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DiagnosticVersionTotals> GetVersionTotalsAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DiagnosticSyncSummary> GetSyncSummaryAsync(
DiagnosticAggregateQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DiagnosticErrorGroup?> GetErrorGroupAsync(
DiagnosticErrorGroupOccurrenceQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticEventRecord>> GetErrorGroupOccurrencesAsync(
DiagnosticErrorGroupOccurrenceQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticGroupCount>> CountErrorGroupByClientAsync(
DiagnosticErrorGroupOccurrenceQuery query,
int limit,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticLicenseErrorCount>> CountErrorGroupByLicenseAsync(
DiagnosticErrorGroupOccurrenceQuery query,
int limit,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DiagnosticEventRecord?> GetByIdAsync(
long id,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticEventRecord>> GetCorrelatedAsync(
DiagnosticCorrelationQuery query,
CancellationToken cancellationToken = default)
{
query.Validate();
// Sans identifiant, le contrat exige « aucune requête » (§T3/17) : le résultat
// vide est retourné SANS toucher PostgreSQL, même si la base est down.
return query.HasAnyIdentifier
? throw new InvalidOperationException("PostgreSQL DOWN simulé")
: Task.FromResult<IReadOnlyList<DiagnosticEventRecord>>([]);
}
public Task<IReadOnlyList<string>> GetFilterOptionsAsync(
DiagnosticFilterDimension dimension,
DateTimeOffset fromUtc,
int limit,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DiagnosticRecentCorrelation>> GetRecentCorrelationsAsync(
DiagnosticCorrelationDimension dimension,
DateTimeOffset fromUtc,
int limit,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<long> CountActiveDevicesAsync(
DeviceFleetQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DeviceDistribution>> CountDevicesGroupedAsync(
DeviceFleetDimension dimension,
DeviceFleetQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<DeviceFleetQueryResult> GetDevicesAsync(
DeviceFleetQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<DeviceDistribution>> CountDevicesByLicenseAsync(
DeviceFleetQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<ScreenUsageRecord>> GetScreenUsageAsync(
UsageTelemetryQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
public Task<IReadOnlyList<FeatureUsageRecord>> GetFeatureUsageAsync(
UsageTelemetryQuery query,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("PostgreSQL DOWN simulé");
}
@@ -0,0 +1,366 @@
using System.Text;
using Omika.Core.Observability.External;
using Omika.Diagnostics.Data;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// §5C.1C — Lecture sûre des lignes JSONL : une ligne finale SANS newline n'est
/// jamais retournée ni checkpointée (relue à la passe suivante). Une ligne poison
/// COMPLÈTE (terminée par newline) reste sautée et fait avancer le curseur.
/// </summary>
public class JsonlCompleteLineReaderTests
{
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
// ---- Splitteur pur (LF / CRLF / fragment) ------------------------------
[Fact]
public void SplitCompleteLines_WithLf_ReturnsAllLines()
{
byte[] bytes = Utf8NoBom.GetBytes("a\nb\nc\n");
string[] lines = JsonlCompleteLineReader.SplitCompleteLines(bytes, bytes.Length);
Assert.Equal(new[] { "a", "b", "c" }, lines);
}
[Fact]
public void SplitCompleteLines_WithCrLf_ReturnsLinesWithoutCr()
{
byte[] bytes = Utf8NoBom.GetBytes("a\r\nb\r\n");
string[] lines = JsonlCompleteLineReader.SplitCompleteLines(bytes, bytes.Length);
Assert.Equal(new[] { "a", "b" }, lines);
}
[Fact]
public void SplitCompleteLines_TrailingFragment_IsExcluded()
{
byte[] bytes = Utf8NoBom.GetBytes("a\nb\npartial");
string[] lines = JsonlCompleteLineReader.SplitCompleteLines(bytes, bytes.Length);
Assert.Equal(new[] { "a", "b" }, lines); // "partial" sans newline est exclu
}
// ---- Reader : lignes complètes LF / CRLF -------------------------------
[Fact]
public void CompleteLine_WithLf_IsRead()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Single(read.Records);
Assert.Equal(r1.Event.EventId, read.Records[0].Event.EventId);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void CompleteLine_WithCrLf_IsRead()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\r\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Single(read.Records);
Assert.Equal(r1.Event.EventId, read.Records[0].Event.EventId);
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Ligne finale partielle -------------------------------------------
[Fact]
public void PartialFinalLine_IsNotReturned()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
RemoteDiagnosticRecord r2 = DiagnosticTestData.Record(Guid.NewGuid(), "B");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n" + DiagnosticTestData.SerializeLine(r2)); // r2 sans newline
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Single(read.Records);
Assert.Equal(r1.Event.EventId, read.Records[0].Event.EventId); // r2 (fragment) non retourné
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void PartialFinalLine_DoesNotAdvanceCheckpoint()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
RemoteDiagnosticRecord r2 = DiagnosticTestData.Record(Guid.NewGuid(), "B");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n" + DiagnosticTestData.SerializeLine(r2)); // fragment
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Equal(0, read.NextPosition!.LineIndex); // seul r1 (index 0) est checkpointé
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void PartialLineCompletedLater_IsReadOnNextPass()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
RemoteDiagnosticRecord r2 = DiagnosticTestData.Record(Guid.NewGuid(), "B");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n" + DiagnosticTestData.SerializeLine(r2)); // fragment r2
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult first = source.ReadNext(null, 10);
Assert.Single(first.Records);
source.Commit(first.NextPosition!);
AppendRaw(file, "\n"); // complète le fragment
DiagnosticImportCheckpoint? checkpoint = source.LoadCheckpoint();
DiagnosticJsonlReadResult second = source.ReadNext(checkpoint, 10);
Assert.Single(second.Records);
Assert.Equal(r2.Event.EventId, second.Records[0].Event.EventId);
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Ligne poison complète ---------------------------------------------
[Fact]
public void CompletePoisonLine_IsSkippedAndCheckpointAdvances()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, "{ json invalide\n" + DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Single(read.Records);
Assert.Equal(1, read.NextPosition!.LineIndex); // poison (0) sautée, r1 (1)
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Fichier qui grandit ----------------------------------------------
[Fact]
public void GrowingFile_NewCompleteLineReadNextPass()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
RemoteDiagnosticRecord r2 = DiagnosticTestData.Record(Guid.NewGuid(), "B");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult first = source.ReadNext(null, 10);
source.Commit(first.NextPosition!);
AppendRaw(file, DiagnosticTestData.SerializeLine(r2) + "\n");
DiagnosticJsonlReadResult second = source.ReadNext(source.LoadCheckpoint(), 10);
Assert.Single(second.Records);
Assert.Equal(r2.Event.EventId, second.Records[0].Event.EventId);
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Rotation / fichier disparu ----------------------------------------
[Fact]
public void RotatedFileDuringRead_NoPermanentFailure()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
RemoteDiagnosticRecord r2 = DiagnosticTestData.Record(Guid.NewGuid(), "B");
string file1 = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
string file2 = Path.Combine(dir, "omika-mobile-diagnostic-20260101-002.jsonl");
WriteRaw(file1, DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult first = source.ReadNext(null, 10);
source.Commit(first.NextPosition!);
File.Delete(file1); // rotation : 001 supprimé
WriteRaw(file2, DiagnosticTestData.SerializeLine(r2) + "\n");
DiagnosticJsonlReadResult second = source.ReadNext(source.LoadCheckpoint(), 10);
Assert.Single(second.Records);
Assert.Equal(r2.Event.EventId, second.Records[0].Event.EventId);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void MissingFile_NoPermanentFailure()
{
string dir = CreateTempDir();
try
{
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10); // aucun fichier
Assert.Empty(read.Records);
Assert.True(read.ReachedEnd);
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Checkpoint corrompu ----------------------------------------------
[Fact]
public void CorruptCheckpoint_FailsSafe()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n");
WriteRaw(Path.Combine(dir, "import-checkpoint.json"), "{ pas du json valide");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
Assert.Null(source.LoadCheckpoint()); // fail-safe → replay
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Single(read.Records);
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Checkpoint séparé de la source ------------------------------------
[Fact]
public void CheckpointDirectory_CanDifferFromSource()
{
string srcDir = CreateTempDir();
string cpDir = Path.Combine(Path.GetTempPath(), "omika-cp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(cpDir);
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string file = Path.Combine(srcDir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(srcDir, "import-checkpoint.json", cpDir);
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
source.Commit(read.NextPosition!);
Assert.True(File.Exists(Path.Combine(cpDir, "import-checkpoint.json")));
Assert.False(File.Exists(Path.Combine(srcDir, "import-checkpoint.json")));
}
finally
{
Directory.Delete(srcDir, recursive: true);
Directory.Delete(cpDir, recursive: true);
}
}
[Fact]
public void CheckpointDirectory_DefaultCompatibility()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(file, DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json"); // pas de checkpointDir
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
source.Commit(read.NextPosition!);
Assert.True(File.Exists(Path.Combine(dir, "import-checkpoint.json")));
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Staging *.copying -------------------------------------------------
[Fact]
public void CopyingFile_IsIgnored()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string copying = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl.copying");
WriteRaw(copying, DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Empty(read.Records);
Assert.True(read.ReachedEnd);
}
finally { Directory.Delete(dir, recursive: true); }
}
[Fact]
public void CopyingRenamedToJsonl_IsImported()
{
string dir = CreateTempDir();
try
{
RemoteDiagnosticRecord r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string copying = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl.copying");
string final = Path.Combine(dir, "omika-mobile-diagnostic-20260101-001.jsonl");
WriteRaw(copying, DiagnosticTestData.SerializeLine(r1) + "\n");
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
Assert.Empty(source.ReadNext(null, 10).Records); // .copying ignoré
File.Move(copying, final); // rename atomique
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Single(read.Records);
Assert.Equal(r1.Event.EventId, read.Records[0].Event.EventId);
}
finally { Directory.Delete(dir, recursive: true); }
}
// ---- Helpers -----------------------------------------------------------
private static void WriteRaw(string path, string content) =>
File.WriteAllText(path, content, Utf8NoBom);
private static void AppendRaw(string path, string content) =>
File.AppendAllText(path, content, Utf8NoBom);
private static string CreateTempDir()
{
string dir = Path.Combine(Path.GetTempPath(), "omika-diag-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
}
@@ -0,0 +1,77 @@
using Omika.Diagnostics.Data;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>§21 — Checkpoint : reprise après restart / PostgreSQL down, jamais depuis zéro.</summary>
public class JsonlDiagnosticSourceCheckpointTests
{
[Fact]
public void ReadNext_Commit_ThenResumeFromCheckpoint()
{
string dir = CreateTempDir();
try
{
var r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
var r2 = DiagnosticTestData.Record(Guid.NewGuid(), "B");
var r3 = DiagnosticTestData.Record(Guid.NewGuid(), "C");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20250101-001.jsonl");
File.WriteAllLines(file, new[]
{
DiagnosticTestData.SerializeLine(r1),
DiagnosticTestData.SerializeLine(r2),
DiagnosticTestData.SerializeLine(r3)
});
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult first = source.ReadNext(null, 2);
Assert.Equal(2, first.Records.Count);
Assert.Equal(1, first.NextPosition!.LineIndex);
source.Commit(first.NextPosition);
DiagnosticImportCheckpoint? checkpoint = source.LoadCheckpoint();
Assert.NotNull(checkpoint);
Assert.Equal(1, checkpoint!.LineIndex);
DiagnosticJsonlReadResult second = source.ReadNext(checkpoint, 10);
Assert.Single(second.Records);
Assert.Equal(r3.Event.EventId, second.Records[0].Event.EventId);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void PoisonLine_IsSkipped_CursorAdvances()
{
string dir = CreateTempDir();
try
{
var r1 = DiagnosticTestData.Record(Guid.NewGuid(), "A");
string file = Path.Combine(dir, "omika-mobile-diagnostic-20250101-001.jsonl");
File.WriteAllLines(file, new[] { "{ json invalide", DiagnosticTestData.SerializeLine(r1) });
var source = new JsonlDiagnosticSource(dir, "import-checkpoint.json");
DiagnosticJsonlReadResult read = source.ReadNext(null, 10);
Assert.Single(read.Records);
Assert.Equal(1, read.NextPosition!.LineIndex); // la ligne poison ne bloque pas
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
private static string CreateTempDir()
{
string dir = Path.Combine(Path.GetTempPath(), "omika-diag-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
}
@@ -0,0 +1,83 @@
using Omika.Diagnostics.Web.Authorization;
namespace Omika.Diagnostics.Tests;
/// <summary>T23 — Anti-bruteforce léger (in-memory) : blocage temporaire après N échecs.</summary>
public class LoginThrottleTests
{
private static readonly DateTimeOffset Now = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void BlocksOnlyAfterMaxAttempts()
{
var throttle = new LoginThrottle();
string key = LoginThrottle.Key("alice", "127.0.0.1");
for (int i = 0; i < LoginThrottle.MaxAttempts; i++)
{
throttle.RecordFailure(key, Now.AddSeconds(i));
}
Assert.True(throttle.IsBlocked(key, Now.AddSeconds(LoginThrottle.MaxAttempts)));
}
[Fact]
public void NotBlockedBelowThreshold()
{
var throttle = new LoginThrottle();
string key = LoginThrottle.Key("alice", "127.0.0.1");
for (int i = 0; i < LoginThrottle.MaxAttempts - 1; i++)
{
throttle.RecordFailure(key, Now.AddSeconds(i));
}
Assert.False(throttle.IsBlocked(key, Now.AddSeconds(LoginThrottle.MaxAttempts)));
}
[Fact]
public void LockoutExpires()
{
var throttle = new LoginThrottle();
string key = LoginThrottle.Key("alice", "127.0.0.1");
for (int i = 0; i < LoginThrottle.MaxAttempts; i++)
{
throttle.RecordFailure(key, Now.AddSeconds(i));
}
Assert.True(throttle.IsBlocked(key, Now.AddSeconds(60)));
Assert.False(throttle.IsBlocked(key, Now + LoginThrottle.Lockout + TimeSpan.FromSeconds(1)));
}
[Fact]
public void ResetClearsFailures()
{
var throttle = new LoginThrottle();
string key = LoginThrottle.Key("alice", "127.0.0.1");
for (int i = 0; i < LoginThrottle.MaxAttempts; i++)
{
throttle.RecordFailure(key, Now.AddSeconds(i));
}
throttle.Reset(key);
Assert.False(throttle.IsBlocked(key, Now.AddSeconds(60)));
}
[Fact]
public void KeyIsInsensitiveToCaseAndTrims()
{
Assert.Equal(
LoginThrottle.Key(" Alice ", "127.0.0.1"),
LoginThrottle.Key("alice", "127.0.0.1"));
}
[Fact]
public void KeySeparatesIps()
{
Assert.NotEqual(
LoginThrottle.Key("alice", "127.0.0.1"),
LoginThrottle.Key("alice", "10.0.0.1"));
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!--
Tests du Diagnostic Center SANS PostgreSQL réel (§51) : fake repository,
repository jetant (PostgreSQL down), mappers, checkpoint, sécurité, pagination.
-->
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Diagnostics.Core\Omika.Diagnostics.Core.csproj" />
<ProjectReference Include="..\..\src\Omika.Diagnostics.Data\Omika.Diagnostics.Data.csproj" />
<ProjectReference Include="..\..\src\Omika.Diagnostics.Worker\Omika.Diagnostics.Worker.csproj" />
<ProjectReference Include="..\..\src\Omika.Diagnostics.Web\Omika.Diagnostics.Web.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,130 @@
using Microsoft.Extensions.Options;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Diagnostics.Data;
using Omika.Diagnostics.Import;
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Tests.Fakes;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// Tâche 13-E — E12 + §38 : compatibilité d'un JSONL produit par Omika.Worker avec le
/// pipeline d'import (JsonlDiagnosticSource → DiagnosticImporter → DiagnosticEventRecord)
/// et NON-ÉCRASEMENT du contexte du producteur par Omika.Diagnostics.Worker.
///
/// Un événement Worker portant <c>ServerInstance = WKR-SRV-01</c> doit arriver dans
/// <c>DiagnosticEventRecord</c> avec EXACTEMENT <c>WKR-SRV-01</c>, jamais remplacé par
/// le nom de machine de l'importeur. Le contexte (ClientCode/ServerInstance/Environment)
/// représente le composant qui a PRODUIT l'événement, pas celui qui l'importe.
/// Aucun PostgreSQL réel : FakeDiagnosticEventRepository (§51).
/// </summary>
public class WorkerSyncImportContextTests : IDisposable
{
private readonly string _dir = CreateTempDirectory();
public void Dispose() => DeleteDirectory(_dir);
// --------------------------- E12 + §38 : import complet ---------------------
[Fact]
public async Task WorkerJsonl_ImportsToRecord_PreservingProducerContext()
{
// 1. Production d'un événement Worker (format RemoteDiagnosticIngestionStore,
// fichier omika-mobile-diagnostic-*.jsonl) avec contexte du producteur.
Guid operationId = Guid.NewGuid();
Guid eventId = Guid.NewGuid();
var context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-A",
ServerInstance = "WKR-SRV-01",
Environment = "Production"
};
await WriteWorkerEventAsync(eventId, operationId, context);
// 2. Import par Omika.Diagnostics.Worker (JsonlDiagnosticSource + importer).
var repository = new FakeDiagnosticEventRepository();
var importer = new DiagnosticImporter(
new JsonlDiagnosticSource(_dir, "import-checkpoint.json"),
repository,
Options.Create(new DiagnosticImportOptions { BatchSize = 100, Source = "worker" }));
DiagnosticImportResult result = await importer.ImportOnceAsync();
// 3. L'événement est lisible et importé (aucun format incompatible).
Assert.True(result.Succeeded);
Assert.Equal(1, result.ImportedCount);
Assert.Equal(0, result.DroppedCount);
// 4. Le contexte du PRODUCTEUR est conservé, jamais remplacé par l'importeur.
IReadOnlyList<DiagnosticEventRecord> records = await repository.GetByOperationIdAsync(operationId);
DiagnosticEventRecord record = Assert.Single(records);
Assert.Equal("CLIENT-A", record.ClientCode);
Assert.Equal("WKR-SRV-01", record.ServerInstance);
Assert.NotEqual(Environment.MachineName, record.ServerInstance);
Assert.Equal("Production", record.Environment);
Assert.Equal("Omika.Worker", record.Component);
Assert.Equal("OMK-WKR-SYNC-001", record.EventCode);
Assert.Equal("worker", record.Source);
Assert.Equal(operationId, record.OperationId);
}
// --------------------------- Helpers -----------------------------------------
private async Task WriteWorkerEventAsync(
Guid eventId,
Guid operationId,
DiagnosticDeploymentContext context)
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
var evt = new DiagnosticEventDto
{
EventId = eventId,
EventCode = "OMK-WKR-SYNC-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
Component = "Omika.Worker",
OperationId = operationId,
AppVersion = "1.0.0.0",
Platform = "worker",
Source = "worker",
EntityType = "Client",
EntityId = Guid.NewGuid().ToString("D"),
Success = true,
DurationMs = 12,
Metadata = new Dictionary<string, object?>
{
["operationType"] = "Create",
["retryCount"] = 0,
["result"] = "Success"
}
};
var batch = new DiagnosticBatchDto { Events = [evt] };
await store.StoreAsync(batch, DiagnosticActorKey.Empty, Guid.NewGuid().ToString("D"), DateTimeOffset.UtcNow, CancellationToken.None, context);
}
private static string CreateTempDirectory()
{
string dir = Path.Combine(Path.GetTempPath(), "omika-worker-sync-import-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private static void DeleteDirectory(string dir)
{
try
{
if (Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
}