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.
}
}
}
@@ -0,0 +1,624 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Domain.Entities.Mobile;
using Omika.Core.Domain.Entities.Security;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'endpoint d'authentification Back-Office WinDev
/// (POST /api/backoffice/auth/token).
///
/// Vérifie le contrat complet : codes HTTP, émission du Bearer token via le
/// mécanisme normal (ITokenService / MobileAuthMiddleware), contrôle d'accès
/// Back-Office (CO_AccesBO / UT_AccesBO), code client, et séparation avec le
/// droit A.LIC.MOB (qui n'est pas une condition d'émission du token).
///
/// Le hash utilisé est un placeholder volontairement faux : aucun vrai hash ni
/// vrai token n'apparaît dans ces tests.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class BackOfficeAuthEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string ValidLogin = "DFO";
private const string ValidHash = "FAKE-HASH-0123456789ABCDEF0123456789ABCDEF";
private const long ValidUserId = 42;
private const long ValidCollaborateurId = 7;
private readonly FakeBackOfficeAuthRepository _authRepository = new();
private readonly FakeLicenceRepository _licenceRepository = new();
private readonly FakeLicenseService _licenseService = new();
private readonly FakePermissionService _permissionService = new();
private readonly WebApplicationFactory<Program> _factory;
public BackOfficeAuthEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IBackOfficeAuthRepository>();
services.AddScoped<IBackOfficeAuthRepository>(_ => _authRepository);
services.RemoveAll<ILicenceRepository>();
services.AddScoped<ILicenceRepository>(_ => _licenceRepository);
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== 1. Authentification réussie =====
[Fact]
public async Task Post_ValidCredentials_WithBoAccess_Returns200_AndValidToken()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId);
// Le token est valide et porte l'identité attendue (même mécanisme que le mobile).
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
MobileTokenIdentity identity = tokenService.ValidateToken(body.AccessToken)!;
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(ValidUserId.ToString(), identity.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), identity.CollaborateurId);
}
[Fact]
public async Task Post_LoginIsCaseInsensitive_Returns200()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "dfo", // minuscules : reproduit le UPPER(UT_Login) = UPPER(login) WinDev
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
}
// ===== 2/3. Identifiants incorrects =====
[Fact]
public async Task Post_WrongLogin_Returns401_GenericMessage()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "INCONNU",
PasswordHash = ValidHash
});
Assert.Equal(401, (int)response.StatusCode);
Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Post_WrongPasswordHash_Returns401_GenericMessage()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = "AUTRE-HASH-FAUX"
});
Assert.Equal(401, (int)response.StatusCode);
Assert.Contains("Compte ou mot de passe incorrect.", await response.Content.ReadAsStringAsync());
}
// ===== 4. Hash vide =====
[Fact]
public async Task Post_EmptyPasswordHash_Returns400()
{
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = string.Empty
});
Assert.Equal(400, (int)response.StatusCode);
}
// ===== 5. Accès Back-Office refusé =====
[Fact]
public async Task Post_UserWithoutBoAccess_Returns403()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, ValidCollaborateurId, ValidCollaborateurId,
UtilisateurAccesBO: false, CollaborateurAccesBO: false, CollaborateurActif: true);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(403, (int)response.StatusCode);
}
[Fact]
public async Task Post_UserWithoutCollaborateur_ButUtAccesBoTrue_Returns200()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0,
UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
}
// ===== 6. Collaborateur lié =====
[Fact]
public async Task Post_LinkedCollaborateur_ReturnsCorrectUserIdAndCollaborateurId()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Equal(ValidCollaborateurId.ToString(), body.CollaborateurId);
}
// ===== 7. Utilisateur sans collaborateur =====
[Fact]
public async Task Post_UserWithoutCollaborateur_ReturnsNullCollaborateurId()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = new BackOfficeAuthUser(
ValidUserId, ValidLogin, CollaborateurId: 0, CollaborateurPkId: 0,
UtilisateurAccesBO: true, CollaborateurAccesBO: false, CollaborateurActif: false);
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(body);
Assert.Equal(ValidUserId.ToString(), body.UserId);
Assert.Null(body.CollaborateurId);
}
// ===== 8/9. Code client =====
[Fact]
public async Task Post_EmptyCodeClient_Returns400()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = string.Empty,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(400, (int)response.StatusCode);
}
[Fact]
public async Task Post_UnknownCodeClient_Returns400()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = null; // client inconnu côté serveur
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = "INCONNU.99",
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(400, (int)response.StatusCode);
}
// ===== 10/11/12. Token accepté par MobileAuthMiddleware + A.LIC.MOB =====
[Fact]
public async Task Post_ReturnedToken_IsAcceptedByMobileAuthMiddleware_OnProtectedEndpoint()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-001",
CodeClient = CodeClient,
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
// 1. Obtention du token Back-Office.
var authResponse = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)authResponse.StatusCode);
var session = await authResponse.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(session);
Assert.False(string.IsNullOrWhiteSpace(session.AccessToken));
// 2. Le token est accepté par MobileAuthMiddleware sur un endpoint protégé.
var protectedClient = _factory.CreateClient();
protectedClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var protectedResponse = await protectedClient.GetAsync(
$"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(200, (int)protectedResponse.StatusCode);
}
[Fact]
public async Task Post_UserWithLicenceMobilePermission_CanAccessLicenceMobileEndpoint()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-002",
CodeClient = CodeClient,
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
string token = await ObtainTokenAsync();
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(200, (int)response.StatusCode);
Assert.Equal(CodeClient, _licenseService.LastCodeClient);
}
[Fact]
public async Task Post_UserWithoutLicenceMobilePermission_TokenCreated_ButLicenceMobileDenied()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
string token = await ObtainTokenAsync();
Assert.False(string.IsNullOrWhiteSpace(token));
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.GetAsync($"/collaborateurs/{ValidCollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
// ===== 13. Sécurité : aucun hash/token exposé =====
[Fact]
public async Task ErrorResponses_DoNotContainPasswordHash()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = "INCONNU",
PasswordHash = ValidHash
});
Assert.Equal(401, (int)response.StatusCode);
string body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain(ValidHash, body);
Assert.DoesNotContain(ValidHash.ToLowerInvariant(), body);
}
// ===== Helpers =====
private async Task<string> ObtainTokenAsync()
{
_authRepository.ValidLogin = ValidLogin;
_authRepository.ValidHash = ValidHash;
_authRepository.UserToReturn = ValidUser();
_licenceRepository.ClientToReturn = ValidClient();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/backoffice/auth/token", new BackOfficeAuthTokenRequest
{
CodeClient = CodeClient,
Login = ValidLogin,
PasswordHash = ValidHash
});
Assert.Equal(200, (int)response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<BackOfficeAuthTokenResponse>();
Assert.NotNull(session);
return session.AccessToken;
}
private static BackOfficeAuthUser ValidUser() => new(
ValidUserId,
ValidLogin,
CollaborateurId: ValidCollaborateurId,
CollaborateurPkId: ValidCollaborateurId,
UtilisateurAccesBO: false,
CollaborateurAccesBO: true,
CollaborateurActif: true);
private static ClientBase ValidClient() => new()
{
Id = 1,
Code = CodeClient,
Nom = "Société Test"
};
// ===== Fakes =====
private sealed class FakeBackOfficeAuthRepository : IBackOfficeAuthRepository
{
public string ValidLogin { get; set; } = string.Empty;
public string ValidHash { get; set; } = string.Empty;
public BackOfficeAuthUser? UserToReturn { get; set; }
public Task<BackOfficeAuthUser?> GetByLoginAndPasswordHashAsync(
string login,
string passwordHash,
CancellationToken cancellationToken = default)
{
bool loginMatches = string.Equals(login, ValidLogin, StringComparison.OrdinalIgnoreCase);
bool hashMatches = string.Equals(passwordHash, ValidHash, StringComparison.Ordinal);
return Task.FromResult(loginMatches && hashMatches ? UserToReturn : null);
}
}
private sealed class FakeLicenceRepository : ILicenceRepository
{
public ClientBase? ClientToReturn { get; set; }
public Task<ClientBase?> GetClientByCodeAsync(string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult(ClientToReturn);
public Task<Licence?> GetByNumeroAsync(string numero, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<Licence?> GetByNumeroAndClientAsync(string numero, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<string?> GetMobileApiUrlForClientAsync(string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
public Task<Licence?> GetByCollaborateurAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<Licence?> GetFreeMobileLicenceAsync(long clientId, CancellationToken cancellationToken = default)
=> Task.FromResult<Licence?>(null);
public Task<bool> AssignMobileLicenceAsync(string numero, long userId, long collaborateurId, string? nom, string? prenom, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> ReleaseMobileLicenceAsync(string numero, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task<bool> AssignDeviceAsync(string numero, string deviceId, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
public Task AddHistoAsync(LicenceHistoEntry entry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public string? LastCodeClient { get; private set; }
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(GetResult);
}
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult<CollaborateurLicenceDto?>(null);
}
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,359 @@
using System.Net.Http.Json;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Contracts.Clients;
using Omika.Core.Contracts.Common;
using Omika.Core.Interfaces;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Runtime;
using Omika.Core.Domain;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints Client. Utilise <see cref="WebApplicationFactory{TEntryPoint}"/>
/// avec un fake <see cref="IDatabaseRuntimeInitializer"/> (pas de HFSQL)
/// et un fake <see cref="IClientService"/> pour contrôler les réponses.
///
/// Les tests modifient le contexte statique <see cref="ApplicationRuntimeContext"/>,
/// donc ils sont sérialisés via la collection <c>"ApplicationRuntimeContext"</c>.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class ClientEndpointsTests : IDisposable
{
private readonly FakeClientService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public ClientEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Configuration minimale avec valeurs HFSQL factices
// requises par ValidateOnStart().
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db"
})
.Build();
services.AddSingleton<IConfiguration>(config);
// Remplacer le runtime initializer par un fake qui
// configure Batik comme version détectée (pas de HFSQL).
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
// Remplacer IHfsqlConnection par un fake (pas d'ODBC).
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Remplacer IClientService par un fake contrôlable.
services.RemoveAll<IClientService>();
services.AddScoped<IClientService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// =========================================================================
// GET /clients/{id}
// =========================================================================
[Fact]
public async Task GetClientById_NumericId_TransmittedAsString()
{
var client = _factory.CreateClient();
await client.GetAsync("/clients/12345");
Assert.Equal("12345", _fakeService.LastDetailsId);
}
[Fact]
public async Task GetClientById_GuidId_TransmittedAsString()
{
var client = _factory.CreateClient();
var guid = Guid.NewGuid().ToString("D");
await client.GetAsync($"/clients/{guid}");
Assert.Equal(guid, _fakeService.LastDetailsId);
}
[Fact]
public async Task GetClientById_Returns200WithDto()
{
_fakeService.DetailsResult = new ClientDetailsDto
{
Id = "test-id",
Code = "C001",
Nom = "Dupont"
};
var client = _factory.CreateClient();
var response = await client.GetAsync("/clients/42");
var body = await response.Content.ReadFromJsonAsync<ClientDetailsDto>();
Assert.Equal(200, (int)response.StatusCode);
Assert.NotNull(body);
Assert.Equal("test-id", body.Id);
Assert.Equal("C001", body.Code);
Assert.Equal("Dupont", body.Nom);
}
[Fact]
public async Task GetClientById_NullResult_Returns404()
{
_fakeService.DetailsResult = null;
var client = _factory.CreateClient();
var response = await client.GetAsync("/clients/999");
Assert.Equal(404, (int)response.StatusCode);
}
[Fact]
public async Task GetClientById_ServiceException_NotTransformedTo404()
{
_fakeService.ThrowOnDetails = new InvalidOperationException("Infrastructure error");
var client = _factory.CreateClient();
var response = await client.GetAsync("/clients/42");
// L'exception n'est pas catchée dans le endpoint → 500.
Assert.Equal(500, (int)response.StatusCode);
}
// =========================================================================
// HEALTH
// =========================================================================
[Fact]
public async Task GetHealth_StillResponds()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/health");
Assert.Equal(200, (int)response.StatusCode);
}
// =========================================================================
// ARCHITECTURE
// =========================================================================
[Fact]
public void Endpoints_DependsOnlyOnIClientService()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Static);
foreach (var method in methods)
{
var parameters = method.GetParameters();
// Les handlers doivent accepter IClientService, pas les repositories.
Assert.DoesNotContain(parameters, p =>
p.ParameterType.Name.Contains("Repository", StringComparison.OrdinalIgnoreCase)
&& p.ParameterType.Name != "IClientService");
}
}
[Fact]
public void Endpoints_HasNoOmikaDataReferences()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns => ns.StartsWith("Omika.Data"));
}
[Fact]
public void Endpoints_HasNoApplicationRuntimeContext()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("ApplicationRuntimeContext", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Endpoints_HasNoHfsqlRowReferences()
{
var type = typeof(Omika.Api.Endpoints.ClientEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
}
// =========================================================================
// HELPERS
// =========================================================================
private static IEnumerable<string> GetReferencedNamespaces(Type type)
{
var ns = new HashSet<string>();
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (method.DeclaringType?.Namespace is not null)
ns.Add(method.DeclaringType.Namespace);
if (method.ReturnType.Namespace is not null)
ns.Add(method.ReturnType.Namespace);
foreach (var p in method.GetParameters())
{
if (p.ParameterType.Namespace is not null)
ns.Add(p.ParameterType.Namespace);
}
}
return ns;
}
// =========================================================================
// FAKES
// =========================================================================
/// <summary>
/// Fake runtime initializer : configure Batik comme version détectée,
/// sans contacter de base HFSQL.
/// </summary>
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
// Configurer le contexte runtime avec Batik comme version détectée.
// Cela permet à AdaptiveClientRepository de fonctionner sans base réelle.
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
/// <summary>
/// Fake IClientService pour les tests HTTP : enregistre les appels
/// et retourne des données contrôlées.
/// </summary>
private sealed class FakeClientService : IClientService
{
public int SearchCallCount { get; private set; }
public int DetailsCallCount { get; private set; }
public int UpdateCallCount { get; private set; }
public int ArchiveCallCount { get; private set; }
public SearchClientsRequest? LastRequest { get; private set; }
public string? LastDetailsId { get; private set; }
public string? LastUpdateId { get; private set; }
public UpdateClientRequest? LastUpdateRequest { get; private set; }
public string? LastArchiveId { get; private set; }
public PagedResult<ClientSummaryDto>? SearchResult { get; set; }
public ClientDetailsDto? DetailsResult { get; set; }
public ClientDetailsDto? UpdateResult { get; set; }
public bool ArchiveResult { get; set; }
public Exception? ThrowOnSearch { get; set; }
public Exception? ThrowOnDetails { get; set; }
public Exception? ThrowOnUpdate { get; set; }
public Exception? ThrowOnArchive { get; set; }
public Task<PagedResult<ClientSummaryDto>> SearchAsync(
SearchClientsRequest request, CancellationToken cancellationToken = default)
{
SearchCallCount++;
LastRequest = request;
if (ThrowOnSearch is not null) throw ThrowOnSearch;
return Task.FromResult(SearchResult
?? new PagedResult<ClientSummaryDto>([], 1, 20, 0, 0));
}
public Task<PagedResult<ClientDetailsDto>> SearchDetailsAsync(
SearchClientsRequest request, CancellationToken cancellationToken = default)
{
// Fake minimal : retourne un résultat vide paginé. Le snapshot mobile
// n'est pas exercé par ces tests HTTP (ils ciblent les endpoints CRUD).
return Task.FromResult(new PagedResult<ClientDetailsDto>([], 1, 20, 0, 0));
}
public Task<ClientDetailsDto?> GetByIdAsync(
string id, CancellationToken cancellationToken = default)
{
DetailsCallCount++;
LastDetailsId = id;
if (ThrowOnDetails is not null) throw ThrowOnDetails;
return Task.FromResult(DetailsResult);
}
public Task<ClientDetailsDto> CreateAsync(
CreateClientRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<ClientDetailsDto?> UpdateAsync(
string id, UpdateClientRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
{
UpdateCallCount++;
LastUpdateId = id;
LastUpdateRequest = request;
if (ThrowOnUpdate is not null) throw ThrowOnUpdate;
return Task.FromResult(UpdateResult);
}
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
{
ArchiveCallCount++;
LastArchiveId = id;
if (ThrowOnArchive is not null) throw ThrowOnArchive;
return Task.FromResult(ArchiveResult);
}
}
/// <summary>
/// Fake IHfsqlConnection minimal pour les tests HTTP.
/// </summary>
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,280 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints administratifs de licence mobile
/// (GET/POST /collaborateurs/{id}/licence-mobile[/assign|/release]).
/// Vérifie l'authentification (Bearer) et l'autorisation (droit A.LIC.MOB),
/// ainsi que le contrat de réponse pour l'attribution, la consultation et la
/// libération.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class CollaborateurLicenceEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CollaborateurId = "7";
private readonly FakeLicenseService _licenseService = new();
private readonly FakePermissionService _permissionService = new();
private readonly WebApplicationFactory<Program> _factory;
public CollaborateurLicenceEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== Consultation (GET) =====
[Fact]
public async Task Get_SansToken_Retourne401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Get_DroitLicenceMobileAbsent_Retourne401()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Get_DroitLicenceMobilePresent_Retourne200AvecDto()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.GetResult = new CollaborateurLicenceDto
{
Numero = "LIC-001",
CodeClient = "BATIK.01",
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CollaborateurLicenceDto>();
Assert.NotNull(body);
Assert.Equal("LIC-001", body.Numero);
Assert.Equal("BATIK.01", _licenseService.LastCodeClient);
}
// ===== Attribution (POST assign) =====
[Fact]
public async Task Assign_DroitLicenceMobileAbsent_Retourne401()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/assign", content: null);
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Assign_DroitLicenceMobilePresent_Retourne200AvecDto()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.AssignResult = new CollaborateurLicenceDto
{
Numero = "LIC-002",
CodeClient = "BATIK.01",
Active = true,
QrCode = "omika-enroll-v1:cGF5bG9hZA.sig"
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/assign", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CollaborateurLicenceDto>();
Assert.NotNull(body);
Assert.Equal("LIC-002", body.Numero);
Assert.Equal("BATIK.01", _licenseService.LastCodeClient);
}
// ===== Libération (POST release) =====
[Fact]
public async Task Release_DroitLicenceMobilePresent_Retourne204()
{
_permissionService.Permissions[MobilePermissionKeys.LicenceMobile] = true;
_licenseService.ReleaseResult = true;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsync(
$"/collaborateurs/{CollaborateurId}/licence-mobile/release", content: null);
Assert.Equal(204, (int)response.StatusCode);
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = CollaborateurId,
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public CollaborateurLicenceDto? AssignResult { get; set; }
public bool ReleaseResult { get; set; } = true;
public string? LastCodeClient { get; private set; }
public Task<LicenseResponse> ActivateAsync(
LicenseActivateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(
LicenseValidateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(GetResult);
}
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
{
LastCodeClient = codeClient;
return Task.FromResult(AssignResult);
}
public Task<bool> ReleaseMobileLicenceAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(ReleaseResult);
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,272 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Omika.Api.Endpoints;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'endpoint d'authentification Development local
/// (<c>POST /api/dev/session</c>).
///
/// Vérifie :
/// - qu'un token signé valide est émis quand <c>DevelopmentAuth:Enabled</c> est vrai ;
/// - que l'endpoint est refusé (403) quand <c>DevelopmentAuth:Enabled</c> est faux ;
/// - que le token émis est un vrai token signé par le TokenService normal
/// (même issuer/audience/signature), utilisable sur les endpoints protégés.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DevelopmentAuthEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeBootstrapService _bootstrapService = new();
private readonly WebApplicationFactory<Program> _factory;
public DevelopmentAuthEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey,
["DevelopmentAuth:Enabled"] = "true"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task DevSession_Enabled_ReturnsValidSignedToken()
{
var client = _factory.CreateClient();
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
// Le token est un vrai token signé par le TokenService normal.
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(DevelopmentAuthEndpoints.DevCodeClient, identity.CodeClient);
Assert.Equal(DevelopmentAuthEndpoints.DevUserId, identity.UserId);
Assert.Equal(DevelopmentAuthEndpoints.DevNumeroLicence, identity.NumeroLicence);
}
[Fact]
public async Task DevSession_Enabled_TokenAllowsProtectedEndpoint()
{
var client = _factory.CreateClient();
var sessionResponse = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)sessionResponse.StatusCode);
var session = await sessionResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session);
// Le token Development doit être accepté par l'authentification mobile
// normale (même mécanisme de signature). On vérifie sur un endpoint
// protégé : le bootstrap retourne 200 (pas 401).
var protectedClient = _factory.CreateClient();
protectedClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var bootstrapResponse = await protectedClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
}
[Fact]
public async Task DevSession_Disabled_ReturnsForbidden()
{
// Factory avec DevelopmentAuth:Enabled = false.
using var disabledFactory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey,
["DevelopmentAuth:Enabled"] = "false"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
});
});
using (disabledFactory)
{
var client = disabledFactory.CreateClient();
var response = await client.PostAsync("/api/dev/session", content: null);
// Endpoint non enregistré : 404 (route absente).
Assert.Equal(404, (int)response.StatusCode);
}
}
[Fact]
public async Task DevSession_WithoutCollaborateur_CollaborateurIdNull()
{
// Arrange : factory sans DevelopmentAuth:DevCollaborateurId.
var client = _factory.CreateClient();
// Act
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body!.AccessToken);
// Assert : aucun identifiant Collaborateur inventé.
Assert.NotNull(identity);
Assert.Null(identity.CollaborateurId);
}
[Fact]
public async Task DevSession_WithCollaborateur_IdentityContainsCollaborateurId()
{
// Arrange : factory avec DevelopmentAuth:DevCollaborateurId renseigné.
const string collaborateurId = "bbbbbbbb-0000-0000-0000-000000000042";
using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey,
["DevelopmentAuth:Enabled"] = "true",
["DevelopmentAuth:DevCollaborateurId"] = collaborateurId
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
using (factory)
{
var client = factory.CreateClient();
// Act
var response = await client.PostAsync("/api/dev/session", content: null);
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
using var scope = factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body!.AccessToken);
// Assert : l'identité porte le vrai CollaborateurId configuré.
Assert.NotNull(identity);
Assert.Equal(collaborateurId, identity.CollaborateurId);
}
}
// ===== Fakes =====
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,484 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Configuration;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Observability.Deployment;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tâche 13-C — Intégration du contexte de déploiement dans Omika.Api :
/// <list type="bullet">
/// <item>C1 : événement API interne → contexte du provider (ClientCode serveur).</item>
/// <item>C2/C3 : diagnostic mobile → ClientCode = JWT authentifié (autoritaire), jamais payload/provider.</item>
/// <item>C4/C5 : ServerInstance/Environment mobile = serveur ingérant (provider), jamais le téléphone.</item>
/// <item>C6 : payload falsifié (ClientId/ClientCode) ne remplace jamais le JWT.</item>
/// <item>C7 : requêtes concurrentes → aucune contamination inter-requêtes.</item>
/// <item>C8 : provider sans ClientCode → null sans exception (fail-open).</item>
/// <item>C9 : aucun secret dans ce que les producteurs transmettent.</item>
/// </list>
/// Provider et store sont des fakes en mémoire : l'objectif est de vérifier QUE le
/// bon <see cref="DiagnosticDeploymentContext"/> est transmis à
/// <see cref="IRemoteDiagnosticIngestionStore.StoreAsync"/> (le provider lui-même est
/// couvert par les tests D1–D16 de T13-D ; le JSONL par B1–B24 de T13-B).
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DiagnosticDeploymentContextApiTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeIngestionStore _store = new();
private readonly FakeDeploymentContextProvider _provider = new();
private readonly WebApplicationFactory<Program> _factory;
public DiagnosticDeploymentContextApiTests()
{
ApplicationRuntimeContext.Reset();
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-SERVEUR",
ServerInstance = "API-SRV-01",
Environment = "Production"
};
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Store fake : capture le contexte de déploiement transmis.
services.RemoveAll<IRemoteDiagnosticIngestionStore>();
services.AddSingleton<IRemoteDiagnosticIngestionStore>(_store);
// Provider fake : contexte serveur déterministe (pas de HFSQL).
services.RemoveAll<IDiagnosticDeploymentContextProvider>();
services.AddSingleton<IDiagnosticDeploymentContextProvider>(_provider);
// Active le forwarder serveur (4xx/5xx) pour C1/C8.
services.Configure<ServerDiagnosticOptions>(o => o.Enabled = true);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// --------------------------- C1 : événement API interne -----------------------
[Fact]
public async Task C1_InternalApiEvent_UsesProviderContext()
{
var client = _factory.CreateClient();
// 404 (hors /health et /api/diagnostics) → le ServerDiagnosticMiddleware
// enregistre un événement API_HTTP_4XX avec le contexte du provider.
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
AssertContext(call.Context, clientCode: "CLIENT-SERVEUR", serverInstance: "API-SRV-01", environment: "Production");
}
// --------------------------- C2 : diagnostic mobile ---------------------------
[Fact]
public async Task C2_MobileDiagnostic_UsesJwtCodeClientOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload mobile : ClientId falsifiable = FAUX-CLIENT.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C3 : priorité JWT > provider > payload -----------
[Fact]
public async Task C3_MobileDiagnostic_PriorityJwtOverProviderOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
// JWT gagne ; le provider ("CLIENT-SERVEUR") ne gagne PAS pour le mobile ;
// le payload ("FAUX-CLIENT") n'est JAMAIS candidat.
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
Assert.NotEqual("CLIENT-SERVEUR", call.Context.ClientCode);
Assert.NotEqual("FAUX-CLIENT", call.Context.ClientCode);
}
// --------------------------- C4 : ServerInstance mobile = provider ------------
[Fact]
public async Task C4_MobileDiagnostic_ServerInstanceFromProviderNotPhone()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// DeviceModel du téléphone : ne doit jamais devenir ServerInstance.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(deviceModel: "iPhone 15"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("API-SRV-01", call.Context!.ServerInstance);
Assert.NotEqual("iPhone 15", call.Context.ServerInstance);
}
// --------------------------- C5 : Environment mobile = provider ---------------
[Fact]
public async Task C5_MobileDiagnostic_EnvironmentFromProvider()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("Production", call.Context!.Environment);
}
// --------------------------- C6 : payload falsifié ----------------------------
[Fact]
public async Task C6_FalsifiedPayload_NeverReplacesJwtCodeClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload brut contenant clientId + un champ inconnu "clientCode" (ignoré par
// la désérialisation typée) : aucun des deux ne doit devenir le ClientCode.
string rawJson = $$"""
{
"schemaVersion": 1,
"batchId": "{{Guid.NewGuid():D}}",
"sentAtUtc": "2025-01-01T00:00:00+00:00",
"events": [
{
"eventId": "{{Guid.NewGuid():D}}",
"eventCode": "OMK-MOB-APP-001",
"timestampUtc": "2025-01-01T00:00:00+00:00",
"level": "Info",
"priority": "P3",
"clientId": "AUTRE-CLIENT",
"clientCode": "AUTRE-CLIENT"
}
]
}
""";
using var content = new StringContent(rawJson, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/diagnostics", content);
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C7 : requêtes concurrentes -----------------------
[Fact]
public async Task C7_ConcurrentRequests_NoContextContamination()
{
var client = _factory.CreateClient();
(string Code, string EventCode)[] cases =
[
("CLIENT-A", "T7-A"),
("CLIENT-B", "T7-B"),
("CLIENT-C", "T7-C")
];
// Construction explicite des requêtes concurrentes avec des tokens distincts.
Task<HttpResponseMessage>[] requests = cases.Select(c =>
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/diagnostics")
{
Content = JsonContent.Create(ValidBatch(eventCode: c.EventCode))
};
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(c.Code));
return client.SendAsync(req);
}).ToArray();
HttpResponseMessage[] responses = await Task.WhenAll(requests);
Assert.All(responses, r => Assert.Equal(200, (int)r.StatusCode));
Assert.Equal(3, _store.Calls.Count);
foreach ((string Code, string EventCode) c in cases)
{
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call =
Assert.Single(_store.Calls, x => x.Batch.Events[0].EventCode == c.EventCode);
Assert.Equal(c.Code, call.Context!.ClientCode);
// ServerInstance/Environment identiques pour tous les clients.
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
}
// --------------------------- C8 : provider sans ClientCode --------------------
[Fact]
public async Task C8_ProviderWithoutClientCode_NullWithoutException()
{
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = null,
ServerInstance = "API-SRV-01",
Environment = "Production"
};
var client = _factory.CreateClient();
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
// Fail-open : le 404 est toujours produit, l'événement est enregistré avec
// ClientCode = null, aucune exception.
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Null(call.Context!.ClientCode);
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
// --------------------------- C9 : absence de secret ---------------------------
[Fact]
public async Task C9_NoSecretsInProducedContextOrBatch()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
string contextJson = JsonSerializer.Serialize(call.Context!);
string batchJson = JsonSerializer.Serialize(call.Batch);
foreach (string secret in new[] { "Authorization", "Bearer", "HmacKey", "ConnectionString", "password", "CL_CleAPIInstall" })
{
Assert.DoesNotContain(secret, contextJson, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secret, batchJson, StringComparison.OrdinalIgnoreCase);
}
}
// --------------------------- Helpers ------------------------------------------
private static DiagnosticBatchDto ValidBatch(
string eventCode = "OMK-MOB-APP-001",
string? clientId = null,
string? deviceModel = null) => new()
{
BatchId = Guid.NewGuid(),
Events =
[
new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
ClientId = clientId,
DeviceModel = deviceModel
}
]
};
private string CreateToken(string codeClient)
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = codeClient,
DeviceId = "device-test",
UserId = "user-test",
CollaborateurId = "7",
NumeroLicence = "licence-test"
});
}
private static void AssertContext(
DiagnosticDeploymentContext? context,
string? clientCode,
string? serverInstance,
string? environment)
{
Assert.NotNull(context);
Assert.Equal(clientCode, context!.ClientCode);
Assert.Equal(serverInstance, context.ServerInstance);
Assert.Equal(environment, context.Environment);
}
// ===== Fakes =====
private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> _calls = [];
public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> Calls
{
get
{
lock (_gate)
{
return _calls.ToArray();
}
}
}
public Task<int> StoreAsync(
DiagnosticBatchDto batch,
DiagnosticActorKey actorKey,
string ingestionTraceId,
DateTimeOffset receivedAtUtc,
CancellationToken cancellationToken = default,
DiagnosticDeploymentContext? deploymentContext = null,
DiagnosticDeviceKey? deviceKey = null,
string? licenseNumber = null)
{
lock (_gate)
{
_calls.Add((batch, deploymentContext));
}
return Task.FromResult(batch.Events.Count);
}
}
private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider
{
public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty;
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
/// <summary>
/// Tâche 13-C C10 — Régression : les anciens appels <c>StoreAsync</c> (sans
/// <c>deploymentContext</c>) et les anciens JSONL restent compatibles. Utilise le
/// vrai <see cref="RemoteDiagnosticIngestionStore"/> (aucun HTTP requis).
/// </summary>
public class DiagnosticDeploymentContextBackwardCompatibilityTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-t13c-compat", Guid.NewGuid().ToString("N"));
public DiagnosticDeploymentContextBackwardCompatibilityTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try
{
if (Directory.Exists(_dir))
{
Directory.Delete(_dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
[Fact]
public async Task C10_LegacyStoreAsyncCall_WritesJsonlWithoutContextFields()
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
var evt = new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3"
};
var batch = new DiagnosticBatchDto { Events = [evt] };
// Appel hérité : AUCUN deploymentContext.
int accepted = await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace-id", DateTimeOffset.UtcNow);
Assert.Equal(1, accepted);
string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
using JsonDocument doc = JsonDocument.Parse(content);
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _));
Assert.False(doc.RootElement.TryGetProperty("environment", out _));
}
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();
}
}
@@ -0,0 +1,318 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Configuration;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Observability.Deployment;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tâche 17-B — Autorité serveur de LicenseNumber / DeviceKey dans Omika.Api :
/// <list type="bullet">
/// <item>D1 : LicenseNumber = NumeroLicence du token authentifié (jamais le payload).</item>
/// <item>D2 : DeviceKey = HMAC(CodeClient + DeviceId) calculé serveur (jamais choisi par le mobile).</item>
/// <item>D3 : un payload falsifié (deviceKey/licenseNumber/deviceId) ne remplace jamais l'identité serveur.</item>
/// <item>D4 : ClientCode reste autoritaire (protection T13-C intacte).</item>
/// <item>D5 : token sans numéro de licence → LicenseNumber null, aucun rejet (fail-open).</item>
/// </list>
/// Provider et store sont des fakes en mémoire ; la DeviceKey est calculée par le VRAI
/// <see cref="IDiagnosticDeviceKeyProvider"/> enregistré dans Program (clé de test déterministe).
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DiagnosticDeviceLicenseApiTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private static readonly string HmacKey = Convert.ToBase64String(new byte[32]);
private readonly FakeIngestionStore _store = new();
private readonly FakeDeploymentContextProvider _provider = new();
private readonly WebApplicationFactory<Program> _factory;
public DiagnosticDeviceLicenseApiTests()
{
ApplicationRuntimeContext.Reset();
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-SERVEUR",
ServerInstance = "API-SRV-01",
Environment = "Production"
};
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey,
["DiagnosticsIdentity:HmacKey"] = HmacKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IRemoteDiagnosticIngestionStore>();
services.AddSingleton<IRemoteDiagnosticIngestionStore>(_store);
services.RemoveAll<IDiagnosticDeploymentContextProvider>();
services.AddSingleton<IDiagnosticDeploymentContextProvider>(_provider);
// DeviceKey provider déterministe (clé de test) : le provider réel de
// Program capture builder.Configuration (non surchargé ici), donc on
// remplace explicitement le provider pour obtenir un HMAC stable.
services.RemoveAll<IDiagnosticDeviceKeyProvider>();
services.AddSingleton<IDiagnosticDeviceKeyProvider>(new DiagnosticDeviceKeyProvider(HmacKey));
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// --------------------------- D1 : LicenseNumber = token ----------------------
[Fact]
public async Task D1_LicenseNumber_ComesFromToken()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT", numeroLicence: "LIC-AAAA"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Equal("LIC-AAAA", call.LicenseNumber);
}
// --------------------------- D2 : DeviceKey = HMAC serveur -------------------
[Fact]
public async Task D2_DeviceKey_IsComputedServerSide()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
CreateToken(codeClient: "CLIENT-JWT", deviceId: "device-AAAA"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
// La DeviceKey reçue doit être EXACTEMENT le HMAC serveur attendu, jamais une
// valeur transmise par le client (le DTO ne l'expose pas).
DiagnosticDeviceKey expected = new DiagnosticDeviceKeyProvider(HmacKey).Compute("CLIENT-JWT", "device-AAAA");
Assert.True(expected.HasValue);
Assert.Equal(expected.Value, call.DeviceKey!.Value);
Assert.Equal(expected.Version, call.DeviceKey.Version);
}
// --------------------------- D3 : payload falsifié ignoré --------------------
[Fact]
public async Task D3_FalsifiedPayload_NeverReplacesDeviceOrLicenseIdentity()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
CreateToken(codeClient: "CLIENT-JWT", deviceId: "device-AAAA", numeroLicence: "LIC-AAAA"));
// Le payload tente d'injecter deviceId / deviceKey / licenseNumber : aucun de
// ces champs n'est un champ structuré du DTO (deviceId est épuré, deviceKey et
// licenseNumber sont ignorés à la désérialisation).
string rawJson = $$"""
{
"schemaVersion": 1,
"batchId": "{{Guid.NewGuid():D}}",
"sentAtUtc": "2025-01-01T00:00:00+00:00",
"events": [
{
"eventId": "{{Guid.NewGuid():D}}",
"eventCode": "OMK-MOB-APP-001",
"timestampUtc": "2025-01-01T00:00:00+00:00",
"level": "Info",
"priority": "P3",
"deviceId": "FAUX-DEVICE",
"deviceKey": "FAUX-DEVICE-KEY",
"licenseNumber": "FAUX-LICENCE"
}
]
}
""";
using var content = new StringContent(rawJson, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/diagnostics", content);
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Equal("LIC-AAAA", call.LicenseNumber); // token, pas FAUX-LICENCE
DiagnosticDeviceKey expected = new DiagnosticDeviceKeyProvider(HmacKey).Compute("CLIENT-JWT", "device-AAAA");
Assert.Equal(expected.Value, call.DeviceKey!.Value); // HMAC serveur, pas FAUX-DEVICE-KEY
Assert.DoesNotContain("FAUX-DEVICE-KEY", call.DeviceKey.Value, StringComparison.Ordinal);
}
// --------------------------- D4 : ClientCode reste autoritaire ---------------
[Fact]
public async Task D4_ClientCode_ProtectionStillIntact()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- D5 : token sans licence → null, pas de rejet -----
[Fact]
public async Task D5_TokenWithoutLicense_NullLicenseNumber_NoRejection()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(codeClient: "CLIENT-JWT", numeroLicence: ""));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber) call =
Assert.Single(_store.Calls);
Assert.Null(call.LicenseNumber);
}
// --------------------------- Helpers ------------------------------------------
private static DiagnosticBatchDto ValidBatch(string? clientId = null) => new()
{
BatchId = Guid.NewGuid(),
Events =
[
new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
ClientId = clientId
}
]
};
private string CreateToken(string codeClient, string deviceId = "device-test", string numeroLicence = "licence-test")
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = codeClient,
DeviceId = deviceId,
UserId = "user-test",
CollaborateurId = "7",
NumeroLicence = numeroLicence
});
}
// ===== Fakes =====
private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber)> _calls = [];
public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context, DiagnosticDeviceKey? DeviceKey, string? LicenseNumber)> Calls
{
get
{
lock (_gate)
{
return _calls.ToArray();
}
}
}
public Task<int> StoreAsync(
DiagnosticBatchDto batch,
DiagnosticActorKey actorKey,
string ingestionTraceId,
DateTimeOffset receivedAtUtc,
CancellationToken cancellationToken = default,
DiagnosticDeploymentContext? deploymentContext = null,
DiagnosticDeviceKey? deviceKey = null,
string? licenseNumber = null)
{
lock (_gate)
{
_calls.Add((batch, deploymentContext, deviceKey, licenseNumber));
}
return Task.FromResult(batch.Events.Count);
}
}
private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider
{
public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty;
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,83 @@
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
using Omika.Api;
namespace Omika.Tests.Api;
/// <summary>
/// Tests de la configuration ForwardedHeaders (reverse proxy Apache local).
/// Vérifient le contenu exact des options (en-têtes, ForwardLimit, KnownProxies)
/// et l'absence de toute configuration « trust-all » (symétrie conservée,
/// KnownNetworks jamais vidé, aucun proxy non-loopback).
/// </summary>
public class ForwardedHeadersConfigurationTests
{
[Fact]
public void CreateOptions_ConfiguresForwardedHeaders()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Equal(
ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost,
options.ForwardedHeaders);
}
[Fact]
public void CreateOptions_SetsForwardLimitToOne()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Equal(1, options.ForwardLimit);
}
[Fact]
public void CreateOptions_AddsIpv4LoopbackAsKnownProxy()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Contains(IPAddress.Loopback, options.KnownProxies);
}
[Fact]
public void CreateOptions_AddsIpv6LoopbackAsKnownProxy()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
Assert.Contains(IPAddress.IPv6Loopback, options.KnownProxies);
}
[Fact]
public void CreateOptions_OnlyTrustsLoopbackProxies()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// Aucun wildcard (0.0.0.0 / [::]) : uniquement le proxy Apache local.
Assert.DoesNotContain(IPAddress.Any, options.KnownProxies);
Assert.DoesNotContain(IPAddress.IPv6Any, options.KnownProxies);
Assert.All(options.KnownProxies, ip => Assert.True(IPAddress.IsLoopback(ip)));
}
[Fact]
public void CreateOptions_KeepsRequireHeaderSymmetryEnabled()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// La symétrie des en-têtes reste activée (défaut sécurisé) : jamais
// désactivée pour une configuration permissive globale.
Assert.True(options.RequireHeaderSymmetry);
}
[Fact]
public void CreateOptions_DoesNotClearKnownNetworks()
{
var options = ForwardedHeadersConfiguration.CreateOptions();
// KnownIPNetworks (anciennement KnownNetworks) conserve ses réseaux par
// défaut (loopback) : jamais vidé.
Assert.NotEmpty(options.KnownIPNetworks);
// La confiance réseau reste bornée au loopback (127.0.0.1/8 et ::1/128).
Assert.All(options.KnownIPNetworks, network => Assert.True(IPAddress.IsLoopback(network.BaseAddress)));
}
}
@@ -0,0 +1,458 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Api.Services;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Documents;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Contracts.Mobile.Documents;
using Omika.Core.Domain;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints « courriel envoyé depuis une MESSAGERIE EXTERNE » :
/// <list type="bullet">
/// <item><c>POST /api/interventions/{id}/emails/envoye-externe</c> ;</item>
/// <item><c>PUT /api/interventions/{id}/emails/{emailId}/envoye-externe</c>.</item>
/// </list>
/// Ces endpoints n'envoient RIEN : ils enregistrent un courriel déclaré envoyé
/// par l'utilisateur depuis sa propre messagerie
/// (<c>ME_Envoyer=true</c>, <c>ME_EnvoyerMob=true</c>, <c>ME_Brouillon=false</c>),
/// et refusent l'appel quand la messagerie interne (Back-Office) est active.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class InterventionEmailExterneEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CollaborateurId = "7";
private const string InterventionId = "12345";
private readonly FakeInterventionService _interventionService = new();
private readonly FakePermissionService _permissionService = new();
private readonly FakeSmtpConfigurationService _smtpService = new();
private readonly WebApplicationFactory<Program> _factory;
public InterventionEmailExterneEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IInterventionService>();
services.AddScoped<IInterventionService>(_ => _interventionService);
services.RemoveAll<IMobilePermissionService>();
services.AddScoped<IMobilePermissionService>(_ => _permissionService);
services.RemoveAll<ISmtpConfigurationService>();
services.AddScoped<ISmtpConfigurationService>(_ => _smtpService);
services.RemoveAll<IMobileDocumentService>();
services.AddScoped<IMobileDocumentService, FakeMobileDocumentService>();
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== Authentification / autorisation =====
/// <summary>Sans token : refusé avant toute écriture.</summary>
[Fact]
public async Task PostEnvoyeExterne_SansToken_Retourne401()
{
var client = _factory.CreateClient();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
/// <summary>Sans le droit d'envoi d'email : refusé.</summary>
[Fact]
public async Task PostEnvoyeExterne_SansDroitEnvoiEmail_Retourne403()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Messagerie externe : enregistrement « envoyé » =====
/// <summary>
/// Messagerie externe : le courriel est enregistré avec les champs d'un
/// courriel ENVOYÉ (ME_Envoyer + ME_EnvoyerMob), sans envoi SMTP.
/// </summary>
[Fact]
public async Task PostEnvoyeExterne_MessagerieExterne_EnregistreEnEnvoyeMob()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement;
Assert.NotNull(enregistrement);
Assert.False(enregistrement!.Brouillon);
Assert.True(enregistrement.Envoyer);
Assert.True(enregistrement.EnvoyerMob);
// Aucun expéditeur connu : l'envoi n'est pas passé par un compte Omika.
Assert.Null(enregistrement.AdrExp);
Assert.Equal("Intervention du jour", enregistrement.Objet);
}
/// <summary>
/// Messagerie interne (Back-Office) : l'endpoint refuse — l'envoi doit passer
/// par Omika, jamais être « déclaré » envoyé par le mobile.
/// </summary>
[Fact]
public async Task PostEnvoyeExterne_MessagerieInterne_Retourne400EtRienNEstEcrit()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
/// <summary>Sujet et message vides : refusé (même règle que les autres endpoints).</summary>
[Fact]
public async Task PostEnvoyeExterne_SansSujetNiMessage_Retourne400()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/envoye-externe",
new CreateEmailInterventionRequest { Objet = " ", Message = " " });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Messagerie externe : brouillon existant devenu envoyé =====
/// <summary>Un brouillon existant est marqué envoyé (même état que la création).</summary>
[Fact]
public async Task PutEnvoyeExterne_MessagerieExterne_MarqueLeBrouillonEnvoye()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("email-42", _interventionService.DernierEmailId);
EmailEnregistrement? enregistrement = _interventionService.DernierEnregistrement;
Assert.NotNull(enregistrement);
Assert.True(enregistrement!.EnvoyerMob);
Assert.False(enregistrement.Brouillon);
}
/// <summary>Un courriel déjà envoyé ne peut pas être marqué une seconde fois (409).</summary>
[Fact]
public async Task PutEnvoyeExterne_CourrielDejaEnvoye_Retourne409()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = true;
_interventionService.ErreurMajEmail = "Ce courriel a déjà été envoyé : il ne peut plus être modifié.";
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
/// <summary>Messagerie interne : le marquage externe est refusé.</summary>
[Fact]
public async Task PutEnvoyeExterne_MessagerieInterne_Retourne400()
{
_permissionService.Permissions[MobilePermissionKeys.EnvoiEmail] = true;
_smtpService.MessagerieExterne = false;
HttpClient client = ClientAuthentifie();
HttpResponseMessage response = await client.PutAsJsonAsync(
$"/api/interventions/{InterventionId}/emails/email-42/envoye-externe",
Demande());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(_interventionService.DernierEnregistrement);
}
// ===== Aides =====
private static CreateEmailInterventionRequest Demande()
=> new()
{
Objet = "Intervention du jour",
Message = "Bonjour,\nIntervention terminée.",
AdrDestinataires = "client@exemple.fr",
AdrCC = "copie@exemple.fr"
};
private HttpClient ClientAuthentifie()
{
HttpClient client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", CreateValidToken());
return client;
}
private string CreateValidToken()
{
using IServiceScope scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = CollaborateurId,
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeInterventionService : IInterventionService
{
public EmailEnregistrement? DernierEnregistrement { get; private set; }
public string? DernierEmailId { get; private set; }
/// <summary>Simule un courriel déjà envoyé (409).</summary>
public string? ErreurMajEmail { get; set; }
public Task<EmailInterventionDto> EnregistrerNouvelEmailAsync(
string interventionId,
EmailEnregistrement enregistrement,
AuditActor? auteur,
CancellationToken cancellationToken = default)
{
DernierEnregistrement = enregistrement;
return Task.FromResult(new EmailInterventionDto { Id = "email-nouveau" });
}
public Task<EmailInterventionDto> EnregistrerEmailExistantAsync(
string emailId,
EmailEnregistrement enregistrement,
CancellationToken cancellationToken = default)
{
if (ErreurMajEmail is not null)
{
throw new InvalidOperationException(ErreurMajEmail);
}
DernierEmailId = emailId;
DernierEnregistrement = enregistrement;
return Task.FromResult(new EmailInterventionDto { Id = emailId });
}
// ---- Membres de IInterventionService non exercés par ces tests ----
public Task<PagedResult<InterventionSummaryDto>> SearchAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<InterventionSummaryDto>([], 1, 20, 0, 0));
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<InterventionDetailsDto>([], 1, 20, 0, 0));
public Task<InterventionDetailsDto?> GetByIdAsync(
string id, CancellationToken cancellationToken = default)
=> Task.FromResult<InterventionDetailsDto?>(null);
public Task<InterventionDetailsDto> CreateAsync(
CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> Task.FromResult(new InterventionDetailsDto { Id = "new" });
public Task<InterventionDetailsDto?> UpdateAsync(
string id, UpdateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> Task.FromResult<InterventionDetailsDto?>(null);
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<NoteInterneDto> AddNoteAsync(
string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new NoteInterneDto());
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<EmailInterventionDto>>([]);
public Task<EmailInterventionDto> AddEmailAsync(
string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new EmailInterventionDto());
}
private sealed class FakePermissionService : IMobilePermissionService
{
public Dictionary<string, bool> Permissions { get; } = new();
public MobileTokenIdentity? Identity => null;
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyDictionary<string, bool>>(Permissions);
public Task<bool> HasPermissionAsync(string key, CancellationToken cancellationToken = default)
=> Task.FromResult(Permissions.TryGetValue(key, out bool allowed) && allowed);
}
private sealed class FakeSmtpConfigurationService : ISmtpConfigurationService
{
/// <summary>Préférence de messagerie simulée (false = Back-Office interne).</summary>
public bool MessagerieExterne { get; set; }
public Task<IReadOnlyList<SmtpConfigurationDto>> GetListAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SmtpConfigurationDto>>([]);
public Task<SmtpConfiguration?> GetAsync(
string collaborateurId,
string configurationId,
CancellationToken cancellationToken = default)
=> Task.FromResult<SmtpConfiguration?>(null);
public Task<bool> EstMessagerieExterneAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(MessagerieExterne);
}
private sealed class FakeMobileDocumentService : IMobileDocumentService
{
public Task<DocumentDownloadResponse?> DownloadAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<DocumentDownloadResponse?>(null);
public Task<long> ResolveDocumentPkAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task<string?> ResolveDocumentFullPathAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
public Task<DocumentUploadResponse> UploadAsync(
DocumentUploadRequest request, string categorieCle, AuditActor? auteur, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<DocumentSummaryDto>> GetByInterventionAsync(
string interventionId, IReadOnlyCollection<string>? allowedCategories, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DocumentSummaryDto>>([]);
public Task<IReadOnlyList<DocumentSummaryDto>> GetByInterventionsAsync(
IReadOnlyList<long> numericInterventionIds, IReadOnlyCollection<string>? allowedCategories, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DocumentSummaryDto>>([]);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
Guid documentGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<NoteInterneDto> AddNoteAsync(
Guid documentGuid, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,432 @@
using System.Net.Http.Json;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Documents;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Contracts.Mobile.Interventions;
using Omika.Core.Contracts.Planning;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Metadata;
using Omika.Data.Runtime;
using Omika.Core.Domain;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints Intervention. Utilise <see cref="WebApplicationFactory{TEntryPoint}"/>
/// avec un fake <see cref="IDatabaseRuntimeInitializer"/> (pas de HFSQL)
/// et un fake <see cref="IInterventionService"/> pour contrôler les réponses.
///
/// Les tests modifient le contexte statique <see cref="ApplicationRuntimeContext"/>,
/// donc ils sont sérialisés via la collection <c>"ApplicationRuntimeContext"</c>.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class InterventionEndpointsTests : IDisposable
{
private readonly FakeInterventionService _fakeService = new();
private readonly FakeMobileInterventionSearchService _fakeMobileSearch = new();
private readonly WebApplicationFactory<Program> _factory;
public InterventionEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db"
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IInterventionService>();
services.AddScoped<IInterventionService>(_ => _fakeService);
services.RemoveAll<IMobileInterventionSearchService>();
services.AddScoped<IMobileInterventionSearchService>(_ => _fakeMobileSearch);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// =========================================================================
// GET /interventions/{id}
// =========================================================================
[Fact]
public async Task GetInterventionById_NumericId_TransmittedAsString()
{
var client = _factory.CreateClient();
await client.GetAsync("/interventions/12345");
Assert.Equal("12345", _fakeService.LastDetailsId);
}
[Fact]
public async Task GetInterventionById_GuidId_TransmittedAsString()
{
var client = _factory.CreateClient();
var guid = Guid.NewGuid().ToString("D");
await client.GetAsync($"/interventions/{guid}");
Assert.Equal(guid, _fakeService.LastDetailsId);
}
[Fact]
public async Task GetInterventionById_Returns200WithDto()
{
_fakeService.DetailsResult = new InterventionDetailsDto
{
Id = "test-id",
Reference = "INT-001",
Titre = "Réparation"
};
var client = _factory.CreateClient();
var response = await client.GetAsync("/interventions/42");
var body = await response.Content.ReadFromJsonAsync<InterventionDetailsDto>();
Assert.Equal(200, (int)response.StatusCode);
Assert.NotNull(body);
Assert.Equal("test-id", body.Id);
Assert.Equal("INT-001", body.Reference);
Assert.Equal("Réparation", body.Titre);
}
[Fact]
public async Task GetInterventionById_NullResult_Returns404()
{
_fakeService.DetailsResult = null;
var client = _factory.CreateClient();
var response = await client.GetAsync("/interventions/999");
Assert.Equal(404, (int)response.StatusCode);
}
[Fact]
public async Task GetInterventionById_ServiceException_NotTransformedTo404()
{
_fakeService.ThrowOnDetails = new InvalidOperationException("Infrastructure error");
var client = _factory.CreateClient();
var response = await client.GetAsync("/interventions/42");
// L'exception n'est pas catchée dans le endpoint → 500.
Assert.Equal(500, (int)response.StatusCode);
}
// =========================================================================
// HEALTH
// =========================================================================
[Fact]
public async Task GetHealth_StillResponds()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/health");
Assert.Equal(200, (int)response.StatusCode);
}
// =========================================================================
// MOBILE SEARCH / BY-IDS — route enregistrée sous /api/interventions
// =========================================================================
[Fact]
public async Task MobileSearch_RouteRegistered_Returns401WithoutToken_Not404()
{
var client = _factory.CreateClient();
// Sans token, l'endpoint authentifié renvoie 401. S'il renvoyait 404,
// c'est que la route /api/interventions/search n'est pas enregistrée.
var response = await client.PostAsJsonAsync(
"/api/interventions/search", new MobileInterventionSearchRequest());
Assert.Equal(401, (int)response.StatusCode);
Assert.Equal(0, _fakeMobileSearch.SearchCallCount);
}
[Fact]
public async Task MobileByIds_RouteRegistered_Returns401WithoutToken_Not404()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/interventions/by-ids", new MobileInterventionsByIdsRequest { Ids = [] });
Assert.Equal(401, (int)response.StatusCode);
Assert.Equal(0, _fakeMobileSearch.GetByIdsCallCount);
}
// =========================================================================
// ARCHITECTURE
// =========================================================================
[Fact]
public void Endpoints_DependsOnlyOnIInterventionService()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Static);
foreach (var method in methods)
{
var parameters = method.GetParameters();
Assert.DoesNotContain(parameters, p =>
p.ParameterType.Name.Contains("Repository", StringComparison.OrdinalIgnoreCase)
&& p.ParameterType.Name != "IInterventionService");
}
}
[Fact]
public void Endpoints_HasNoOmikaDataReferences()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns => ns.StartsWith("Omika.Data"));
}
[Fact]
public void Endpoints_HasNoApplicationRuntimeContext()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("ApplicationRuntimeContext", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Endpoints_HasNoHfsqlRowReferences()
{
var type = typeof(Omika.Api.Endpoints.InterventionEndpoints);
var refs = GetReferencedNamespaces(type);
Assert.DoesNotContain(refs, ns =>
ns.Contains("HfSql", StringComparison.OrdinalIgnoreCase));
}
// =========================================================================
// HELPERS
// =========================================================================
private static IEnumerable<string> GetReferencedNamespaces(Type type)
{
var ns = new HashSet<string>();
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (method.DeclaringType?.Namespace is not null)
ns.Add(method.DeclaringType.Namespace);
if (method.ReturnType.Namespace is not null)
ns.Add(method.ReturnType.Namespace);
foreach (var p in method.GetParameters())
{
if (p.ParameterType.Namespace is not null)
ns.Add(p.ParameterType.Namespace);
}
}
return ns;
}
// =========================================================================
// FAKES
// =========================================================================
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeInterventionService : IInterventionService
{
public int SearchCallCount { get; private set; }
public int DetailsCallCount { get; private set; }
public int CreateCallCount { get; private set; }
public int UpdateCallCount { get; private set; }
public int ArchiveCallCount { get; private set; }
public SearchInterventionsRequest? LastRequest { get; private set; }
public string? LastDetailsId { get; private set; }
public CreateInterventionRequest? LastCreateRequest { get; private set; }
public string? LastUpdateId { get; private set; }
public UpdateInterventionRequest? LastUpdateRequest { get; private set; }
public string? LastArchiveId { get; private set; }
public PagedResult<InterventionSummaryDto>? SearchResult { get; set; }
public InterventionDetailsDto? DetailsResult { get; set; }
public InterventionDetailsDto? CreateResult { get; set; }
public InterventionDetailsDto? UpdateResult { get; set; }
public bool ArchiveResult { get; set; }
public Exception? ThrowOnSearch { get; set; }
public Exception? ThrowOnDetails { get; set; }
public Exception? ThrowOnCreate { get; set; }
public Exception? ThrowOnUpdate { get; set; }
public Exception? ThrowOnArchive { get; set; }
public Task<PagedResult<InterventionSummaryDto>> SearchAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
{
SearchCallCount++;
LastRequest = request;
if (ThrowOnSearch is not null) throw ThrowOnSearch;
return Task.FromResult(SearchResult
?? new PagedResult<InterventionSummaryDto>([], 1, 20, 0, 0));
}
public Task<PagedResult<InterventionDetailsDto>> SearchDetailsAsync(
SearchInterventionsRequest request, CancellationToken cancellationToken = default)
{
SearchCallCount++;
LastRequest = request;
if (ThrowOnSearch is not null) throw ThrowOnSearch;
return Task.FromResult(new PagedResult<InterventionDetailsDto>(
DetailsResult is null ? [] : [DetailsResult], 1, 20, DetailsResult is null ? 0 : 1, 1));
}
public Task<InterventionDetailsDto?> GetByIdAsync(
string id, CancellationToken cancellationToken = default)
{
DetailsCallCount++;
LastDetailsId = id;
if (ThrowOnDetails is not null) throw ThrowOnDetails;
return Task.FromResult(DetailsResult);
}
public Task<InterventionDetailsDto> CreateAsync(
CreateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
{
CreateCallCount++;
LastCreateRequest = request;
if (ThrowOnCreate is not null) throw ThrowOnCreate;
return Task.FromResult(CreateResult
?? new InterventionDetailsDto { Id = "new" });
}
public Task<InterventionDetailsDto?> UpdateAsync(
string id, UpdateInterventionRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
{
UpdateCallCount++;
LastUpdateId = id;
LastUpdateRequest = request;
if (ThrowOnUpdate is not null) throw ThrowOnUpdate;
return Task.FromResult(UpdateResult);
}
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
{
ArchiveCallCount++;
LastArchiveId = id;
if (ThrowOnArchive is not null) throw ThrowOnArchive;
return Task.FromResult(ArchiveResult);
}
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<IReadOnlyList<NoteInterneDto>> GetNotesAdministrativesAsync(string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<NoteInterneDto>>([]);
public Task<NoteInterneDto> AddNoteAsync(
string interventionId, string contenu, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new NoteInterneDto());
public Task<IReadOnlyList<EmailInterventionDto>> GetEmailsAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<EmailInterventionDto>>([]);
public Task<EmailInterventionDto> AddEmailAsync(
string interventionId, CreateEmailInterventionRequest request, AuditActor? auteur, CancellationToken cancellationToken = default)
=> Task.FromResult(new EmailInterventionDto());
}
private sealed class FakeMobileInterventionSearchService : IMobileInterventionSearchService
{
public int SearchCallCount { get; private set; }
public int GetByIdsCallCount { get; private set; }
public Task<IReadOnlyList<MobileInterventionSearchResult>> SearchAsync(MobileInterventionSearchRequest request, MobileTokenIdentity? identity, IReadOnlyDictionary<string, bool> permissions, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<MobileInterventionSearchResult>>([]);
public Task<IReadOnlyList<MobileInterventionSearchResult>> GetByIdsAsync(
IReadOnlyList<string> ids, CancellationToken cancellationToken = default)
{
GetByIdsCallCount++;
return Task.FromResult<IReadOnlyList<MobileInterventionSearchResult>>([]);
}
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(
string interventionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RendezVousDetailsDto>>([]);
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,136 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Omika.Core.Contracts.Mobile.Languages;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP de l'API dédiée de téléchargement des modèles de langue (ONNX).
/// Accès anonyme : vérifie le manifeste, le streaming de fichier et les 404.
/// </summary>
public class LanguageEndpointsTests : IDisposable
{
private const string ManifestJson = """
{
"packs": [
{
"code": "es",
"libelle": "Espagnol",
"version": "1.0.0",
"fichiers": [
{ "sens": "versFrancais", "nomFichier": "encoder.onnx", "tailleOctets": 16, "sha256": "abc" }
]
}
]
}
""";
private readonly string _root;
private readonly WebApplicationFactory<Omika.Langues.Api.Program> _factory;
public LanguageEndpointsTests()
{
_root = CreateRoot();
Populate(_root);
_factory = CreateFactory(_root);
}
public void Dispose()
{
_factory.Dispose();
Directory.Delete(_root, true);
}
[Fact]
public async Task Langues_Manifest_RetournePacksEtUrls()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues");
Assert.Equal(200, (int)response.StatusCode);
var manifest = await response.Content.ReadFromJsonAsync<LanguageManifestResponse>();
Assert.NotNull(manifest);
var pack = Assert.Single(manifest.Packs);
Assert.Equal("es", pack.Code);
Assert.Equal("Espagnol", pack.Libelle);
var fichier = Assert.Single(pack.Fichiers);
Assert.Equal("/api/langues/es/versFrancais/encoder.onnx", fichier.Url);
}
[Fact]
public async Task Langues_Fichier_RetourneContenu()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues/es/versFrancais/encoder.onnx");
Assert.Equal(200, (int)response.StatusCode);
string content = await response.Content.ReadAsStringAsync();
Assert.Equal("fake-model-bytes", content);
}
[Fact]
public async Task Langues_Fichier_Introuvable_Retourne404()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/langues/es/versFrancais/absent.onnx");
Assert.Equal(404, (int)response.StatusCode);
}
[Fact]
public async Task Langues_ManifestAbsent_Retourne404()
{
string emptyRoot = CreateRoot();
using var emptyFactory = CreateFactory(emptyRoot);
try
{
var client = emptyFactory.CreateClient();
var response = await client.GetAsync("/api/langues");
Assert.Equal(404, (int)response.StatusCode);
}
finally
{
Directory.Delete(emptyRoot, true);
}
}
// ===== Helpers =====
private static string CreateRoot()
{
string root = Path.Combine(Path.GetTempPath(), "omika-langues-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private static void Populate(string root)
{
Directory.CreateDirectory(Path.Combine(root, "es", "versFrancais"));
File.WriteAllText(Path.Combine(root, "manifest.json"), ManifestJson);
File.WriteAllText(Path.Combine(root, "es", "versFrancais", "encoder.onnx"), "fake-model-bytes");
}
private static WebApplicationFactory<Omika.Langues.Api.Program> CreateFactory(string root)
=> new WebApplicationFactory<Omika.Langues.Api.Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Langues:RootPath"] = root,
["Langues:ManifestFileName"] = "manifest.json"
})
.Build();
services.AddSingleton<IConfiguration>(config);
});
});
}
@@ -0,0 +1,380 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de licence mobile.
/// Vérifie l'émission du Bearer token après activation/validation réussie,
/// l'absence de token en cas d'échec, la chaîne activation → bootstrap,
/// et le renouvellement du token après validation.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class LicenseEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3";
private const string DeviceId = "device-abc-123";
private readonly FakeLicenseService _licenseService = new();
private readonly FakeBootstrapService _bootstrapService = new();
private readonly WebApplicationFactory<Program> _factory;
public LicenseEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _bootstrapService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// ===== ACTIVATE =====
[Fact]
public async Task Activate_Success_ReturnsToken_ValidIdentity_AndExpiration()
{
_licenseService.ActivateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
// Le token est valide et porte l'identité attendue.
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal("42", identity.UserId);
Assert.Equal("7", identity.CollaborateurId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
// L'expiration retournée est cohérente avec la durée de vie configurée (24 h).
var expected = tokenService.GetTokenExpirationUtc();
Assert.True(body.AccessTokenExpiresAtUtc <= expected.AddMinutes(1));
Assert.True(body.AccessTokenExpiresAtUtc >= expected.AddMinutes(-1));
}
[Fact]
public async Task Activate_Failure_ReturnsNoToken()
{
_licenseService.ActivateResult = InvalidLicenseResponse(LicenseErrorCodes.LicenceInconnue);
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(400, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.False(body.License.IsValid);
Assert.Equal(LicenseErrorCodes.LicenceInconnue, body.License.ErrorCode);
Assert.True(string.IsNullOrWhiteSpace(body.AccessToken));
}
// ===== VALIDATE =====
[Fact]
public async Task Validate_Success_ReturnsNewToken()
{
_licenseService.ValidateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.True(body.License.IsValid);
Assert.False(string.IsNullOrWhiteSpace(body.AccessToken));
Assert.True(body.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(body.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
}
[Theory]
[InlineData(LicenseErrorCodes.LicenceInconnue)]
[InlineData(LicenseErrorCodes.LicenceInactive)]
[InlineData(LicenseErrorCodes.LicenceExpiree)]
[InlineData(LicenseErrorCodes.LicenceAttribuee)]
public async Task Validate_Failure_ReturnsNoToken(string errorCode)
{
_licenseService.ValidateResult = InvalidLicenseResponse(errorCode);
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(400, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(body);
Assert.False(body.License.IsValid);
Assert.Equal(errorCode, body.License.ErrorCode);
Assert.True(string.IsNullOrWhiteSpace(body.AccessToken));
}
// ===== CHAÎNE COMPLÈTE : activation → bootstrap =====
[Fact]
public async Task Activate_Then_Bootstrap_WithBearer_Returns200()
{
_licenseService.ActivateResult = ValidLicenseResponse();
_bootstrapService.Result = new MobileBootstrapResponse
{
Utilisateur = new CurrentUserDto { Id = "42", Login = "user-42" },
Profil = new MobileProfileDto { Code = "BATIK", Nom = "BATIK" }
};
var client = _factory.CreateClient();
// 1. Activation → récupération du Bearer token.
var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)activateResponse.StatusCode);
var session = await activateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session);
Assert.False(string.IsNullOrWhiteSpace(session.AccessToken));
// 2. Bootstrap avec le vrai format Bearer.
var bootstrapClient = _factory.CreateClient();
bootstrapClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
var bootstrap = await bootstrapResponse.Content.ReadFromJsonAsync<MobileBootstrapResponse>();
Assert.NotNull(bootstrap);
Assert.Equal("42", bootstrap.Utilisateur.Id);
}
// ===== RENOUVELLEMENT APRÈS EXPIRATION (conceptuel) =====
[Fact]
public async Task Validate_After_Activate_Produces_New_Valid_Token()
{
_licenseService.ActivateResult = ValidLicenseResponse();
_licenseService.ValidateResult = ValidLicenseResponse();
var client = _factory.CreateClient();
// T1 via activation.
var activateResponse = await client.PostAsJsonAsync("/api/license/activate", new LicenseActivateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
var session1 = await activateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session1);
// T2 via validation (renouvellement).
var validateResponse = await client.PostAsJsonAsync("/api/license/validate", new LicenseValidateRequest
{
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
DeviceId = DeviceId
});
Assert.Equal(200, (int)validateResponse.StatusCode);
var session2 = await validateResponse.Content.ReadFromJsonAsync<MobileLicenseSessionResponse>();
Assert.NotNull(session2);
// T2 est un nouveau token, valide, avec une nouvelle expiration.
Assert.False(string.IsNullOrWhiteSpace(session2.AccessToken));
Assert.True(session2.AccessTokenExpiresAtUtc > DateTimeOffset.UtcNow);
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
var identity = tokenService.ValidateToken(session2.AccessToken);
Assert.NotNull(identity);
Assert.Equal(CodeClient, identity.CodeClient);
Assert.Equal(DeviceId, identity.DeviceId);
Assert.Equal(NumeroLicence, identity.NumeroLicence);
// Le nouveau token permet l'accès aux endpoints protégés.
var bootstrapClient = _factory.CreateClient();
bootstrapClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session2.AccessToken);
var bootstrapResponse = await bootstrapClient.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)bootstrapResponse.StatusCode);
}
// ===== Helpers =====
private static LicenseResponse ValidLicenseResponse() => new()
{
IsValid = true,
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
NomSociete = "Société Test",
UserNom = "Dupont",
UserPrenom = "Jean",
UserId = "42",
CollaborateurId = "7",
OfflineGraceDays = 10,
ServerTimeUtc = DateTimeOffset.UtcNow,
ErrorCode = LicenseErrorCodes.Ok
};
private static LicenseResponse InvalidLicenseResponse(string errorCode) => new()
{
IsValid = false,
CodeClient = CodeClient,
NumeroLicence = NumeroLicence,
ErrorCode = errorCode,
ErrorMessage = "Licence invalide",
ServerTimeUtc = DateTimeOffset.UtcNow
};
// ===== Fakes =====
private sealed class FakeLicenseService : ILicenseService
{
public LicenseResponse ActivateResult { get; set; } = new();
public LicenseResponse ValidateResult { get; set; } = new();
public Task<LicenseResponse> ActivateAsync(
LicenseActivateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(ActivateResult);
public Task<LicenseResponse> ValidateAsync(
LicenseValidateRequest request,
CancellationToken cancellationToken = default)
=> Task.FromResult(ValidateResult);
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(
string collaborateurId,
string codeClient,
CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<bool> ReleaseMobileLicenceAsync(
string collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
=> Task.FromResult(Result);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,185 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de bootstrap mobile.
/// Vérifie l'authentification (sans token, token invalide) et le contrat retourné.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class MobileBootstrapEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeBootstrapService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public MobileBootstrapEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<IMobileBootstrapService>();
services.AddScoped<IMobileBootstrapService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task Bootstrap_WithoutToken_Returns401()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_InvalidToken_Returns401()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid.token.value");
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_DeviceMismatch_Returns403_Not500()
{
_fakeService.ThrowDeviceMismatch = true;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(403, (int)response.StatusCode);
}
[Fact]
public async Task Bootstrap_ValidToken_Returns200WithContract()
{
_fakeService.Result = new MobileBootstrapResponse
{
Utilisateur = new CurrentUserDto { Id = "user-1", Login = "user-1" },
Profil = new MobileProfileDto { Code = "BATIK", Nom = "BATIK" }
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.GetAsync("/api/mobile/bootstrap");
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MobileBootstrapResponse>();
Assert.NotNull(body);
Assert.Equal("user-1", body.Utilisateur.Id);
Assert.Equal("BATIK", body.Profil.Code);
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = "7",
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeBootstrapService : IMobileBootstrapService
{
public MobileBootstrapResponse Result { get; set; } = new();
public bool ThrowDeviceMismatch { get; set; }
public Task<MobileBootstrapResponse> GetBootstrapAsync(
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
{
if (ThrowDeviceMismatch)
{
throw new UnauthorizedAccessException(
"Le DeviceId authentifié ne correspond pas à la licence attribuée.");
}
return Task.FromResult(Result);
}
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,232 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Security;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests d'intégration de la résolution réelle des permissions :
/// token → MobileAuthMiddleware → MobileTokenIdentity → IMobilePermissionService
/// → IMobileIdentityProvider → IMobileIdentityRepository → A.LIC.MOB.
///
/// Utilise le vrai <see cref="MobilePermissionService"/> et le vrai
/// <see cref="Omika.Core.Services.Mobile.MobileIdentityProvider"/>, avec un faux
/// repository d'identité. Vérifie qu'un grand identifiant 64 bits traverse la
/// chaîne sans troncature et que A.LIC.MOB est résolu dynamiquement.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class MobilePermissionResolutionIntegrationTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private const string CodeClient = "BATIK.01";
private const string CollaborateurId = "7";
private const string BigUserId = "9000000000"; // > Int32.MaxValue, aucun risque de troncature
private readonly FakeMobileIdentityRepository _identityRepository = new();
private readonly FakeLicenseService _licenseService = new();
private readonly WebApplicationFactory<Program> _factory;
public MobilePermissionResolutionIntegrationTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Réel IMobilePermissionService + IMobileIdentityProvider,
// avec un faux repository d'identité (résolution contrôlée).
services.RemoveAll<IMobileIdentityRepository>();
services.AddScoped<IMobileIdentityRepository>(_ => _identityRepository);
services.RemoveAll<ILicenseService>();
services.AddScoped<ILicenseService>(_ => _licenseService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task PermissionAccordee_EndpointLicenceNonRefuse_UserIdGrandIntact()
{
_identityRepository.GrantLicenceMobile = true;
_licenseService.GetResult = new CollaborateurLicenceDto { Numero = "LIC-001", CodeClient = CodeClient, Active = true };
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
// 200 (licence trouvée) ou 404 (licence absente), mais jamais 401 pour manque de droit.
Assert.NotEqual(401, (int)response.StatusCode);
// Le UserId du token (grand identifiant 64 bits) est transmis intact à la résolution.
Assert.Equal(BigUserId, _identityRepository.LastUserId);
}
[Fact]
public async Task PermissionRefusee_EndpointLicence_Retourne401()
{
_identityRepository.GrantLicenceMobile = false;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task PermissionAbsente_EndpointLicence_Retourne401()
{
// Aucune permission A.LIC.MOB dans le catalogue retourné.
_identityRepository.GrantLicenceMobile = null;
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken());
var response = await client.GetAsync($"/collaborateurs/{CollaborateurId}/licence-mobile");
Assert.Equal(401, (int)response.StatusCode);
}
private string CreateToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = CodeClient,
DeviceId = "backoffice-windev",
UserId = BigUserId,
CollaborateurId = CollaborateurId,
NumeroLicence = string.Empty
});
}
// ===== Fakes =====
private sealed class FakeMobileIdentityRepository : IMobileIdentityRepository
{
/// <summary>true = accordée, false = refusée, null = absente.</summary>
public bool? GrantLicenceMobile { get; set; }
public string? LastUserId { get; private set; }
public Task<MobileProfileDto> GetProfileAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
return Task.FromResult(new MobileProfileDto { Code = "ROLE", Nom = "ROLE" });
}
public Task<IReadOnlyDictionary<string, bool>> GetPermissionsAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
var permissions = MobilePermissionKeys.Empty();
if (GrantLicenceMobile is { } granted)
{
permissions[MobilePermissionKeys.LicenceMobile] = granted;
}
return Task.FromResult<IReadOnlyDictionary<string, bool>>(permissions);
}
public Task<string?> GetLoginByUserIdAsync(string userId, CancellationToken cancellationToken = default)
{
LastUserId = userId;
return Task.FromResult<string?>("TEST_USER");
}
public Task<long?> GetUtilisateurPkIdByCollaborateurAsync(long collaborateurPkId, CancellationToken cancellationToken = default)
=> Task.FromResult<long?>(null);
public Task<string?> GetLoginByCollaborateurAsync(long collaborateurPkId, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
}
private sealed class FakeLicenseService : ILicenseService
{
public CollaborateurLicenceDto? GetResult { get; set; }
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new LicenseResponse());
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult(GetResult);
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurLicenceDto?>(null);
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,298 @@
using Omika.Api.Services;
using Omika.Core.Compatibility;
using Omika.Core.Constants;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Contracts.Organisation;
using Omika.Core.Contracts.Planning;
using Omika.Core.Interfaces;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Domain;
namespace Omika.Tests.Api;
/// <summary>
/// Tests du filtre de périmètre <see cref="RdvScopeService"/> : compatibilité
/// Omika (GUID + PK numérique résolu) sans régression Batik (comparaison par
/// PK numérique). Le contrôle des droits n'est jamais contourné : un RDV hors
/// périmètre reste filtré.
/// </summary>
public class RdvScopeServiceTests
{
// Le token porte le PK numérique du collaborateur (CO_PKID Omika), comme
// produit par LicenseService.BuildSuccessAsync.
private static readonly MobileTokenIdentity IdentityPk42 = new()
{
CodeClient = "CLI",
DeviceId = "dev",
UserId = "1",
CollaborateurId = "42"
};
[Fact]
public async Task Cas1_UtilisateurAvecAcces_RendezVousVisible()
{
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 42)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas2_UtilisateurSansAcces_RendezVousFiltre()
{
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 99)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Empty(result);
}
[Fact]
public async Task Cas3_DroitSocietePrincipale_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 100]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 100)],
IdentityPk42,
Droits(societePrincipale: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, true, false, false), service.LastCall);
}
[Fact]
public async Task Cas4_DroitSociete_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 200]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 200)],
IdentityPk42,
Droits(societes: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, false, true, false), service.LastCall);
}
[Fact]
public async Task Cas5_DroitEquipe_ComportementPreserve()
{
var service = new FakeCollaborateurService(allowed: [42, 300]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 300)],
IdentityPk42,
Droits(equipe: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Equal((42L, false, false, true), service.LastCall);
}
[Fact]
public async Task Cas6_CollaborateurOmikaIdentifieParGuid_RendezVousAutoriseVisible()
{
// Modèle Omika : l'affectation porte le GUID ET le PK numérique résolu.
// Le RDV autorisé ne doit plus disparaître.
Guid guid = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e");
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 42, guid: guid)],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas7_Batik_DansPerimetre_Visible()
{
// Affectation Batik : référence numérique uniquement (FromLegacyId).
// Le collaborateur courant (PK 42) est toujours inclus → visible.
var rdv = new RendezVousDetailsDto
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = "42",
SourceReference = LegacyEntityReference.FromLegacyId(42)
}
]
};
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[rdv],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
}
[Fact]
public async Task Cas7b_Batik_HorsPerimetre_Filtre()
{
// Comportement attendu Batik (aligné sur MobileBootstrapService) : sans les
// 3 droits A.C.PL.*, un RDV affecté à un collaborateur hors périmètre est
// filtré (le filtre par PK numérique s'applique). Avant le correctif, ce
// cas était silencieusement fail-open (RDV visible à tort).
var rdv = new RendezVousDetailsDto
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = "99",
SourceReference = LegacyEntityReference.FromLegacyId(99)
}
]
};
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[rdv],
IdentityPk42,
Droits(),
service,
new FakeIdentityResolver(),
default);
Assert.Empty(result);
}
[Fact]
public async Task TroisDroits_AucunFiltre()
{
// Les 3 droits présents → aucune restriction (comportement historique).
var service = new FakeCollaborateurService(allowed: [42]);
var result = await RdvScopeService.ApplyScopeAsync(
[RdvOmika(numericId: 99)],
IdentityPk42,
Droits(societePrincipale: true, societes: true, equipe: true),
service,
new FakeIdentityResolver(),
default);
Assert.Single(result);
Assert.Null(service.LastCall);
}
private static RendezVousDetailsDto RdvOmika(long numericId, Guid? guid = null)
=> new()
{
Id = Guid.NewGuid().ToString("D"),
Debut = new DateTime(2026, 8, 19, 9, 0, 0),
Collaborateurs =
[
new AffectationCollaborateurDto
{
CollaborateurId = guid?.ToString("D") ?? numericId.ToString(),
SourceReference = new LegacyEntityReference(
DatabaseSchemaVersion.Omika,
NumericId: numericId,
EntityGuid: guid)
}
]
};
private static IReadOnlyDictionary<string, bool> Droits(
bool societePrincipale = false,
bool societes = false,
bool equipe = false)
=> new Dictionary<string, bool>
{
[MobilePermissionKeys.PlanningSocietePrincipale] = societePrincipale,
[MobilePermissionKeys.PlanningSocietes] = societes,
[MobilePermissionKeys.PlanningEquipe] = equipe
};
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
{
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
=> physical.Guid ?? Guid.NewGuid();
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
=> new PhysicalEntityIdentity { Guid = publicGuid };
public bool IsCanonical(string entityType, Guid publicGuid)
=> publicGuid != Guid.Empty;
}
private sealed class FakeCollaborateurService : ICollaborateurService
{
private readonly IReadOnlyList<long> _allowed;
public FakeCollaborateurService(IReadOnlyList<long> allowed)
=> _allowed = allowed;
public (long CollaborateurId, bool SocietePrincipale, bool Societes, bool Equipe)? LastCall { get; private set; }
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(
long collaborateurId,
bool includeSocietePrincipale,
bool includeSocietes,
bool includeEquipe,
CancellationToken cancellationToken = default)
{
LastCall = (collaborateurId, includeSocietePrincipale, includeSocietes, includeEquipe);
return Task.FromResult(_allowed);
}
public Task<PagedResult<CollaborateurSummaryDto>> SearchAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<PagedResult<CollaborateurDetailsDto>> SearchDetailsAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> CreateAsync(CreateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> UpdateAsync(string id, UpdateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(string collaborateurId, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousForCollaborateursAsync(IReadOnlyList<string> collaborateurIds, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,118 @@
namespace Omika.Tests.Api;
/// <summary>
/// Tests de sécurité du reverse proxy Apache local : analyse la source
/// d'Omika.Api (Program.cs + ForwardedHeadersConfiguration.cs) pour garantir
/// l'ordre du middleware, l'absence de configuration « trust-all », l'absence
/// de certificat Kestrel et l'absence de bind sur 0.0.0.0.
/// </summary>
public class ReverseProxySecurityTests
{
private const string ProgramFile = "src/Omika.Api/Program.cs";
private const string ForwardedHeadersFile = "src/Omika.Api/ForwardedHeadersConfiguration.cs";
[Fact]
public void Program_UsesForwardedHeadersBeforeHttpsRedirection()
{
string source = ReadRepoFile(ProgramFile);
int forwarded = source.IndexOf("app.UseForwardedHeaders(", StringComparison.Ordinal);
int https = source.IndexOf("app.UseHttpsRedirection(", StringComparison.Ordinal);
Assert.True(forwarded >= 0, "UseForwardedHeaders doit être présent.");
Assert.True(https >= 0, "UseHttpsRedirection doit être présent.");
Assert.True(
forwarded < https,
"UseForwardedHeaders doit s'exécuter AVANT UseHttpsRedirection.");
}
[Fact]
public void ReverseProxy_DoesNotClearProxyTrustLists()
{
string combined = ReadRepoFile(ProgramFile) + "\n" + ReadRepoFile(ForwardedHeadersFile);
Assert.DoesNotContain("KnownNetworks.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("KnownIPNetworks.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("KnownProxies.Clear", combined, StringComparison.Ordinal);
Assert.DoesNotContain("RequireHeaderSymmetry = false", combined, StringComparison.Ordinal);
}
[Fact]
public void ReverseProxy_DoesNotAddWildcardProxyTrust()
{
string combined = ReadRepoFile(ProgramFile) + "\n" + ReadRepoFile(ForwardedHeadersFile);
// Aucune adresse « any » (0.0.0.0 / [::]) ni réseau wildcard dans la confiance.
Assert.DoesNotContain("IPAddress.Any", combined, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.IPv6Any", combined, StringComparison.Ordinal);
Assert.DoesNotContain("0.0.0.0", combined, StringComparison.Ordinal);
}
[Fact]
public void OmikaApi_DoesNotConfigureKestrelCertificate()
{
foreach (string file in EnumerateOmikaApiSourceFiles())
{
string content = File.ReadAllText(file);
// Pas de terminaison TLS dans Kestrel (certificat / HTTPS) :
// c'est Apache qui termine TLS.
Assert.DoesNotContain("UseHttps(", content, StringComparison.Ordinal);
Assert.DoesNotContain("AddHttps", content, StringComparison.Ordinal);
Assert.DoesNotContain("X509Certificate", content, StringComparison.Ordinal);
Assert.DoesNotContain(".pfx", content, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("HttpsConnectionAdapterOptions", content, StringComparison.Ordinal);
}
}
[Fact]
public void OmikaApi_DoesNotBindToAnyAddress()
{
foreach (string file in EnumerateOmikaApiSourceFiles())
{
string content = File.ReadAllText(file);
Assert.DoesNotContain("0.0.0.0", content, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.Any", content, StringComparison.Ordinal);
Assert.DoesNotContain("IPAddress.IPv6Any", content, StringComparison.Ordinal);
Assert.DoesNotContain("ListenAnyIP", content, StringComparison.Ordinal);
}
}
[Fact]
public void Program_DoesNotHardcodeKestrelPort()
{
string source = ReadRepoFile(ProgramFile);
// Le port (5080) et l'URL doivent rester dans appsettings.Production.json,
// jamais en dur dans Program.cs.
Assert.DoesNotContain("5080", source, StringComparison.Ordinal);
Assert.DoesNotContain("UseUrls", source, StringComparison.Ordinal);
}
private static string ReadRepoFile(string relativePath)
{
return File.ReadAllText(Path.Combine(RepoRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)));
}
private static IEnumerable<string> EnumerateOmikaApiSourceFiles()
{
string apiRoot = Path.Combine(RepoRoot(), "src", "Omika.Api");
return Directory.EnumerateFiles(apiRoot, "*.cs", SearchOption.AllDirectories)
.Where(f =>
!f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) &&
!f.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar));
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Omika.slnx")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
+200
View File
@@ -0,0 +1,200 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tests HTTP des endpoints de synchronisation mobile.
/// Vérifie l'authentification (sans token) et la réception d'une opération.
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class SyncEndpointsTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeSyncOperationService _fakeService = new();
private readonly WebApplicationFactory<Program> _factory;
public SyncEndpointsTests()
{
ApplicationRuntimeContext.Reset();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
services.RemoveAll<ISyncOperationService>();
services.AddScoped<ISyncOperationService>(_ => _fakeService);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
[Fact]
public async Task Receive_WithoutToken_Returns401()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(401, (int)response.StatusCode);
}
[Fact]
public async Task Receive_ValidOperation_Returns200Received()
{
_fakeService.Receipt = new SyncOperationReceipt
{
OperationId = Guid.NewGuid(),
Status = "RECEIVED",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(200, (int)response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<SyncOperationReceipt>();
Assert.NotNull(body);
Assert.Equal("RECEIVED", body.Status);
}
[Fact]
public async Task Receive_ValidOperation_PassesIdentityFromToken()
{
_fakeService.Receipt = new SyncOperationReceipt
{
OperationId = Guid.NewGuid(),
Status = "RECEIVED",
ReceivedAtUtc = DateTimeOffset.UtcNow
};
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateValidToken());
var response = await client.PostAsJsonAsync("/api/sync/operations", ValidOperation());
Assert.Equal(200, (int)response.StatusCode);
Assert.NotNull(_fakeService.LastIdentity);
Assert.Equal("BATIK.01", _fakeService.LastIdentity.CodeClient);
Assert.Equal("device-abc-123", _fakeService.LastIdentity.DeviceId);
Assert.Equal("user-1", _fakeService.LastIdentity.UserId);
}
private static SyncOperation ValidOperation()
{
return new SyncOperation
{
OperationId = Guid.NewGuid(),
DeviceId = "device-abc-123",
EntityType = "Client",
OperationType = SyncOperationType.Create,
EntityGuid = Guid.NewGuid(),
Payload = "{}"
};
}
private string CreateValidToken()
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = "BATIK.01",
DeviceId = "device-abc-123",
UserId = "user-1",
CollaborateurId = "7",
NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3"
});
}
// ===== Fakes =====
private sealed class FakeSyncOperationService : ISyncOperationService
{
public SyncOperationReceipt Receipt { get; set; } = new();
public MobileTokenIdentity? LastIdentity { get; private set; }
public Task<SyncOperationReceipt> ReceiveAsync(
SyncOperation operation,
MobileTokenIdentity identity,
CancellationToken cancellationToken = default)
{
LastIdentity = identity;
return Task.FromResult(Receipt);
}
public Task<SyncStatusResponse> GetStatusAsync(
Guid operationId,
string authenticatedDeviceId,
CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncStatusResponse
{
OperationId = operationId,
Status = SyncStatus.StatusNotAvailable,
DeviceId = authenticatedDeviceId
});
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,270 @@
using Omika.Core.Compatibility;
using Omika.Core.Runtime;
namespace Omika.Tests;
[Collection("ApplicationRuntimeContext")]
public class ApplicationRuntimeContextTests
{
[Fact]
public void Parse_Auto_IsRecognized()
{
var result = DatabaseSchemaSelectionParser.Parse("Auto");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Auto, result.Value);
}
[Fact]
public void Parse_Batik_IsRecognized()
{
var result = DatabaseSchemaSelectionParser.Parse("Batik");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Batik, result.Value);
}
[Fact]
public void Parse_Omika_IsRecognized()
{
var result = DatabaseSchemaSelectionParser.Parse("Omika");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Omika, result.Value);
}
[Theory]
[InlineData("auto")]
[InlineData("AUTO")]
[InlineData("batik")]
[InlineData("Batik")]
[InlineData("BATIK")]
[InlineData(" Omika ")]
[InlineData("omika")]
[InlineData("OMIKA")]
public void Parse_IsCaseAndWhitespaceInsensitive(string value)
{
var result = DatabaseSchemaSelectionParser.Parse(value);
Assert.True(result.IsSuccess);
}
[Fact]
public void Parse_UnknownValue_ReturnsClearError()
{
var result = DatabaseSchemaSelectionParser.Parse("2024");
Assert.True(result.IsFailure);
var error = Assert.Single(result.Errors);
Assert.Contains("2024", error, StringComparison.OrdinalIgnoreCase);
Assert.Contains(nameof(DatabaseSchemaSelection.Auto), error, StringComparison.Ordinal);
Assert.Contains(nameof(DatabaseSchemaSelection.Batik), error, StringComparison.Ordinal);
Assert.Contains(nameof(DatabaseSchemaSelection.Omika), error, StringComparison.Ordinal);
}
[Fact]
public void Parse_NullOrEmpty_ReturnsClearError()
{
var result = DatabaseSchemaSelectionParser.Parse(null);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void TryParse_KnownValue_ReturnsTrue()
{
var ok = DatabaseSchemaSelectionParser.TryParse("Batik", out var selection);
Assert.True(ok);
Assert.Equal(DatabaseSchemaSelection.Batik, selection);
}
[Fact]
public void TryParse_UnknownValue_ReturnsFalse()
{
var ok = DatabaseSchemaSelectionParser.TryParse("inconnu", out _);
Assert.False(ok);
}
[Fact]
public void Configure_UnknownValue_ThrowsClearError()
{
ApplicationRuntimeContext.Reset();
var ex = Assert.Throws<InvalidOperationException>(() => ApplicationRuntimeContext.Configure("inconnu"));
Assert.Contains("inconnu", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains(ApplicationRuntimeContext.ConfigurationKey, ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Configure_ForcedSelection_WithoutDetection_IsNotActive()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
Assert.True(ApplicationRuntimeContext.IsConfigured);
Assert.Equal(DatabaseSchemaSelection.Omika, ApplicationRuntimeContext.Current.Selection);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
}
[Fact]
public void Configure_Auto_WithoutDetection_IsUnknown()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
Assert.True(ApplicationRuntimeContext.IsConfigured);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
}
[Fact]
public void Configure_Auto_WithDetectedOmika_IsOmika()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
Assert.Equal(DatabaseSchemaVersion.Omika, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_Auto_WithDetectedBatik_IsBatik()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsBatik);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.Equal(DatabaseSchemaVersion.Batik, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_ForcedOmika_WithDetectedOmika_IsOmika()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsOmika);
Assert.Equal(DatabaseSchemaVersion.Omika, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_ForcedBatik_WithDetectedBatik_IsBatik()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Batik);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsSuccess);
Assert.True(ApplicationRuntimeContext.Current.IsBatik);
Assert.Equal(DatabaseSchemaVersion.Batik, ApplicationRuntimeContext.Current.DetectedVersion);
}
[Fact]
public void Configure_ForcedOmika_WithDetectedBatik_ReturnsFailureAndKeepsUnknown()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
}
[Fact]
public void Configure_ForcedBatik_WithDetectedOmika_ReturnsFailureAndKeepsUnknown()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Batik);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Omika);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
Assert.Equal(DatabaseSchemaVersion.Unknown, ApplicationRuntimeContext.Current.DetectedVersion);
Assert.False(ApplicationRuntimeContext.Current.IsOmika);
Assert.False(ApplicationRuntimeContext.Current.IsBatik);
}
[Fact]
public void SetDetectedVersion_UnknownVersion_ReturnsFailure()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto);
var result = ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Unknown);
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void ValidateDetectedVersion_ForcedSelection_Mismatch_ReturnsFailure()
{
ApplicationRuntimeContext.Reset();
ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Omika);
var result = ApplicationRuntimeContext.Current.ValidateDetectedVersion(DatabaseSchemaVersion.Batik);
Assert.True(result.IsFailure);
var error = Assert.Single(result.Errors);
Assert.Contains(nameof(DatabaseSchemaSelection.Omika), error, StringComparison.Ordinal);
Assert.Contains(nameof(DatabaseSchemaVersion.Batik), error, StringComparison.Ordinal);
}
[Fact]
public void FromConfiguration_ReturnsContextWithParsedSelection()
{
var result = ApplicationRuntimeContext.FromConfiguration("Batik");
Assert.True(result.IsSuccess);
Assert.Equal(DatabaseSchemaSelection.Batik, result.Value.Selection);
Assert.Equal(DatabaseSchemaVersion.Unknown, result.Value.DetectedVersion);
Assert.False(result.Value.IsBatik);
Assert.False(result.Value.IsOmika);
}
[Fact]
public void FromConfiguration_UnknownValue_ReturnsFailure()
{
var result = ApplicationRuntimeContext.FromConfiguration("n'importe quoi");
Assert.True(result.IsFailure);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void Current_WithoutConfiguration_ThrowsClearError()
{
ApplicationRuntimeContext.Reset();
var ex = Assert.Throws<InvalidOperationException>(() => _ = ApplicationRuntimeContext.Current);
Assert.Contains(nameof(ApplicationRuntimeContext.Configure), ex.Message, StringComparison.Ordinal);
}
}
@@ -0,0 +1,530 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Interventions;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Models.Batik;
using Omika.Data.Repositories.Batik;
namespace Omika.Tests.Architecture;
/// <summary>
/// Tests de la stratégie BULK bornée de
/// <see cref="BatikInterventionRepository.SearchDetailsAsync"/>.
///
/// Ces tests garantissent que le bulk des interventions :
/// <list type="bullet">
/// <item>n'appelle JAMAIS GetDetailsAsync par intervention (anti-N+1) ;</item>
/// <item>charge chaque table relationnelle en UNE SEULE requête HFSQL (colonnes
/// ciblées) puis filtre en mémoire via un HashSet des identifiants de la page ;
/// le nombre de requêtes relationnelles est borné (1 par table), indépendant du
/// nombre d'interventions ;</item>
/// <item>n'utilise aucun WHERE IN massif ni aucun batching Chunk.</item>
/// </list>
///
/// Le test comportemental <see cref="SearchDetailsAsync_200Interventions_RelationsBulkBornees"/>
/// exécute réellement le repository sur un <see cref="SpyingHfsqlConnection"/>
/// (fake mémoire, aucun HFSQL réel) et vérifie le comportement observé :
/// nombre de requêtes borné (1 par table), absence de GetById individuel,
/// filtrage correct des lignes hors page, assemblage correct des relations.
/// Il ne repose sur aucune recherche de texte source.
/// </summary>
public class BatikInterventionBulkStrategyTests
{
private static Type RepositoryType => typeof(BatikInterventionRepository);
[Fact]
public void SearchDetailsAsync_DoesNotCallGetDetailsAsync()
{
// Le bulk SearchDetailsAsync doit assembler les relations en mémoire et
// ne JAMAIS déléguer à GetDetailsAsync par intervention (N+1).
MethodInfo? searchDetails = RepositoryType.GetMethod(
"SearchDetailsAsync",
BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(searchDetails);
// Vérifie que le corps de SearchDetailsAsync ne contient pas d'appel à
// GetDetailsAsync. On inspecte le code IL : aucun appel à la méthode
// GetDetailsAsync ne doit être présent.
MethodBody? body = searchDetails.GetMethodBody();
Assert.NotNull(body);
byte[]? il = body.GetILAsByteArray();
Assert.NotNull(il);
byte[] ilBytes = il;
// Recherche de la signature de GetDetailsAsync dans le même type.
MethodInfo? getDetails = RepositoryType.GetMethod(
"GetDetailsAsync",
BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(getDetails);
// Le token de la méthode GetDetailsAsync ne doit pas apparaître dans le
// corps IL de SearchDetailsAsync.
int token = getDetails.MetadataToken;
byte[] tokenBytes = BitConverter.GetBytes(token);
Assert.DoesNotContain(ilBytes, b => b == tokenBytes[0] && ContainsToken(ilBytes, tokenBytes));
}
[Fact]
public void BulkRelationLoaders_Exist()
{
// Les trois chargeurs relationnels BULK doivent exister.
Assert.NotNull(RepositoryType.GetMethod(
"LoadInterventionPersonnesBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
Assert.NotNull(RepositoryType.GetMethod(
"LoadInterventionCollaborateursBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
Assert.NotNull(RepositoryType.GetMethod(
"LoadEtatsInterventionBulkAsync",
BindingFlags.NonPublic | BindingFlags.Instance));
}
[Fact]
public void BulkRelationLoaders_UseTargetedColumnSelection()
{
// Chaque chargeur relationnel BULK doit lire des colonnes ciblées (et
// non SELECT *) : les constantes SQL des colonnes doivent exister.
// Combiné au test comportemental SearchDetailsAsync_200Interventions_RelationsBulkBornees,
// cela garantit une lecture relationnelle ciblée en une seule requête,
// jamais un full scan de table entière ni un SELECT *.
Assert.NotNull(RepositoryType.GetField(
"SqlSelectInterventionPersonneColumns",
BindingFlags.NonPublic | BindingFlags.Static));
Assert.NotNull(RepositoryType.GetField(
"SqlSelectInterventionCollaborateurColumns",
BindingFlags.NonPublic | BindingFlags.Static));
Assert.NotNull(RepositoryType.GetField(
"SqlSelectEtatInterventionColumns",
BindingFlags.NonPublic | BindingFlags.Static));
}
[Fact]
public async Task SearchDetailsAsync_200Interventions_RelationsBulkBornees()
{
// Test COMPORTEMENTAL : exécute réellement SearchDetailsAsync sur un
// fake mémoire (SpyingHfsqlConnection) qui enregistre chaque requête.
// Aucun HFSQL réel, aucun ODBC, aucun réseau.
//
// Scénario : 200 interventions, chacune avec 1 personne et 1 état. Les
// tables relationnelles lues dans ce chemin (Intervention_Personne,
// EtatIntervention) doivent être chargées en UNE SEULE requête (lecture
// bulk ciblée), puis filtrées en mémoire via un HashSet. Le nombre de
// requêtes relationnelles doit être borné à 1 par table, soit 2 requêtes
// relationnelles au total — jamais 200 par table (N+1), jamais 4 par
// table (batching Chunk).
//
// On ajoute aussi des lignes PARASITES (interventions hors page, IDs
// 1000+) dans chaque table relationnelle pour vérifier que le filtre
// HashSet les exclut correctement : la lecture globale ne doit pas
// mélanger les relations d'autres interventions.
const int interventionCount = 200;
const int parasiteCount = 5;
var connection = new SpyingHfsqlConnection();
connection.InterventionRows = BuildInterventionRows(interventionCount);
connection.PersonneRows = BuildPersonneRows(interventionCount, parasiteCount);
connection.CollaborateurRows = BuildCollaborateurRows(interventionCount, parasiteCount);
connection.EtatRows = BuildEtatRows(interventionCount, parasiteCount);
var repository = new BatikInterventionRepository(connection);
PagedResult<Intervention> result = await repository.SearchDetailsAsync(
new InterventionSearchCriteria { Page = 1, PageSize = interventionCount });
// 1. Toutes les interventions sont retournées.
Assert.Equal(interventionCount, result.Items.Count);
// 2. Nombre de requêtes relationnelles borné : EXACTEMENT 1 par table.
// Aucune requête par intervention (N+1) : sinon on aurait 200 requêtes
// par table. Aucun batching Chunk : sinon on aurait 4 requêtes par
// table.
int personneQueries = connection.CountQueriesContaining("FROM Intervention_Personne");
int collaborateurQueries = connection.CountQueriesContaining("FROM Intervention_Collaborateur");
int etatQueries = connection.CountQueriesContaining("FROM EtatIntervention");
Assert.Equal(1, personneQueries);
// SearchDetailsAsync charge en bulk les personnes et les états. La table
// Intervention_Collaborateur n'est pas lue dans ce chemin : les
// collaborateurs sont résolus par la couche service (MobileIdentityMapper)
// à partir de la référence source, pas par un bulk relationnel ici.
Assert.Equal(0, collaborateurQueries);
Assert.Equal(1, etatQueries);
// 3. Aucun GetByIdAsync individuel : aucune requête ne cible une seule
// intervention par IN_PKID = ? (pattern du détail).
Assert.Equal(0, connection.CountQueriesContaining("WHERE IN_PKID = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE IP_IDIntervention = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE IC_IDIntervention = ?"));
Assert.Equal(0, connection.CountQueriesContaining("WHERE EI_IDIntervention = ?"));
// 4. Aucun WHERE IN massif dans les lectures relationnelles : les tables
// relationnelles sont lues globalement (sans clause IN) puis filtrées
// en mémoire via un HashSet. L'enrichissement client/site/adresse
// utilise une jointure unique avec un IN paramétré borné à la page,
// ce qui n'est pas une lecture relationnelle.
Assert.Equal(0, connection.CountQueriesContaining("FROM Intervention_Personne", "IN ("));
Assert.Equal(0, connection.CountQueriesContaining("FROM EtatIntervention", "IN ("));
Assert.Equal(0, connection.CountQueriesContaining("FROM Intervention_Collaborateur", "IN ("));
// 5. Les relations sont assemblées correctement : chaque intervention
// porte exactement 1 action (état) rattachée. Les lignes parasites
// (interventions hors page) ne doivent pas être rattachées.
foreach (Intervention intervention in result.Items)
{
Assert.Single(intervention.Actions);
}
}
// --- Helpers --------------------------------------------------------------
private static List<Dictionary<string, object?>> BuildInterventionRows(int count)
{
var rows = new List<Dictionary<string, object?>>(count);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IN_PKID"] = (long)i,
["IN_IDTypeIntervention"] = 0L,
["IN_IDEtatIntervention"] = 0L,
["IN_IDSuiviPar"] = 0L,
["IN_IDDossier"] = 0L,
["IN_IDDemandeur"] = 0L,
["IN_IDClient"] = 0L,
["IN_IDLieu"] = 0L,
["IN_Description"] = null,
["IN_AttestationTVAPresente"] = false,
["IN_NoteInterne"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L,
["IN_Titre"] = $"Intervention {i}",
["IN_RefInterne"] = $"INT-{i:D4}",
["IN_RefExterne"] = null,
["IN_IDImport"] = null,
["IN_IDSociete"] = 0L,
["Archive"] = false,
["DateArchivage"] = DateTime.MinValue,
["IN_DateIntervention"] = new DateOnly(2026, 1, 1),
["IN_DureePrevue"] = 0f,
["IN_NumDevis"] = null,
["IN_DateDevis"] = default(DateOnly),
["IN_NumFacture"] = null,
["IN_DateFacture"] = default(DateOnly),
["IN_NumIntervention"] = null
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildPersonneRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IP_PKID"] = (long)i,
["IP_IDIntervention"] = (long)i,
["IP_IDPersonne"] = (long)i,
["IP_IDClient"] = 0L,
["IP_IDLieu"] = 0L,
["IP_IDRole"] = 0L,
["IP_IDImport"] = 0L,
["IP_EstDemandeur"] = false,
["IP_Etage"] = null,
["IP_Lot"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
// Lignes parasites : interventions hors page (IDs 1000+). Elles doivent
// être exclues par le filtre HashSet du repository.
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["IP_PKID"] = id,
["IP_IDIntervention"] = id,
["IP_IDPersonne"] = id,
["IP_IDClient"] = 0L,
["IP_IDLieu"] = 0L,
["IP_IDRole"] = 0L,
["IP_IDImport"] = 0L,
["IP_EstDemandeur"] = false,
["IP_Etage"] = null,
["IP_Lot"] = null,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildCollaborateurRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["IC_PKID"] = (long)i,
["IC_IDIntervention"] = (long)i,
["IC_IDCollaborateur"] = (long)i,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
// Lignes parasites : interventions hors page (IDs 1000+).
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["IC_PKID"] = id,
["IC_IDIntervention"] = id,
["IC_IDCollaborateur"] = id,
["CreationDate"] = DateTime.MinValue,
["CreationUser"] = 0L,
["ModifDate"] = DateTime.MinValue,
["ModifUser"] = 0L
});
}
return rows;
}
private static List<Dictionary<string, object?>> BuildEtatRows(int count, int parasiteCount = 0)
{
var rows = new List<Dictionary<string, object?>>(count + parasiteCount);
for (int i = 1; i <= count; i++)
{
rows.Add(new Dictionary<string, object?>
{
["EI_PKID"] = (long)i,
["EI_IDEtat"] = 0L,
["EI_DateDebut"] = new DateTime(2026, 1, 1),
["EI_DateFin"] = new DateTime(2026, 1, 2),
["EI_Description"] = $"État {i}",
["EI_IDIntervention"] = (long)i
});
}
// Lignes parasites : interventions hors page (IDs 1000+).
for (int p = 1; p <= parasiteCount; p++)
{
long id = 1000 + p;
rows.Add(new Dictionary<string, object?>
{
["EI_PKID"] = id,
["EI_IDEtat"] = 0L,
["EI_DateDebut"] = new DateTime(2026, 1, 1),
["EI_DateFin"] = new DateTime(2026, 1, 2),
["EI_Description"] = $"État parasite {p}",
["EI_IDIntervention"] = id
});
}
return rows;
}
private static bool ContainsToken(byte[] il, byte[] tokenBytes)
{
for (int i = 0; i <= il.Length - tokenBytes.Length; i++)
{
bool match = true;
for (int j = 0; j < tokenBytes.Length; j++)
{
if (il[i + j] != tokenBytes[j])
{
match = false;
break;
}
}
if (match)
{
return true;
}
}
return false;
}
// =========================================================================
// SpyingHfsqlConnection
// =========================================================================
// Fake mémoire de IHfsqlConnection qui enregistre chaque requête SQL et
// retourne des lignes par table. Aucun HFSQL réel, aucun ODBC, aucun réseau.
// Permet de compter les requêtes et de vérifier le comportement bulk réel.
// =========================================================================
private sealed class SpyingHfsqlConnection : IHfsqlConnection
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State { get; private set; } = HfsqlConnectionState.Closed;
public int QueryCount { get; private set; }
public IReadOnlyList<string> AllSql => _allSql;
private readonly List<string> _allSql = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> InterventionRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> PersonneRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> CollaborateurRows { get; set; } = [];
public IReadOnlyList<IReadOnlyDictionary<string, object?>> EtatRows { get; set; } = [];
public int CountQueriesContaining(params string[] fragments)
=> _allSql.Count(sql => fragments.All(f => sql.Contains(f, StringComparison.OrdinalIgnoreCase)));
private void Record(string sql)
{
QueryCount++;
_allSql.Add(sql);
}
public Task OpenAsync(CancellationToken cancellationToken = default)
{
State = HfsqlConnectionState.Open;
return Task.CompletedTask;
}
public Task CloseAsync()
{
State = HfsqlConnectionState.Closed;
return Task.CompletedTask;
}
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
if (sql.Contains("COUNT(*)", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<object?>((long)InterventionRows.Count);
}
return Task.FromResult<object?>(null);
}
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
return Task.FromResult(0);
}
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken cancellationToken = default)
{
Record(sql);
return Task.FromResult(ResolveRows(sql, parameters));
}
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(
string tableName, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
}
public ValueTask DisposeAsync()
{
State = HfsqlConnectionState.Closed;
return ValueTask.CompletedTask;
}
private IReadOnlyList<IReadOnlyDictionary<string, object?>> ResolveRows(
string sql, IReadOnlyList<object?>? parameters)
{
// Les requêtes relationnelles sont des lectures globales (SELECT ...
// FROM table, sans WHERE IN) : le repository filtre ensuite en mémoire
// via un HashSet des identifiants de la page. Le fake retourne donc
// TOUTES les lignes (y compris les parasites hors page) pour que le
// filtre mémoire du repository soit réellement exercé.
if (ContainsFrom(sql, "Intervention_Personne"))
{
return FilterByInterventionId(PersonneRows, parameters, "IP_IDIntervention");
}
if (ContainsFrom(sql, "Intervention_Collaborateur"))
{
return FilterByInterventionId(CollaborateurRows, parameters, "IC_IDIntervention");
}
if (ContainsFrom(sql, "EtatIntervention"))
{
return FilterByInterventionId(EtatRows, parameters, "EI_IDIntervention");
}
if (ContainsFrom(sql, "Intervention"))
{
return InterventionRows;
}
return [];
}
private static IReadOnlyList<IReadOnlyDictionary<string, object?>> FilterByInterventionId(
IReadOnlyList<IReadOnlyDictionary<string, object?>> rows,
IReadOnlyList<object?>? parameters,
string idColumn)
{
if (parameters is null || parameters.Count == 0)
{
return rows;
}
var ids = new HashSet<long>();
foreach (object? p in parameters)
{
if (p is long l)
{
ids.Add(l);
}
else if (p is int i)
{
ids.Add(i);
}
}
if (ids.Count == 0)
{
return rows;
}
return rows
.Where(r => r.TryGetValue(idColumn, out object? v) && v is long id && ids.Contains(id))
.ToList();
}
private static bool ContainsFrom(string sql, string table)
{
string marker = $"FROM {table}";
int index = sql.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
while (index >= 0)
{
int next = index + marker.Length;
if (next >= sql.Length || (!char.IsLetterOrDigit(sql[next]) && sql[next] != '_'))
{
return true;
}
index = sql.IndexOf(marker, index + 1, StringComparison.OrdinalIgnoreCase);
}
return false;
}
}
}
@@ -0,0 +1,29 @@
using System.Reflection;
using Omika.Core.Observability;
namespace Omika.Tests.Architecture;
/// <summary>
/// Règle d'architecture (Phase 1) : le métier ne doit référencer aucune pile
/// d'export d'observabilité (OpenTelemetry, SigNoz, ClickHouse, OTLP). Il ne
/// connaît que les abstractions <c>IOmikaTelemetry</c> / <c>IEventTracker</c> /
/// <c>IDiagnosticLogger</c>. OpenTelemetry n'étant pas installé, ce test prépare
/// la règle pour les phases suivantes.
/// </summary>
public class BusinessMustNotReferenceObservabilityExportTests
{
[Fact]
public void OmikaCore_DoesNotReference_OpenTelemetryOrSigNozOrClickHouseOrOtlp()
{
Assembly core = typeof(IOmikaTelemetry).Assembly;
string[] referenced = core.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.ToArray();
Assert.DoesNotContain(referenced, n => n.StartsWith("OpenTelemetry", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.StartsWith("SigNoz", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.StartsWith("ClickHouse", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, n => n.Contains("OTLP", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,21 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer ASP.NET Core : les contrats sont de purs
/// DTO (pagination incluse) sans dépendance framework web.
/// </summary>
public class CoreMustNotReferenceAspNetCoreTests
{
[Fact]
public void OmikaCore_DoesNotReference_AspNetCore()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name ?? string.Empty).ToArray();
Assert.DoesNotContain(referenced, name => name.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal));
Assert.DoesNotContain(referenced, name => name.StartsWith("Microsoft.Extensions.Primitives", StringComparison.Ordinal));
}
}
@@ -0,0 +1,29 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer Avalonia : le domaine et les contrats sont
/// des bibliothèques de classes pures.
/// </summary>
public class CoreMustNotReferenceAvaloniaTests
{
[Fact]
public void OmikaCore_DoesNotReference_Avalonia()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
string[] avaloniaNames =
[
"Avalonia",
"Avalonia.Base",
"Avalonia.Controls",
"Avalonia.Desktop",
"Avalonia.Themes.Fluent"
];
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name ?? string.Empty).ToArray();
Assert.DoesNotContain(referenced, name => avaloniaNames.Contains(name));
}
}
@@ -0,0 +1,45 @@
using System.Reflection;
namespace Omika.Tests.Architecture;
/// <summary>
/// Omika.Core ne doit pas référencer Omika.Data : le modèle métier canonique et
/// les contrats API sont indépendants de la persistance HFSQL.
/// </summary>
public class CoreMustNotReferenceDataTests
{
[Fact]
public void OmikaCore_DoesNotReference_OmikaData()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
Assert.DoesNotContain(
core.GetReferencedAssemblies(),
assembly => assembly.Name == "Omika.Data");
}
[Fact]
public void OmikaCore_ContainsNoHfSqlAttribute()
{
Assembly core = typeof(Omika.Core.Domain.Common.EntityBase).Assembly;
// Les attributs HFSQL appartiennent à Omika.Data : aucun type d'Omika.Core
// ne doit en porter.
var hfSqlAttributeNames = new[]
{
"HfSqlTableAttribute",
"HfSqlColumnAttribute",
"HfSqlPrimaryKeyAttribute"
};
foreach (Type type in core.GetTypes())
{
foreach (string attributeName in hfSqlAttributeNames)
{
Assert.DoesNotContain(
type.GetCustomAttributesData(),
attr => attr.AttributeType.Name == attributeName);
}
}
}
}
@@ -0,0 +1,165 @@
using System.Reflection;
using Omika.Core.Contracts.Clients;
using Omika.Core.Contracts.Common;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Clients;
using Omika.Core.Interfaces;
namespace Omika.Tests.Architecture;
/// <summary>
/// Tests du contrat canonique <see cref="IClientRepository"/> : indépendance
/// vis-à-vis d'Omika.Data, de HFSQL/ODBC et des modèles physiques, périmètre
/// lecture seule et compatibilité avec une implémentation en mémoire.
/// </summary>
public class IClientRepositoryContractTests
{
private static Type RepositoryType => typeof(IClientRepository);
private static Assembly CoreAssembly => typeof(IClientRepository).Assembly;
[Fact]
public void Interface_ExistsInOmikaCore()
{
Assert.True(typeof(IClientRepository).IsInterface);
Assert.Equal("Omika.Core", CoreAssembly.GetName().Name);
}
[Fact]
public void Core_DoesNotReferenceOmikaData()
{
Assert.DoesNotContain(
CoreAssembly.GetReferencedAssemblies(),
assembly => assembly.Name == "Omika.Data");
}
[Fact]
public void Core_DoesNotReferenceOdbcOrHfSql()
{
Assert.DoesNotContain(
CoreAssembly.GetReferencedAssemblies(),
assembly => assembly.Name == "System.Data.Odbc");
}
[Fact]
public void Signatures_DoNotExposePhysicalRowTypes()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.False(
method.ReturnType.Name.EndsWith("Row", StringComparison.Ordinal),
$"Le retour de « {method.Name} » expose un type physique « {method.ReturnType.Name} ».");
foreach (ParameterInfo parameter in method.GetParameters())
{
Assert.False(
parameter.ParameterType.Name.EndsWith("Row", StringComparison.Ordinal),
$"Le paramètre « {parameter.Name} » de « {method.Name} » expose un type physique « {parameter.ParameterType.Name} ».");
}
}
}
[Fact]
public void HasSearchMethod_ReturningCanonicalPagedResult()
{
MethodInfo? search = RepositoryType.GetMethod("SearchAsync");
Assert.NotNull(search);
Assert.Equal(typeof(Task<PagedResult<Client>>), search.ReturnType);
}
[Fact]
public void HasDetailsMethod_ReturningNullableClient()
{
MethodInfo? details = RepositoryType.GetMethod("GetDetailsAsync");
Assert.NotNull(details);
Assert.Equal(typeof(Task<Client>), details.ReturnType);
}
[Fact]
public void AllMethods_AcceptCancellationToken()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.Contains(
method.GetParameters(),
parameter => parameter.ParameterType == typeof(CancellationToken));
}
}
[Fact]
public void HasNoDeleteMethods()
{
// CreateAsync, UpdateAsync et ArchiveAsync existent maintenant (écriture
// supportée). Seule la suppression physique (Delete) reste interdite.
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.DoesNotContain("Delete", method.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void HasNoBatikOrOmikaSpecificMethods()
{
foreach (MethodInfo method in RepositoryType.GetMethods())
{
Assert.DoesNotContain("Batik", method.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Omika", method.Name, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Legacy", method.Name, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void CanBeImplementedByInMemoryFake()
{
var fake = new InMemoryClientRepository();
PagedResult<Client> result = fake.SearchAsync(new ClientSearchCriteria()).GetAwaiter().GetResult();
Client? missing = fake.GetDetailsAsync("CL-001").GetAwaiter().GetResult();
Assert.Empty(result.Items);
Assert.Null(missing);
}
/// <summary>
/// Implémentation en mémoire minimale prouvant que le contrat est
/// implémentable sans aucune dépendance physique.
/// </summary>
private sealed class InMemoryClientRepository : IClientRepository
{
public Task<Client> CreateAsync(
ClientCreateCommand command,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(command);
return Task.FromResult(new Client { Nom = command.Nom });
}
public Task<PagedResult<Client>> SearchAsync(
ClientSearchCriteria criteria,
CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Client>([], 1, 20, 0, 0));
public Task<PagedResult<Client>> SearchDetailsAsync(
ClientSearchCriteria criteria,
CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Client>([], 1, 20, 0, 0));
public Task<Client?> GetDetailsAsync(
string id,
CancellationToken cancellationToken = default)
=> Task.FromResult<Client?>(null);
public Task<Client?> UpdateAsync(
string id, Omika.Core.Domain.Commands.ClientUpdateCommand command,
CancellationToken cancellationToken = default)
=> Task.FromResult<Client?>(null);
public Task<bool> ArchiveAsync(
string id,
CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
}
@@ -0,0 +1,99 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Architecture;
/// <summary>
/// Aucun contrat public ne doit contenir de nom de rubrique HFSQL, de préfixe
/// physique (CL_, IN_, PL_, RDV_, DC_…) ni de nom de table de base.
/// </summary>
public class PublicContractsMustNotContainHfSqlNamesTests
{
private static readonly string[] ForbiddenFragments =
[
"CL_", "IN_", "PL_", "RDV_", "AD_Ligne", "DC_",
"PKID", "IDType", "CleType", "IDStatut", "CleStatut",
"RDP_GUID", "PL_PKID", "RDP_", "HfSql", "HFSQL"
];
private static readonly string[] ForbiddenTableNames =
[
"Planning_RDV", "RDV_Planning", "RDV_Personne", "RDV_Collaborateur",
"RDV_Contact", "AccesEtDroit", "CategorieAcces", "RubriquesDocument"
];
/// <summary>
/// Noms interdits en tant que nom de propriété complet (et non sous-chaîne,
/// pour ne pas rejeter des mots métier français comme « Comptable »).
/// </summary>
private static readonly string[] ForbiddenExactPropertyNames =
[
"Table", "Row"
];
private static IEnumerable<Type> GetContractTypes()
=> typeof(PagedResult<>).Assembly
.GetTypes()
.Where(t => t.IsPublic)
.Where(t => t.Namespace?.StartsWith("Omika.Core.Contracts", StringComparison.Ordinal) == true)
.Where(t => !t.IsGenericTypeDefinition || t.Name.Contains("PagedResult", StringComparison.Ordinal));
private static IEnumerable<string> GetPublicPropertyNames(Type type)
=> type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name);
[Fact]
public void NoContractType_HasPhysicalTableOrPrefixInItsName()
{
foreach (Type type in GetContractTypes())
{
foreach (string forbidden in ForbiddenTableNames.Concat(ForbiddenFragments))
{
Assert.False(
type.Name.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
$"Le type de contrat « {type.FullName} » contient la référence physique « {forbidden} ».");
}
}
}
[Fact]
public void NoContractProperty_HasPhysicalPrefixOrRubricName()
{
foreach (Type type in GetContractTypes())
{
foreach (string propertyName in GetPublicPropertyNames(type))
{
// Vérification des fragments de rubrique / noms de table.
foreach (string forbidden in ForbiddenTableNames.Concat(ForbiddenFragments))
{
Assert.False(
propertyName.Contains(forbidden, StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{propertyName} » contient la référence physique « {forbidden} ».");
}
// Vérification des noms interdits uniquement en nom complet,
// afin d'éviter les faux positifs (« Comptable » contient « Table »).
Assert.DoesNotContain(
ForbiddenExactPropertyNames,
name => string.Equals(name, propertyName, StringComparison.OrdinalIgnoreCase));
}
}
}
[Fact]
public void Contracts_DoNotExposeOmikaDataTypes()
{
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
string propertyTypeName = property.PropertyType.Name;
Assert.False(
propertyTypeName.Contains("Row", StringComparison.OrdinalIgnoreCase)
&& propertyTypeName.EndsWith("Row", StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{property.Name} » expose un type physique « {propertyTypeName} ».");
}
}
}
}
@@ -0,0 +1,61 @@
using System.Reflection;
using Omika.Core.Contracts.Common;
namespace Omika.Tests.Architecture;
/// <summary>
/// Aucun contrat public ne doit exposer de « champ zombie » : les rubriques
/// physiques obsolètes (anciens identifiants de civilité ou de statut, clés
/// internes, rubriques marquées legacy) sont converties par le mapper et ne
/// doivent jamais transiter telles quelles vers l'application.
/// </summary>
public class PublicContractsMustNotContainZombieFieldsTests
{
private static readonly string[] ZombieMarkers =
[
"Zombie", "LegacyField", "IsLegacy", "Ancien", "OldId",
"IdCivilite", "IdStatut", "CleStatut", "CleType", "IDType",
"PKID", "RDP_GUID", "PL_PKID", "GUIDPhysique", "PhysicalGuid",
"NumeroCle", "CleNumero"
];
private static IEnumerable<Type> GetContractTypes()
=> typeof(PagedResult<>).Assembly
.GetTypes()
.Where(t => t.IsPublic)
.Where(t => t.Namespace?.StartsWith("Omika.Core.Contracts", StringComparison.Ordinal) == true);
[Fact]
public void NoContractProperty_HasZombieMarker()
{
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
foreach (string marker in ZombieMarkers)
{
Assert.False(
property.Name.Contains(marker, StringComparison.OrdinalIgnoreCase),
$"Le contrat « {type.FullName}.{property.Name} » expose le champ zombie « {marker} ».");
}
}
}
}
[Fact]
public void NoContractProperty_HasPhysicalRowShapedTypes()
{
// Les DTO ne doivent pas exposer de propriété dont le type est une classe
// physique (Row) d'Omika.Data : les contrats sont autonomes.
foreach (Type type in GetContractTypes())
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
string ns = property.PropertyType.Namespace ?? string.Empty;
Assert.False(
ns.StartsWith("Omika.Data", StringComparison.Ordinal),
$"Le contrat « {type.FullName}.{property.Name} » référence un type d'Omika.Data.");
}
}
}
}
@@ -0,0 +1,61 @@
using Omika.Core.Compatibility;
namespace Omika.Tests.Compatibility;
/// <summary>
/// Tests de <see cref="LegacyEntityReference"/> : conservation de la provenance
/// (Batik / Omika) sans exposer les noms HFSQL, et identifiant public stable.
/// </summary>
public class LegacyEntityReferenceTests
{
[Fact]
public void FromLegacyId_IsBatikAndKeepsNumericId()
{
var reference = LegacyEntityReference.FromLegacyId(42);
Assert.Equal(DatabaseSchemaVersion.Batik, reference.Source);
Assert.Equal(42, reference.NumericId);
Assert.Equal("42", reference.PublicIdentifier);
}
[Fact]
public void FromGuid_KeepsEntityGuidAndSchemaVersion()
{
var guid = Guid.NewGuid();
var reference = LegacyEntityReference.FromGuid(DatabaseSchemaVersion.Omika, guid);
Assert.Equal(DatabaseSchemaVersion.Omika, reference.Source);
Assert.Equal(guid, reference.EntityGuid);
Assert.Equal(guid.ToString("D"), reference.PublicIdentifier);
}
[Fact]
public void FromBusinessKey_UsesBusinessKey()
{
var reference = LegacyEntityReference.FromBusinessKey("BK-2026-01");
Assert.Equal("BK-2026-01", reference.BusinessKey);
Assert.Equal("BK-2026-01", reference.PublicIdentifier);
}
[Fact]
public void PhysicalGuid_IsNotInterpretedAsGuid()
{
var reference = new LegacyEntityReference(DatabaseSchemaVersion.Batik, PhysicalGuid: "1A2B-étrange");
Assert.Equal(DatabaseSchemaVersion.Batik, reference.Source);
Assert.Null(reference.EntityGuid);
Assert.Equal("1A2B-étrange", reference.PhysicalGuid);
}
[Fact]
public void Reference_DoesNotCarryConnectionSecret()
{
var reference = LegacyEntityReference.FromLegacyId(7);
var publicProperties = reference.GetType().GetProperties();
Assert.DoesNotContain(publicProperties, p => p.Name.Contains("Password", StringComparison.Ordinal));
Assert.DoesNotContain(publicProperties, p => p.Name.Contains("ConnectionString", StringComparison.Ordinal));
Assert.DoesNotContain(publicProperties, p => p.Name.Contains("MotDePasse", StringComparison.Ordinal));
}
}
@@ -0,0 +1,47 @@
using Omika.Core.Compatibility;
namespace Omika.Tests.Compatibility;
/// <summary>
/// Tests de <see cref="SchemaCapabilities"/> : le code métier peut savoir si une
/// fonctionnalité est disponible selon la version de base, sans connaître la
/// structure physique.
/// </summary>
public class SchemaCapabilitiesTests
{
[Fact]
public void Batik_DoesNotSupportNewAppointmentModel()
{
SchemaCapabilities batik = SchemaCapabilities.ForBatik();
Assert.Equal(DatabaseSchemaVersion.Batik, batik.Version);
Assert.False(batik.SupportsNewAppointmentModel);
Assert.False(batik.SupportsBusinessKeys);
Assert.False(batik.SupportsRoles);
}
[Fact]
public void Omika_SupportsNewFeatures()
{
SchemaCapabilities omika = SchemaCapabilities.ForOmika();
Assert.Equal(DatabaseSchemaVersion.Omika, omika.Version);
Assert.True(omika.SupportsNewAppointmentModel);
Assert.True(omika.SupportsBusinessKeys);
Assert.True(omika.SupportsEntityGuids);
Assert.True(omika.SupportsRoles);
Assert.True(omika.SupportsModificationTracking);
Assert.True(omika.SupportsReportModels);
Assert.True(omika.SupportsDocumentFields);
}
[Fact]
public void Capabilities_AreConfigurablePerInstance()
{
var custom = SchemaCapabilities.ForBatik() with { SupportsRoles = true };
Assert.True(custom.SupportsRoles);
// L'instance de base n'est pas mutée (record immutable).
Assert.False(SchemaCapabilities.ForBatik().SupportsRoles);
}
}
@@ -0,0 +1,98 @@
using Omika.Data.Configuration;
namespace Omika.Tests.Connection;
public class E2EOptionsTests
{
[Fact]
public void AllowHfSqlWrites_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.AllowHfSqlWrites);
}
[Fact]
public void ServiceDatabaseConfirmedTest_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.ServiceDatabaseConfirmedTest);
}
[Fact]
public void WebServiceDatabaseConfirmedTest_Default_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.WebServiceDatabaseConfirmedTest);
}
[Fact]
public void IsWriteAllowed_AllFlagsFalse_IsFalse()
{
var options = new E2EOptions();
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyAllowWrites_IsFalse()
{
var options = new E2EOptions { AllowHfSqlWrites = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyServiceConfirmed_IsFalse()
{
var options = new E2EOptions { ServiceDatabaseConfirmedTest = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_OnlyWebServiceConfirmed_IsFalse()
{
var options = new E2EOptions { WebServiceDatabaseConfirmedTest = true };
Assert.False(options.IsWriteAllowed);
}
[Fact]
public void IsWriteAllowed_AllFlagsTrue_IsTrue()
{
var options = new E2EOptions
{
AllowHfSqlWrites = true,
ServiceDatabaseConfirmedTest = true,
WebServiceDatabaseConfirmedTest = true
};
Assert.True(options.IsWriteAllowed);
}
[Fact]
public void SectionName_IsE2E()
{
Assert.Equal("E2E", E2EOptions.SectionName);
}
[Fact]
public void ToString_DoesNotExposeSecrets()
{
var options = new E2EOptions
{
AllowHfSqlWrites = true,
ServiceDatabaseConfirmedTest = true,
WebServiceDatabaseConfirmedTest = true
};
var text = options.ToString();
Assert.Contains("AllowHfSqlWrites = True", text);
Assert.Contains("ServiceDatabaseConfirmedTest = True", text);
Assert.Contains("WebServiceDatabaseConfirmedTest = True", text);
}
}
@@ -0,0 +1,452 @@
using System.Collections;
using System.Data;
using System.Data.Common;
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class HfSqlFileAccessResolverTests
{
private static HfSqlConnectionOptions CreateOptions() => new()
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret",
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = "STD_PASSWORD",
ProtectionPlus = "PLUS_PASSWORD"
}
};
[Fact]
public async Task ResolveAsync_DirectPass_ReturnsDirect()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Direct, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedPass_ReturnsProtectedMode()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.ProtectionStd, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedPlusPass_ReturnsProtectionPlus()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: ["ParametreSysteme"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreSysteme", ExpectedGroup = "ProtectionPlus" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.ProtectionPlus, result["ParametreSysteme"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_DirectFail_ProtectedFail_ReturnsFailed()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Failed, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_NoExpectedGroup_DirectFail_ReturnsFailed()
{
var factory = new FakeFactory(
directPass: [],
protectedPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = null }
};
var result = await resolver.ResolveAsync(files);
// Sans groupe attendu, pas de tentative protégée : le fichier reste Failed.
Assert.Equal(HfSqlFileAccessMode.Failed, result["Client"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_GetProfile_ReturnsCachedProfile()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
var profile = resolver.GetProfile("Client");
Assert.NotNull(profile);
Assert.Equal(HfSqlFileAccessMode.Direct, profile!.ObservedAccess);
}
[Fact]
public async Task ResolveAsync_GetProfile_UnknownFile_ReturnsNull()
{
var factory = new FakeFactory(directPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
Assert.Null(resolver.GetProfile("Inexistant"));
}
[Fact]
public async Task ResolveAsync_InvalidIdentifier_ReturnsFailed()
{
var factory = new FakeFactory(directPass: []);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Bad;Name", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
Assert.Equal(HfSqlFileAccessMode.Failed, result["Bad;Name"].ObservedAccess);
}
[Fact]
public async Task ResolveAsync_ExplicitStdPassword_UsedInExtendedProperties()
{
// Vérifie que le resolver utilise la valeur EXACTE de FilePasswords.ProtectionStd
// dans les Extended Properties (aucune dérivation).
var factory = new CapturingFactory(directPass: [], protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
await resolver.ResolveAsync(files);
Assert.NotNull(factory.LastExtendedProperties);
Assert.Contains("Password=Client:STD_PASSWORD", factory.LastExtendedProperties);
Assert.DoesNotContain("DeriveProtectionStd", factory.LastExtendedProperties);
}
[Fact]
public async Task ResolveAsync_ExplicitPlusPassword_UsedInExtendedProperties()
{
// Vérifie que le resolver utilise la valeur EXACTE de FilePasswords.ProtectionPlus
// dans les Extended Properties (aucune dérivation).
var factory = new CapturingFactory(directPass: [], protectedPass: ["ParametreSysteme"]);
var resolver = new HfSqlFileAccessResolver(factory, CreateOptions());
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "ParametreSysteme", ExpectedGroup = "ProtectionPlus" }
};
await resolver.ResolveAsync(files);
Assert.NotNull(factory.LastExtendedProperties);
Assert.Contains("Password=ParametreSysteme:PLUS_PASSWORD", factory.LastExtendedProperties);
Assert.DoesNotContain("DeriveProtectionPlus", factory.LastExtendedProperties);
}
[Fact]
public async Task ResolveAsync_NoPasswordsConfigured_DirectOnly()
{
// Sans passwords configurés, le resolver ne tente aucune connexion protégée.
var options = new HfSqlConnectionOptions
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret",
FilePasswords = new HfSqlFilePasswordOptions() // aucun password
};
var factory = new CapturingFactory(directPass: ["Client"], protectedPass: ["Client"]);
var resolver = new HfSqlFileAccessResolver(factory, options);
var files = new List<HfSqlFileAccessProfile>
{
new() { LogicalFile = "Client", ExpectedGroup = "ProtectionStd" }
};
var result = await resolver.ResolveAsync(files);
// Direct PASS : pas de connexion protégée nécessaire.
Assert.Equal(HfSqlFileAccessMode.Direct, result["Client"].ObservedAccess);
Assert.Null(factory.LastExtendedProperties);
}
[Fact]
public void ToString_DoesNotExposeSecrets()
{
var options = CreateOptions();
var str = options.ToString();
Assert.DoesNotContain("STD_PASSWORD", str);
Assert.DoesNotContain("PLUS_PASSWORD", str);
Assert.DoesNotContain("secret", str);
}
/// <summary>
/// Fabrique simulée qui capture les Extended Properties pour vérifier que
/// les mots de passe exacts des options sont utilisés (aucune dérivation).
/// </summary>
private sealed class CapturingFactory : IHfSqlOleDbConnectionFactory
{
private readonly HashSet<string> _directPass;
private readonly HashSet<string> _protectedPass;
public string? LastExtendedProperties { get; private set; }
public CapturingFactory(string[] directPass, string[]? protectedPass = null)
{
_directPass = new HashSet<string>(directPass, StringComparer.OrdinalIgnoreCase);
_protectedPass = new HashSet<string>(protectedPass ?? [], StringComparer.OrdinalIgnoreCase);
}
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
if (!string.IsNullOrWhiteSpace(extendedProperties))
{
LastExtendedProperties = extendedProperties;
}
bool isProtected = !string.IsNullOrWhiteSpace(extendedProperties);
return new FakeConnection(
isProtected ? _protectedPass : _directPass,
isProtected ? extendedProperties : null);
}
}
/// <summary>
/// Fabrique simulée : contrôle quels fichiers passent en Direct et lesquels
/// passent en protégé. Vérifie aussi que les mots de passe sont bien fournis
/// dans les Extended Properties de la connexion protégée.
/// </summary>
private sealed class FakeFactory : IHfSqlOleDbConnectionFactory
{
private readonly HashSet<string> _directPass;
private readonly HashSet<string> _protectedPass;
public FakeFactory(string[] directPass, string[]? protectedPass = null)
{
_directPass = new HashSet<string>(directPass, StringComparer.OrdinalIgnoreCase);
_protectedPass = new HashSet<string>(protectedPass ?? [], StringComparer.OrdinalIgnoreCase);
}
public DbConnection Create(HfSqlConnectionOptions options, string? extendedProperties = null)
{
bool isProtected = !string.IsNullOrWhiteSpace(extendedProperties);
return new FakeConnection(
isProtected ? _protectedPass : _directPass,
isProtected ? extendedProperties : null);
}
}
private sealed class FakeConnection : DbConnection
{
private readonly HashSet<string> _passingTables;
private readonly string? _extendedProperties;
private ConnectionState _state = ConnectionState.Closed;
public FakeConnection(HashSet<string> passingTables, string? extendedProperties)
{
_passingTables = passingTables;
_extendedProperties = extendedProperties;
}
public override string ConnectionString { get; set; } = "";
public override string Database => "Omika";
public override string DataSource => "hfsql-serveur:4900";
public override string ServerVersion => "1.0";
public override ConnectionState State => _state;
public override void ChangeDatabase(string databaseName) { }
public override void Close() => _state = ConnectionState.Closed;
public override void Open() => _state = ConnectionState.Open;
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand(_passingTables);
protected override void Dispose(bool disposing)
{
_state = ConnectionState.Closed;
base.Dispose(disposing);
}
}
private sealed class FakeCommand : DbCommand
{
private readonly HashSet<string> _passingTables;
public FakeCommand(HashSet<string> passingTables)
{
_passingTables = passingTables;
}
public override string CommandText { get; set; } = "";
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection { get; } = new FakeParameterCollection();
protected override DbTransaction? DbTransaction { get; set; }
public override void Cancel() { }
public override int ExecuteNonQuery() => 0;
public override object? ExecuteScalar() => null;
public override void Prepare() { }
protected override DbParameter CreateDbParameter() => new FakeParameter();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
// Extraire le nom de table depuis "SELECT TOP 1 * FROM <Table>".
var table = CommandText.Split("FROM ", StringSplitOptions.RemoveEmptyEntries)[^1].Trim();
if (_passingTables.Contains(table))
{
return new FakeReader();
}
throw new InvalidOperationException("Code erreur : 70114");
}
}
private sealed class FakeReader : DbDataReader
{
public override int FieldCount => 1;
public override bool HasRows => true;
public override bool IsClosed => false;
public override int RecordsAffected => 0;
public override object this[int ordinal] => throw new NotSupportedException();
public override object this[string name] => throw new NotSupportedException();
public override int Depth => 0;
public override bool GetBoolean(int ordinal) => throw new NotSupportedException();
public override byte GetByte(int ordinal) => throw new NotSupportedException();
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override char GetChar(int ordinal) => throw new NotSupportedException();
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
=> throw new NotSupportedException();
public override string GetDataTypeName(int ordinal) => throw new NotSupportedException();
public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException();
public override decimal GetDecimal(int ordinal) => throw new NotSupportedException();
public override double GetDouble(int ordinal) => throw new NotSupportedException();
public override Type GetFieldType(int ordinal) => throw new NotSupportedException();
public override float GetFloat(int ordinal) => throw new NotSupportedException();
public override Guid GetGuid(int ordinal) => throw new NotSupportedException();
public override short GetInt16(int ordinal) => throw new NotSupportedException();
public override int GetInt32(int ordinal) => throw new NotSupportedException();
public override long GetInt64(int ordinal) => throw new NotSupportedException();
public override string GetName(int ordinal) => throw new NotSupportedException();
public override int GetOrdinal(string name) => throw new NotSupportedException();
public override string GetString(int ordinal) => throw new NotSupportedException();
public override object GetValue(int ordinal) => throw new NotSupportedException();
public override int GetValues(object[] values) => throw new NotSupportedException();
public override bool IsDBNull(int ordinal) => throw new NotSupportedException();
public override bool NextResult() => false;
public override bool Read() => true;
public override IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
}
private sealed class FakeParameterCollection : DbParameterCollection
{
public override int Count => 0;
public override object SyncRoot => this;
public override int Add(object value) => 0;
public override void AddRange(Array values) { }
public override void Clear() { }
public override bool Contains(object value) => false;
public override bool Contains(string value) => false;
public override void CopyTo(Array array, int index) { }
public override IEnumerator GetEnumerator() => Array.Empty<object>().GetEnumerator();
public override int IndexOf(object value) => -1;
public override int IndexOf(string value) => -1;
public override void Insert(int index, object value) { }
public override void Remove(object value) { }
public override void RemoveAt(int index) { }
public override void RemoveAt(string parameterName) { }
protected override DbParameter GetParameter(int index) => new FakeParameter();
protected override DbParameter GetParameter(string parameterName) => new FakeParameter();
protected override void SetParameter(int index, DbParameter value) { }
protected override void SetParameter(string parameterName, DbParameter value) { }
}
private sealed class FakeParameter : DbParameter
{
public override DbType DbType { get; set; }
public override ParameterDirection Direction { get; set; }
public override bool IsNullable { get; set; }
public override string ParameterName { get; set; } = "";
public override string SourceColumn { get; set; } = "";
public override object? Value { get; set; }
public override bool SourceColumnNullMapping { get; set; }
public override int Size { get; set; }
public override void ResetDbType() { }
}
}
@@ -0,0 +1,312 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Organisation;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Collaborateur :
/// CREATE → UPDATE → ARCHIVE (désactivation), le tout dans UNE transaction
/// externe unique, puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbCollaborateurLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Collaborateur, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Collaborateur Batik.
var logicalFiles = new[]
{
"Collaborateur", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikCollaborateurRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new CollaborateurCreateCommand
{
Nom = "INTEGRATION_CYCLE_BATIK",
Prenom = "Cycle",
Code = "ICB",
Actif = true
};
Collaborateur created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Nom);
Assert.NotNull(created.SourceReference);
Assert.NotNull(created.SourceReference!.NumericId);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new CollaborateurUpdateCommand
{
Nom = "INTEGRATION_CYCLE_BATIK_UPDATED",
Actif = true
};
Collaborateur? updated = await repository.UpdateAsync(
created.SourceReference!.NumericId!.Value.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Nom);
// 7. ARCHIVE (désactivation) dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.SourceReference!.NumericId!.Value.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Collaborateur Omika.
var logicalFiles = new[]
{
"Collaborateur", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaCollaborateurRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new CollaborateurCreateCommand
{
Nom = "INTEGRATION_CYCLE_OMIKA",
Prenom = "Cycle",
Code = "ICO",
Actif = true
};
Collaborateur created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Nom);
Assert.NotNull(created.SourceReference);
Assert.NotNull(created.SourceReference!.EntityGuid);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new CollaborateurUpdateCommand
{
Nom = "INTEGRATION_CYCLE_OMIKA_UPDATED",
Actif = true
};
Collaborateur? updated = await repository.UpdateAsync(
created.SourceReference!.EntityGuid!.Value.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Nom);
// 7. ARCHIVE (désactivation) dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.SourceReference!.EntityGuid!.Value.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,104 @@
using Omika.Data.HfSql.Connection;
namespace Omika.Tests.Connection;
public class HfSqlOleDbConnectionStringBuilderTests
{
private static HfSqlConnectionOptions CreateOptions() => new()
{
Server = "hfsql-serveur",
Port = 4900,
Database = "Omika",
User = "tech",
Credential = "secret"
};
[Fact]
public void Build_ContainsProvider()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("PCSOFT.HFSQL", connectionString);
}
[Fact]
public void Build_ContainsServerAndPort()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("hfsql-serveur:4900", connectionString);
}
[Fact]
public void Build_ContainsDatabase()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("Omika", connectionString);
}
[Fact]
public void Build_ContainsUser()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("tech", connectionString);
}
[Fact]
public void Build_ContainsCredential()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.Contains("secret", connectionString);
}
[Fact]
public void Build_OmitsExtendedPropertiesWhenNull()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(CreateOptions());
Assert.DoesNotContain("Extended Properties", connectionString);
}
[Fact]
public void Build_IncludesExtendedPropertiesWhenProvided()
{
var connectionString = HfSqlOleDbConnectionStringBuilder.Build(
CreateOptions(),
"Password=Client:abc123");
Assert.Contains("Extended Properties", connectionString);
Assert.Contains("Password=Client:abc123", connectionString);
}
[Fact]
public void Build_NullOptions_Throws()
{
Assert.Throws<ArgumentNullException>(() => HfSqlOleDbConnectionStringBuilder.Build(null!));
}
[Fact]
public void EscapeExtendedPropertyValue_EscapesSpecialCharacters()
{
var escaped = HfSqlOleDbConnectionStringBuilder.EscapeExtendedPropertyValue("a=b;c:d\\e");
Assert.Equal("a\\=b\\;c\\:d\\\\e", escaped);
}
[Fact]
public void BuildFilePasswordEntry_DoesNotEscapeStructuralColon()
{
var entry = HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry("Client", "abc123");
Assert.Equal("Password=Client:abc123", entry);
}
[Fact]
public void BuildFilePasswordEntry_EscapesFilenameAndPasswordIndividually()
{
var entry = HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry("a=b", "c;d");
Assert.Equal("Password=a\\=b:c\\;d", entry);
}
}
@@ -0,0 +1,278 @@
using System.Data.Common;
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Clients;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du chemin de création (CreateAsync).
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée une transaction externe, on exécute <c>CreateAsync</c> dans cette
/// transaction, on vérifie le client créé, puis on annule (rollback) pour ne
/// laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur le client
/// canonique retourné.
/// </summary>
public class HfSqlOleDbCreateIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au chemin Create, en fonction du mode d'accès observé
/// par le résolveur. Les fichiers accessibles directement n'ont pas besoin
/// d'entrée.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task CreateAsync_Batik_PersistsAndRollsBack()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au chemin Create Batik.
var logicalFiles = new[] { "Client", "ParametreMetier", "Adresse", "Client_Adresse" };
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. Transaction externe (rollback à la fin).
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikClientRepository(connection);
// 5. Création dans la transaction externe.
var command = new ClientCreateCommand
{
Nom = "INTEGRATION_CREATE_BATIK",
Code = "INT-CREATE-BATIK",
AdresseLigne1 = "1 rue du Test",
AdresseCodePostal = "75000",
AdresseVille = "Paris"
};
Client client = await repository.CreateAsync(command, transaction, CancellationToken.None);
// 6. Vérification du client canonique.
Assert.NotNull(client);
Assert.Equal("INTEGRATION_CREATE_BATIK", client.Nom);
Assert.NotEqual(Guid.Empty, client.Id);
// 7. Rollback : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task CreateAsync_Omika_PersistsAndRollsBack()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au chemin Create Omika.
var logicalFiles = new[] { "Client", "ParametreMetier", "Adresse", "Client_Adresse" };
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. Transaction externe (rollback à la fin).
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaClientRepository(connection);
// 5. Création dans la transaction externe.
var command = new ClientCreateCommand
{
Nom = "INTEGRATION_CREATE_OMIKA",
Code = "INT-CREATE-OMIKA",
AdresseLigne1 = "1 rue du Test",
AdresseCodePostal = "75000",
AdresseVille = "Paris"
};
Client client = await repository.CreateAsync(command, transaction, CancellationToken.None);
// 6. Vérification du client canonique.
Assert.NotNull(client);
Assert.Equal("INTEGRATION_CREATE_OMIKA", client.Nom);
Assert.NotEqual(Guid.Empty, client.Id);
// 7. Rollback : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,306 @@
using System.Text.Json;
using Omika.Core.Domain.Commands;
using Omika.Core.Domain.Entities.Interventions;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Batik;
using Omika.Data.Repositories.Omika;
namespace Omika.Tests.Connection;
/// <summary>
/// Tests d'intégration OLE DB HFSQL réels du cycle de vie Dossier :
/// CREATE → UPDATE → ARCHIVE, le tout dans UNE transaction externe unique,
/// puis ROLLBACK total.
///
/// Ces tests nécessitent un serveur HFSQL accessible et le provider OLE DB
/// <c>PCSOFT.HFSQL</c> installé. Ils sont ignorés automatiquement si la
/// configuration locale n'est pas disponible ou si le serveur est injoignable.
///
/// Principe : on ouvre une connexion physique avec les mots de passe de fichiers
/// (Extended Properties) résolus par <see cref="HfSqlFileAccessResolver"/>, on
/// crée UNE transaction externe, on exécute Create puis Update puis Archive dans
/// cette même transaction, on vérifie chaque étape, puis on annule (rollback)
/// pour ne laisser AUCUNE trace en base.
///
/// Aucun secret n'est affiché : les assertions portent uniquement sur les
/// entités canoniques retournées.
/// </summary>
public class HfSqlOleDbDossierLifecycleIntegrationTests
{
private const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string? ProtectionStd { get; set; }
public string? ProtectionPlus { get; set; }
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private static TestConfig? LoadConfig()
{
if (!File.Exists(ConfigPath))
return null;
try
{
var json = File.ReadAllText(ConfigPath);
TestConfig? config = JsonSerializer.Deserialize<TestConfig>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// Serveur HFSQL injoignable : le test est ignoré (pas d'échec).
if (config is null || !IsHfsqlServerReachable(config.Server, config.Port))
return null;
return config;
}
catch
{
return null;
}
}
private static bool IsHfsqlServerReachable(string server, int port)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
return client.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3));
}
catch
{
return false;
}
}
private static HfSqlConnectionOptions BuildOptions(TestConfig config, DatabaseConfig db)
{
return new HfSqlConnectionOptions
{
Server = config.Server,
Port = config.Port,
Database = db.Database,
User = db.User,
Credential = db.Credential,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = config.FilePasswords.ProtectionStd,
ProtectionPlus = config.FilePasswords.ProtectionPlus
}
};
}
/// <summary>
/// Construit les Extended Properties (mots de passe de fichiers) pour les
/// fichiers nécessaires au cycle de vie Dossier, en fonction du mode
/// d'accès observé par le résolveur.
/// </summary>
private static string? BuildExtendedProperties(
TestConfig config,
HfSqlConnectionOptions options,
IReadOnlyList<string> logicalFiles)
{
var factory = new HfSqlOleDbConnectionFactory();
var resolver = new HfSqlFileAccessResolver(factory, options);
var profiles = logicalFiles
.Select(f => new HfSqlFileAccessProfile { LogicalFile = f })
.ToList();
var resolved = resolver.ResolveAsync(profiles).GetAwaiter().GetResult();
var entries = new List<string>();
foreach (var logicalFile in logicalFiles)
{
if (!resolved.TryGetValue(logicalFile, out var profile))
continue;
string? password = profile.ObservedAccess switch
{
HfSqlFileAccessMode.ProtectionStd => config.FilePasswords.ProtectionStd,
HfSqlFileAccessMode.ProtectionPlus => config.FilePasswords.ProtectionPlus,
_ => null
};
if (password is null)
continue;
entries.Add(HfSqlOleDbConnectionStringBuilder.BuildFilePasswordEntry(logicalFile, password));
}
return entries.Count == 0 ? null : string.Join(";", entries);
}
[Fact]
public async Task Batik_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Batik);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Dossier Batik.
var logicalFiles = new[]
{
"Dossier", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Batik.
var repository = new BatikDossierRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new DossierCreateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK",
ReferenceMetier = "DOS-CYCLE-BATIK"
};
Dossier created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_BATIK", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new DossierUpdateCommand
{
Titre = "INTEGRATION_CYCLE_BATIK_UPDATED",
ReferenceMetier = "DOS-CYCLE-BATIK-UPD"
};
Dossier? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_BATIK_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
[Fact]
public async Task Omika_CreateUpdateArchive_RollsBackAtomically()
{
var config = LoadConfig();
if (config is null)
return; // config absente : test ignoré
var options = BuildOptions(config, config.Omika);
var factory = new HfSqlOleDbConnectionFactory();
// Fichiers nécessaires au cycle de vie Dossier Omika.
var logicalFiles = new[]
{
"Dossier", "ParametreMetier"
};
string? extendedProperties = BuildExtendedProperties(config, options, logicalFiles);
try
{
// 1. Connexion physique avec les mots de passe de fichiers.
var physical = factory.Create(options, extendedProperties);
await physical.OpenAsync();
// 2. UNE transaction externe pour tout le cycle de vie.
await using var transaction = await physical.BeginTransactionAsync();
// 3. Connexion HFSQL réutilisant la connexion physique + transaction.
await using var connection = new HfSqlOleDbConnection(
factory, options, physical, extendedProperties);
await connection.OpenAsync();
// 4. Repository Omika.
var repository = new OmikaDossierRepository(connection);
// 5. CREATE dans la transaction externe.
var createCommand = new DossierCreateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA",
ReferenceMetier = "DOS-CYCLE-OMIKA"
};
Dossier created = await repository.CreateAsync(createCommand, transaction, CancellationToken.None);
Assert.NotNull(created);
Assert.Equal("INTEGRATION_CYCLE_OMIKA", created.Titre);
Assert.NotEqual(Guid.Empty, created.Id);
// 6. UPDATE dans la MÊME transaction.
var updateCommand = new DossierUpdateCommand
{
Titre = "INTEGRATION_CYCLE_OMIKA_UPDATED",
ReferenceMetier = "DOS-CYCLE-OMIKA-UPD"
};
Dossier? updated = await repository.UpdateAsync(
created.Id.ToString(), updateCommand, transaction, CancellationToken.None);
Assert.NotNull(updated);
Assert.Equal("INTEGRATION_CYCLE_OMIKA_UPDATED", updated.Titre);
// 7. ARCHIVE dans la MÊME transaction.
bool archived = await repository.ArchiveAsync(
created.Id.ToString(), transaction, CancellationToken.None);
Assert.True(archived);
// 8. ROLLBACK total : aucune trace en base.
await transaction.RollbackAsync();
}
catch (Exception ex) when (IsServerUnavailable(ex))
{
return; // serveur injoignable : test ignoré
}
}
private static bool IsServerUnavailable(Exception ex)
{
var message = ex.Message;
return message.Contains("provider", StringComparison.OrdinalIgnoreCase)
|| message.Contains("serveur", StringComparison.OrdinalIgnoreCase)
|| message.Contains("server", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connexion", StringComparison.OrdinalIgnoreCase)
|| message.Contains("connection", StringComparison.OrdinalIgnoreCase)
|| message.Contains("E_FAIL", StringComparison.OrdinalIgnoreCase)
|| message.Contains("0x80004005", StringComparison.OrdinalIgnoreCase);
}
}

Some files were not shown because too many files have changed in this diff Show More