Premiere commit - Omika
This commit is contained in:
@@ -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 > B > 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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user