Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
using Omika.Tests.Observability.Forwarding;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de la validation du lot central (réutilisation de la whitelist Phase 4).</summary>
|
||||
public class CentralDiagnosticBatchValidatorTests
|
||||
{
|
||||
private static CentralDiagnosticOptions Limits() => new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidBatch_ReturnsValid()
|
||||
{
|
||||
var batch = new CentralDiagnosticBatchDto { Events = [ForwardingTestHarness.NewRecord()] };
|
||||
|
||||
CentralDiagnosticValidationResult result = CentralDiagnosticBatchValidator.Validate(batch, Limits());
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullBatch_Invalid() =>
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(null, Limits()).IsValid);
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyEvents_Invalid() =>
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(new CentralDiagnosticBatchDto(), Limits()).IsValid);
|
||||
|
||||
[Fact]
|
||||
public void Validate_TooManyEvents_Invalid()
|
||||
{
|
||||
var batch = new CentralDiagnosticBatchDto
|
||||
{
|
||||
Events = Enumerable.Range(0, 101).Select(_ => ForwardingTestHarness.NewRecord()).ToList()
|
||||
};
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(batch, Limits()).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullEvent_Invalid()
|
||||
{
|
||||
var batch = new CentralDiagnosticBatchDto { Events = [(RemoteDiagnosticRecord)null!] };
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(batch, Limits()).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidEventCode_Invalid()
|
||||
{
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord();
|
||||
var bad = new RemoteDiagnosticRecord
|
||||
{
|
||||
ReceivedAtUtc = record.ReceivedAtUtc,
|
||||
Event = new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = string.Empty }
|
||||
};
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(new CentralDiagnosticBatchDto { Events = [bad] }, Limits()).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ForbiddenMetadataKey_Invalid()
|
||||
{
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord();
|
||||
var bad = new RemoteDiagnosticRecord
|
||||
{
|
||||
ReceivedAtUtc = record.ReceivedAtUtc,
|
||||
Event = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["password"] = "x" }
|
||||
}
|
||||
};
|
||||
|
||||
Assert.False(CentralDiagnosticBatchValidator.Validate(new CentralDiagnosticBatchDto { Events = [bad] }, Limits()).IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.External;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
using Omika.Tests.Observability.Forwarding;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du spool central (ClientCode autoritaire écrasé, champs préservés, bornes).</summary>
|
||||
public class CentralDiagnosticIngestionStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = ForwardingTestHarness.CreateTempDirectory();
|
||||
|
||||
public void Dispose() => ForwardingTestHarness.DeleteDirectory(_dir);
|
||||
|
||||
private CentralDiagnosticIngestionStore NewStore() =>
|
||||
new(new CentralDiagnosticOptions { Directory = _dir });
|
||||
|
||||
[Fact]
|
||||
public async Task Store_WritesAuthoritativeClientCode_OverridingPayload()
|
||||
{
|
||||
var store = NewStore();
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord(clientCode: "AUTRECLIENT");
|
||||
|
||||
int accepted = await store.StoreAsync([record], "BATIK.01", "trace-id");
|
||||
|
||||
Assert.Equal(1, accepted);
|
||||
string line = Assert.Single(OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
Assert.Equal("BATIK.01", document.RootElement.GetProperty("clientCode").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_PreservesCanonicalFields()
|
||||
{
|
||||
var store = NewStore();
|
||||
RemoteDiagnosticRecord record = ForwardingTestHarness.NewRecord(
|
||||
clientCode: "BATIK.01", component: "telemetry-core", screenCode: "AGENDA", featureCode: "AGENDA_REFRESH");
|
||||
|
||||
await store.StoreAsync([record], "BATIK.01", "trace-id");
|
||||
|
||||
string line = Assert.Single(OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))));
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
JsonElement root = document.RootElement;
|
||||
|
||||
Assert.Equal(record.ActorKey, root.GetProperty("actorKey").GetString());
|
||||
Assert.Equal(record.DeviceKey, root.GetProperty("deviceKey").GetString());
|
||||
Assert.Equal(record.ServerInstance, root.GetProperty("serverInstance").GetString());
|
||||
Assert.Equal(record.Environment, root.GetProperty("environment").GetString());
|
||||
Assert.Equal(record.BatchId.ToString(), root.GetProperty("batchId").GetString());
|
||||
|
||||
JsonElement evt = root.GetProperty("event");
|
||||
Assert.Equal("telemetry-core", evt.GetProperty("component").GetString());
|
||||
Assert.Equal("AGENDA", evt.GetProperty("screenCode").GetString());
|
||||
Assert.Equal("AGENDA_REFRESH", evt.GetProperty("featureCode").GetString());
|
||||
// Identités brutes interdites : jamais dans le spool central.
|
||||
Assert.False(evt.TryGetProperty("deviceId", out _));
|
||||
Assert.False(evt.TryGetProperty("clientId", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_NoDirectory_Throws()
|
||||
{
|
||||
var store = new CentralDiagnosticIngestionStore(new CentralDiagnosticOptions { Directory = string.Empty });
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
store.StoreAsync([ForwardingTestHarness.NewRecord()], "BATIK.01", "trace-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_EmptyClientCode_Throws()
|
||||
{
|
||||
var store = NewStore();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
store.StoreAsync([ForwardingTestHarness.NewRecord()], " ", "trace-id"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du fan-out : l'échec d'un exporteur n'empêche jamais les autres (§16).</summary>
|
||||
public class CompositeTelemetryExporterTests
|
||||
{
|
||||
private sealed class RecordingExporter : ITelemetryExporter
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public bool Throw { get; set; }
|
||||
|
||||
public Task ExportAsync(IReadOnlyList<OmikaEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls++;
|
||||
if (Throw)
|
||||
{
|
||||
throw new InvalidOperationException("exporter down");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneExporterFails_OtherStillRuns()
|
||||
{
|
||||
var broken = new RecordingExporter { Throw = true };
|
||||
var healthy = new RecordingExporter();
|
||||
var composite = new CompositeTelemetryExporter([broken, healthy]);
|
||||
|
||||
await composite.ExportAsync([OutboxTestHarness.NewEvent()], CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, broken.Calls);
|
||||
Assert.Equal(1, healthy.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyExporterList_NoOp()
|
||||
{
|
||||
var composite = new CompositeTelemetryExporter([]);
|
||||
await composite.ExportAsync([OutboxTestHarness.NewEvent()], CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Reflection;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Garde-fous d'architecture (§53, §84) : isolation métier, absence d'infra externe.</summary>
|
||||
public class DiagnosticArchitectureTests
|
||||
{
|
||||
[Fact]
|
||||
public void DiagnosticOutbox_HasNoReferenceToBusinessOutboxAssembly()
|
||||
{
|
||||
// Le DiagnosticOutbox vit dans Omika.Core ; l'outbox métier vit dans
|
||||
// Omika.App.Local. Core ne référence pas App.Local (jamais l'inverse).
|
||||
Assembly core = typeof(DiagnosticOutbox).Assembly;
|
||||
Assert.DoesNotContain(core.GetReferencedAssemblies(), a => a.Name == "Omika.App.Local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticOutboxStoreInterface_IsProviderAgnostic()
|
||||
{
|
||||
// Les signatures du store ne font référence à aucun type de l'outbox métier
|
||||
// (IOutboxRepository / OutboxOperation) ni à SQLite.
|
||||
foreach (MethodInfo method in typeof(IDiagnosticOutboxStore).GetMethods())
|
||||
{
|
||||
Assert.DoesNotContain("Business", method.Name, StringComparison.Ordinal);
|
||||
foreach (Type type in method.ReturnType.GetGenericArguments()
|
||||
.Concat(method.GetParameters().SelectMany(p => ParameterTypes(p.ParameterType))))
|
||||
{
|
||||
Assert.DoesNotContain("OutboxRepository", type.FullName ?? "", StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Microsoft.Data.Sqlite", type.FullName ?? "", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Core_DoesNotReferenceExternalObservabilityInfrastructure()
|
||||
{
|
||||
Assembly core = typeof(OmikaEvent).Assembly;
|
||||
|
||||
string[] referenced = core.GetReferencedAssemblies().Select(a => a.Name!).ToArray();
|
||||
Assert.DoesNotContain(referenced, n => n.Contains("OpenTelemetry"));
|
||||
Assert.DoesNotContain(referenced, n => n.Contains("ClickHouse"));
|
||||
Assert.DoesNotContain(referenced, n => n.Contains("SigNoz"));
|
||||
|
||||
// Aucun type OTLP / OpenTelemetry dans l'assemblage Core.
|
||||
Assert.DoesNotContain(
|
||||
core.GetTypes(),
|
||||
t => (t.FullName ?? "").Contains("Otlp", StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.FullName ?? "").Contains("OpenTelemetry", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> ParameterTypes(Type type)
|
||||
{
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
foreach (Type argument in type.GetGenericArguments())
|
||||
{
|
||||
foreach (Type inner in ParameterTypes(argument))
|
||||
{
|
||||
yield return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du backoff borné avec jitter (§43).</summary>
|
||||
public class DiagnosticBackoffPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void NextDelay_IsBoundedByMax()
|
||||
{
|
||||
var options = new DiagnosticUploadOptions
|
||||
{
|
||||
BackoffInitial = TimeSpan.FromSeconds(15),
|
||||
BackoffMax = TimeSpan.FromHours(1)
|
||||
};
|
||||
var policy = new DiagnosticBackoffPolicy(options);
|
||||
|
||||
// Garantie absolue : 0 < délai <= MaxDelay, pour tout attempt, y compris les
|
||||
// valeurs extrêmes. Le jitter (aléatoire) ne peut JAMAIS faire dépasser MaxDelay
|
||||
// car la borne finale est appliquée après le jitter.
|
||||
int[] attempts = [0, 1, 5, 9, 20, 50, 100, int.MaxValue];
|
||||
foreach (int attempt in attempts)
|
||||
{
|
||||
TimeSpan delay = policy.NextDelay(attempt);
|
||||
Assert.True(delay > TimeSpan.Zero, $"attempt {attempt}: délai non strictement positif ({delay})");
|
||||
Assert.True(delay <= options.BackoffMax, $"attempt {attempt}: délai {delay} dépasse le maximum {options.BackoffMax}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextDelay_GrowsWithAttempts()
|
||||
{
|
||||
var policy = new DiagnosticBackoffPolicy(new DiagnosticUploadOptions
|
||||
{
|
||||
BackoffInitial = TimeSpan.FromSeconds(15),
|
||||
BackoffMax = TimeSpan.FromHours(1),
|
||||
BackoffJitter = 0.0
|
||||
});
|
||||
|
||||
TimeSpan first = policy.NextDelay(1);
|
||||
TimeSpan second = policy.NextDelay(2);
|
||||
TimeSpan third = policy.NextDelay(3);
|
||||
|
||||
Assert.True(second >= first);
|
||||
Assert.True(third >= second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextDelay_WithJitter_StaysWithinJitterBounds()
|
||||
{
|
||||
var options = new DiagnosticUploadOptions
|
||||
{
|
||||
BackoffInitial = TimeSpan.FromSeconds(10),
|
||||
BackoffMax = TimeSpan.FromMinutes(1),
|
||||
BackoffJitter = 0.2
|
||||
};
|
||||
var policy = new DiagnosticBackoffPolicy(options);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
TimeSpan delay = policy.NextDelay(1);
|
||||
Assert.InRange(delay.TotalMilliseconds, 8000, 12000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de la validation stricte serveur (§32, §28, §29, §60).</summary>
|
||||
public class DiagnosticBatchValidatorTests
|
||||
{
|
||||
private static readonly RemoteDiagnosticOptions Limits = new();
|
||||
|
||||
private static DiagnosticBatchDto NewBatch(int eventCount = 1) =>
|
||||
new()
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Events = Enumerable.Range(0, eventCount)
|
||||
.Select(i => new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P3"
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ValidBatch_Passes()
|
||||
{
|
||||
DiagnosticValidationResult result = DiagnosticBatchValidator.Validate(NewBatch(), Limits);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullBatch_Rejected()
|
||||
{
|
||||
Assert.False(DiagnosticBatchValidator.Validate(null, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongSchemaVersion_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch = new DiagnosticBatchDto { SchemaVersion = 999, Events = batch.Events };
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyBatch_Rejected()
|
||||
{
|
||||
Assert.False(DiagnosticBatchValidator.Validate(new DiagnosticBatchDto(), Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooManyEvents_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch(Limits.MaxEventsPerBatch + 1);
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyEventId_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto { EventCode = "OMK-MOB-APP-001" };
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenMetadataKey_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["password"] = "secret" }
|
||||
};
|
||||
|
||||
DiagnosticValidationResult result = DiagnosticBatchValidator.Validate(batch, Limits);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(DiagnosticValidationReason.ForbiddenMetadata, result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenMetadataValue_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["error"] = "ConnectionString=secret" }
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TooManyMetadata_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
var metadata = new Dictionary<string, object?>();
|
||||
for (int i = 0; i <= Limits.MaxMetadataCount; i++)
|
||||
{
|
||||
metadata[$"k{i}"] = i;
|
||||
}
|
||||
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlongString_Rejected()
|
||||
{
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Component = new string('x', Limits.MaxStringLength + 1)
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComplexMetadataValue_Rejected()
|
||||
{
|
||||
// Une valeur de métadonnée objet/tableau (non simple) est refusée.
|
||||
DiagnosticBatchDto batch = NewBatch();
|
||||
batch.Events[0] = new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Metadata = new Dictionary<string, object?> { ["nested"] = new { a = 1 } }
|
||||
};
|
||||
|
||||
Assert.False(DiagnosticBatchValidator.Validate(batch, Limits).IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du mapping événement → DTO transport (§27, §28, §29, §63).</summary>
|
||||
public class DiagnosticEventMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToDto_CopiesWhitelistedFields_AndPreservesEventId()
|
||||
{
|
||||
Guid eventId = Guid.NewGuid();
|
||||
Guid traceId = Guid.NewGuid();
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
Guid operationId = Guid.NewGuid();
|
||||
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Level = TelemetryLevel.Error,
|
||||
Priority = TelemetryPriority.P1,
|
||||
OperationId = operationId,
|
||||
Component = "SyncEngine",
|
||||
AppVersion = "1.2.3",
|
||||
DeviceId = "device-1"
|
||||
};
|
||||
e.TraceId = traceId;
|
||||
e.SessionId = sessionId;
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(eventId, dto!.EventId);
|
||||
Assert.Equal(traceId, dto.TraceId);
|
||||
Assert.Equal(sessionId, dto.SessionId);
|
||||
Assert.Equal(operationId, dto.OperationId);
|
||||
Assert.Equal("device-1", dto.DeviceId);
|
||||
Assert.Equal("1.2.3", dto.AppVersion);
|
||||
Assert.Equal("SyncEngine", dto.Component);
|
||||
Assert.Equal("P1", dto.Priority);
|
||||
Assert.Equal("Error", dto.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_CopiesDeviceDimensions_AppBuildAndScreen()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
AppBuild = "1410",
|
||||
ScreenWidth = 1080,
|
||||
ScreenHeight = 2400,
|
||||
LogicalWidth = 360,
|
||||
LogicalHeight = 800,
|
||||
DisplayScale = 3.0
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("1410", dto!.AppBuild);
|
||||
Assert.Equal(1080, dto.ScreenWidth);
|
||||
Assert.Equal(2400, dto.ScreenHeight);
|
||||
Assert.Equal(360, dto.LogicalWidth);
|
||||
Assert.Equal(800, dto.LogicalHeight);
|
||||
Assert.Equal(3.0, dto.DisplayScale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_CopiesScreenCodeAndFeatureCode()
|
||||
{
|
||||
// T18 : le mapping transport doit conserver ScreenCode/FeatureCode (dimensions
|
||||
// déclaratives d'usage) — c'est le contrat vérifié de bout en bout.
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileScreenView,
|
||||
ScreenCode = DiagnosticScreenCodes.SiteDetail,
|
||||
FeatureCode = DiagnosticFeatureCodes.ReportGenerate
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(DiagnosticScreenCodes.SiteDetail, dto!.ScreenCode);
|
||||
Assert.Equal(DiagnosticFeatureCodes.ReportGenerate, dto.FeatureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesScreenCodeAndFeatureCode()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileScreenView,
|
||||
ScreenCode = DiagnosticScreenCodes.RendezVousDetail,
|
||||
FeatureCode = DiagnosticFeatureCodes.InterventionSearchRun
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
string json = DiagnosticEventMapper.Serialize(dto!);
|
||||
DiagnosticEventDto? back = DiagnosticEventMapper.Deserialize(json);
|
||||
|
||||
Assert.Equal(DiagnosticScreenCodes.RendezVousDetail, back!.ScreenCode);
|
||||
Assert.Equal(DiagnosticFeatureCodes.InterventionSearchRun, back.FeatureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_NullScreenCodeAndFeatureCode_StayNull()
|
||||
{
|
||||
// Compatibilité : les événements historiques/background sans usage fonctionnel
|
||||
// restent ScreenCode=null / FeatureCode=null (aucune invention).
|
||||
var e = new TelemetryEvent { EventCode = TelemetryEventCodes.MobileAppStarted };
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Null(dto!.ScreenCode);
|
||||
Assert.Null(dto.FeatureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_InvalidEventCode_ReturnsNull()
|
||||
{
|
||||
TelemetryEvent e = OutboxTestHarness.NewEvent(code: EventCode.Invalid);
|
||||
|
||||
Assert.Null(DiagnosticEventMapper.ToDto(e, 256));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_DropsForbiddenMetadataKeys()
|
||||
{
|
||||
TelemetryEvent e = OutboxTestHarness.NewEvent(metadata: TelemetryMetadata.Create(
|
||||
("password", "secret"),
|
||||
("method", "POST"),
|
||||
("token", "abc")));
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.NotNull(dto!.Metadata);
|
||||
Assert.True(dto.Metadata!.ContainsKey("method"));
|
||||
Assert.False(dto.Metadata.ContainsKey("password"));
|
||||
Assert.False(dto.Metadata.ContainsKey("token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_DropsForbiddenMetadataValues()
|
||||
{
|
||||
TelemetryEvent e = OutboxTestHarness.NewEvent(metadata: TelemetryMetadata.Create(
|
||||
("error", "ConnectionString=Server=prod;Password=hidden"),
|
||||
("ok", "échec")));
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.NotNull(dto!.Metadata);
|
||||
Assert.False(dto.Metadata.ContainsKey("error"));
|
||||
Assert.True(dto.Metadata.ContainsKey("ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_TruncatesLongStrings()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Component = new string('x', 1000)
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(256, dto!.Component!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesUtf8Accents()
|
||||
{
|
||||
var e = new TelemetryEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileAppStarted,
|
||||
Component = "télémétrie",
|
||||
EntityType = "Créé"
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
string json = DiagnosticEventMapper.Serialize(dto!);
|
||||
DiagnosticEventDto? back = DiagnosticEventMapper.Deserialize(json);
|
||||
|
||||
Assert.Equal("télémétrie", back!.Component);
|
||||
Assert.Equal("Créé", back.EntityType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_UnknownFields_AreDropped()
|
||||
{
|
||||
// Un champ "password" non prévu dans le DTO est ignoré par la désérialisation
|
||||
// typée (aucun JsonExtensionData) : il ne sera jamais persisté. (§60)
|
||||
string json = """
|
||||
{ "eventId": "00000000-0000-0000-0000-000000000001", "eventCode": "OMK-MOB-APP-001",
|
||||
"password": "secret", "component": "test" }
|
||||
""";
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.Deserialize(json);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("test", dto!.Component);
|
||||
Assert.Null(dto.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_CopiesExceptionDetail()
|
||||
{
|
||||
// Phase 6 : message/type/stack trace d'exception sont des champs first-class
|
||||
// du transport (jamais des métadonnées libres), préservés de bout en bout.
|
||||
var e = new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileHandledException,
|
||||
Message = "Échec d'écriture",
|
||||
ExceptionType = "System.IO.IOException",
|
||||
StackTrace = "at Omika.App.Services.SyncEngine.Run()"
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("Échec d'écriture", dto!.Message);
|
||||
Assert.Equal("System.IO.IOException", dto.ExceptionType);
|
||||
Assert.Equal("at Omika.App.Services.SyncEngine.Run()", dto.StackTrace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_TruncatesExceptionDetail()
|
||||
{
|
||||
var e = new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileUnhandledException,
|
||||
Message = new string('m', 500),
|
||||
ExceptionType = new string('t', 500),
|
||||
StackTrace = new string('s', 3000)
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal(256, dto!.Message!.Length);
|
||||
Assert.Equal(256, dto.ExceptionType!.Length);
|
||||
Assert.Equal(DiagnosticEventMapper.MaxStackTraceLength, dto.StackTrace!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_SignaleLaTroncatureParUnMarqueur()
|
||||
{
|
||||
// Une coupe SILENCIEUSE se lit comme une valeur complète : sur un vrai ticket,
|
||||
// une pile coupée en plein identifiant laissait croire qu'elle s'arrêtait là.
|
||||
var e = new DiagnosticEvent
|
||||
{
|
||||
EventCode = TelemetryEventCodes.MobileUnhandledException,
|
||||
Message = new string('m', 500),
|
||||
StackTrace = new string('s', 3000)
|
||||
};
|
||||
|
||||
DiagnosticEventDto? dto = DiagnosticEventMapper.ToDto(e, 256);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.EndsWith("…[tronqué]", dto!.Message, StringComparison.Ordinal);
|
||||
Assert.EndsWith("…[tronqué]", dto.StackTrace, StringComparison.Ordinal);
|
||||
// Le marqueur tient DANS la limite : la valeur transmise reste bornée.
|
||||
Assert.Equal(256, dto.Message!.Length);
|
||||
Assert.Equal(DiagnosticEventMapper.MaxStackTraceLength, dto.StackTrace!.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de la liste noire inverse du transport diagnostic (§29).</summary>
|
||||
public class DiagnosticForbiddenDataTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("password")]
|
||||
[InlineData("Password")]
|
||||
[InlineData("PASSWORD")]
|
||||
[InlineData("connectionString")]
|
||||
[InlineData("requestBody")]
|
||||
[InlineData("smtpPassword")]
|
||||
[InlineData("advertisingId")]
|
||||
[InlineData("authorization")]
|
||||
public void IsForbiddenKey_DetectsForbiddenTokensCaseInsensitive(string key)
|
||||
{
|
||||
Assert.True(DiagnosticForbiddenData.IsForbiddenKey(key));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("method")]
|
||||
[InlineData("routeTemplate")]
|
||||
[InlineData("statusCode")]
|
||||
[InlineData("entityType")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void IsForbiddenKey_AllowsSafeKeys(string? key)
|
||||
{
|
||||
Assert.False(DiagnosticForbiddenData.IsForbiddenKey(key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsForbiddenValue_DetectsSecretsInFreeText()
|
||||
{
|
||||
Assert.True(DiagnosticForbiddenData.ContainsForbiddenValue("Authorization=Bearer abc"));
|
||||
Assert.True(DiagnosticForbiddenData.ContainsForbiddenValue("server=prod;password=hidden"));
|
||||
Assert.False(DiagnosticForbiddenData.ContainsForbiddenValue("échec de connexion"));
|
||||
Assert.False(DiagnosticForbiddenData.ContainsForbiddenValue(""));
|
||||
Assert.False(DiagnosticForbiddenData.ContainsForbiddenValue(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
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.Contracts.Mobile.Bootstrap;
|
||||
using Omika.Core.Contracts.Mobile.License;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Observability.Diagnostic;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
using Omika.Core.Runtime;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.Runtime;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>
|
||||
/// Tests d'intégration mobile → API (Phase 4) : authentification, validation stricte,
|
||||
/// persistance durable avant ACK, données interdites, anti-boucle, panne stockage.
|
||||
/// </summary>
|
||||
[Collection("ApplicationRuntimeContext")]
|
||||
public class DiagnosticIngestionIntegrationTests : IDisposable
|
||||
{
|
||||
private const string SigningKey = "test-signing-key-for-integration-tests-only";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly string _localDir = CreateTempDirectory();
|
||||
private readonly string _remoteDir = CreateTempDirectory();
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public DiagnosticIngestionIntegrationTests()
|
||||
{
|
||||
ApplicationRuntimeContext.Reset();
|
||||
_factory = CreateFactory(_localDir, _remoteDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
ApplicationRuntimeContext.Reset();
|
||||
DeleteDirectory(_localDir);
|
||||
DeleteDirectory(_remoteDir);
|
||||
}
|
||||
|
||||
private string NewToken()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ITokenService tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
|
||||
return tokenService.GenerateToken(new MobileTokenIdentity
|
||||
{
|
||||
CodeClient = "BATIK.01",
|
||||
DeviceId = "device-1",
|
||||
UserId = "user-1",
|
||||
CollaborateurId = Guid.NewGuid().ToString(),
|
||||
NumeroLicence = "lic-1"
|
||||
});
|
||||
}
|
||||
|
||||
private static DiagnosticBatchDto ValidBatch(string component = "test") =>
|
||||
new()
|
||||
{
|
||||
BatchId = Guid.NewGuid(),
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P3",
|
||||
TraceId = Guid.NewGuid(),
|
||||
SessionId = Guid.NewGuid(),
|
||||
Component = component
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutToken_ReturnsUnauthorized()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidBatch_IsPersisted_AndAcknowledged()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
DiagnosticBatchDto batch = ValidBatch("télémétrie");
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", batch);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
DiagnosticBatchResponseDto? body = await response.Content.ReadFromJsonAsync<DiagnosticBatchResponseDto>();
|
||||
Assert.Equal(batch.BatchId, body!.BatchId);
|
||||
Assert.Equal(1, body.AcceptedCount);
|
||||
|
||||
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
||||
.Any(f => ReadAllTextShared(f).Contains("\"eventId\"", StringComparison.Ordinal)));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(ReadFirstLine(_remoteDir));
|
||||
JsonElement evt = document.RootElement.GetProperty("event");
|
||||
Assert.Equal(batch.Events[0].EventId.ToString(), evt.GetProperty("eventId").GetString());
|
||||
Assert.Equal(batch.Events[0].TraceId!.Value.ToString(), evt.GetProperty("traceId").GetString());
|
||||
Assert.Equal(batch.Events[0].SessionId!.Value.ToString(), evt.GetProperty("sessionId").GetString());
|
||||
Assert.Equal("télémétrie", evt.GetProperty("component").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForbiddenMetadata_ReturnsBadRequest_NothingPersisted()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
string json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"batchId": "00000000-0000-0000-0000-000000000001",
|
||||
"events": [{
|
||||
"eventId": "00000000-0000-0000-0000-000000000002",
|
||||
"eventCode": "OMK-MOB-APP-001",
|
||||
"level": "Info",
|
||||
"priority": "P3",
|
||||
"metadata": { "password": "secret" }
|
||||
}]
|
||||
}
|
||||
""";
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
"/api/diagnostics",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(Directory.EnumerateFiles(_remoteDir, "*.jsonl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownField_IsNotPersisted()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
// Le champ "password" n'est pas dans le DTO : il est ignoré par la
|
||||
// désérialisation typée et ne doit jamais apparaître dans le fichier serveur.
|
||||
string json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"batchId": "00000000-0000-0000-0000-000000000001",
|
||||
"password": "secret-password-value",
|
||||
"events": [{
|
||||
"eventId": "00000000-0000-0000-0000-000000000002",
|
||||
"eventCode": "OMK-MOB-APP-001",
|
||||
"level": "Info",
|
||||
"priority": "P3",
|
||||
"component": "ok"
|
||||
}]
|
||||
}
|
||||
""";
|
||||
HttpResponseMessage response = await client.PostAsync(
|
||||
"/api/diagnostics",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
||||
.Any(f => ReadAllTextShared(f).Contains("\"component\"", StringComparison.Ordinal)));
|
||||
|
||||
string content = string.Join("\n", Directory.EnumerateFiles(_remoteDir, "*.jsonl").Select(ReadAllTextShared));
|
||||
Assert.DoesNotContain("secret-password-value", content, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageFailure_Returns503_DoesNotCrash()
|
||||
{
|
||||
string blocker = Path.Combine(_localDir, "blocker");
|
||||
File.WriteAllText(blocker, "x");
|
||||
string inaccessible = Path.Combine(blocker, "sub");
|
||||
|
||||
using var factory = CreateFactory(_localDir, inaccessible);
|
||||
using HttpClient client = factory.CreateClient();
|
||||
|
||||
string token;
|
||||
using (IServiceScope scope = factory.Services.CreateScope())
|
||||
{
|
||||
token = scope.ServiceProvider.GetRequiredService<ITokenService>()
|
||||
.GenerateToken(new MobileTokenIdentity { CodeClient = "BATIK.01", DeviceId = "d", UserId = "u" });
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagnosticsEndpoint_DoesNotEmitHttpEvents_OrLogBody()
|
||||
{
|
||||
using HttpClient client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
||||
|
||||
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch("MARKER-BODY-123"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Le fichier d'ingestion serveur contient la donnée (légitime).
|
||||
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
||||
.Any(f => ReadAllTextShared(f).Contains("MARKER-BODY-123", StringComparison.Ordinal)));
|
||||
|
||||
// Le journal local de l'API ne doit pas contenir le corps ni un événement
|
||||
// HTTP_REQUEST_* pour /api/diagnostics (anti-boucle, sans body).
|
||||
string local = string.Join("\n", Directory.EnumerateFiles(_localDir, "*.jsonl").Select(ReadAllTextShared));
|
||||
Assert.DoesNotContain("MARKER-BODY-123", local, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("/api/diagnostics", local, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory(
|
||||
string localDir,
|
||||
string remoteDir,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
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
|
||||
})
|
||||
.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>(_ => new FakeLicenseService());
|
||||
|
||||
services.RemoveAll<IMobileBootstrapService>();
|
||||
services.AddScoped<IMobileBootstrapService>(_ => new FakeBootstrapService());
|
||||
|
||||
services.Configure<LocalDiagnosticOptions>(options => options.Directory = localDir);
|
||||
services.Configure<RemoteDiagnosticOptions>(options => options.Directory = remoteDir);
|
||||
|
||||
configureServices?.Invoke(services);
|
||||
});
|
||||
});
|
||||
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static string ReadFirstLine(string dir)
|
||||
{
|
||||
string file = Assert.Single(Directory.EnumerateFiles(dir, "*.jsonl"));
|
||||
string content = ReadAllTextShared(file);
|
||||
int newline = content.IndexOf('\n');
|
||||
return (newline >= 0 ? content[..newline] : content).TrimEnd('\r');
|
||||
}
|
||||
|
||||
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 string CreateTempDirectory()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-diag-ingestion-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.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 3000)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition() && DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
Assert.True(condition(), "La condition n'a pas été atteinte dans le délai imparti.");
|
||||
}
|
||||
|
||||
// ===== Fakes =====
|
||||
|
||||
private sealed class FakeLicenseService : ILicenseService
|
||||
{
|
||||
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<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 Task<MobileBootstrapResponse> GetBootstrapAsync(MobileTokenIdentity identity, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new MobileBootstrapResponse());
|
||||
}
|
||||
|
||||
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,33 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests de l'exporteur outbox : never-throw, délégation, priorité.</summary>
|
||||
public class DiagnosticOutboxExporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAsync_DelegatesToOutbox()
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore();
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
var exporter = new DiagnosticOutboxExporter(outbox);
|
||||
|
||||
await exporter.ExportAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P1)], CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, counters.OutboxStored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ThrowingOutbox_NeverThrows()
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore { ThrowOnStore = true };
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
var exporter = new DiagnosticOutboxExporter(outbox);
|
||||
|
||||
// Ne doit pas lever : l'exporteur avale l'erreur de l'outbox (fail-open).
|
||||
await exporter.ExportAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)], CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Omika.App.Local.Diagnostics;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du store SQLite séparé (omika-diagnostics.db) : persistance, rétention, éviction, redémarrage.</summary>
|
||||
public class DiagnosticOutboxSqliteStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-diag-sqlite-tests", Guid.NewGuid().ToString("N"));
|
||||
private string DbPath => Path.Combine(_dir, "omika-diagnostics.db");
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dir))
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort.
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static DiagnosticOutboxRow NewRow(TelemetryPriority priority = TelemetryPriority.P3, string payload = "{}") =>
|
||||
new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Priority = priority,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(7),
|
||||
PayloadVersion = 1,
|
||||
PayloadJson = payload,
|
||||
AttemptCount = 0,
|
||||
NextAttemptUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAndRetrieve_RoundTrip()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow row = NewRow(payload: """{"eventId":"00000000-0000-0000-0000-000000000001"}""");
|
||||
|
||||
await store.StoreAsync([row]);
|
||||
IReadOnlyList<DiagnosticOutboxRow> batch = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow);
|
||||
|
||||
DiagnosticOutboxRow retrieved = Assert.Single(batch);
|
||||
Assert.Equal(row.EventId, retrieved.EventId);
|
||||
Assert.Equal(row.PayloadJson, retrieved.PayloadJson);
|
||||
Assert.Equal(row.Priority, retrieved.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNextBatch_RespectsNextAttemptUtc()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow future = NewRow();
|
||||
future = new DiagnosticOutboxRow
|
||||
{
|
||||
EventId = future.EventId,
|
||||
Priority = future.Priority,
|
||||
CreatedAtUtc = future.CreatedAtUtc,
|
||||
ExpiresAtUtc = future.ExpiresAtUtc,
|
||||
PayloadVersion = future.PayloadVersion,
|
||||
PayloadJson = future.PayloadJson,
|
||||
NextAttemptUtc = DateTimeOffset.UtcNow.AddMinutes(5)
|
||||
};
|
||||
await store.StoreAsync([future]);
|
||||
|
||||
IReadOnlyList<DiagnosticOutboxRow> batch = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow);
|
||||
Assert.Empty(batch);
|
||||
|
||||
IReadOnlyList<DiagnosticOutboxRow> later = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow.AddMinutes(10));
|
||||
Assert.Single(later);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteExpired_RemovesOnlyExpired()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow expired = NewRow();
|
||||
expired = new DiagnosticOutboxRow
|
||||
{
|
||||
EventId = expired.EventId,
|
||||
Priority = expired.Priority,
|
||||
CreatedAtUtc = expired.CreatedAtUtc,
|
||||
ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
PayloadVersion = expired.PayloadVersion,
|
||||
PayloadJson = expired.PayloadJson
|
||||
};
|
||||
await store.StoreAsync([expired, NewRow()]);
|
||||
|
||||
int removed = await store.DeleteExpiredAsync(DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOldest_RemovesOldestOfPriority()
|
||||
{
|
||||
await using var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath });
|
||||
DiagnosticOutboxRow old = NewRow(TelemetryPriority.P3);
|
||||
await store.StoreAsync([old]);
|
||||
await Task.Delay(10);
|
||||
DiagnosticOutboxRow newer = NewRow(TelemetryPriority.P3);
|
||||
await store.StoreAsync([newer]);
|
||||
|
||||
int removed = await store.DeleteOldestAsync(TelemetryPriority.P3, 1);
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.False((await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow)).Any(r => r.EventId == old.EventId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restart_PersistsRowsAcrossSessions()
|
||||
{
|
||||
DiagnosticOutboxRow row = NewRow(payload: """{"eventId":"00000000-0000-0000-0000-00000000000A"}""");
|
||||
await using (var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath }))
|
||||
{
|
||||
await store.StoreAsync([row]);
|
||||
}
|
||||
|
||||
// « Redémarrage » : nouvelle connexion sur le même fichier.
|
||||
await using (var store = new DiagnosticOutboxSqliteStore(new DiagnosticOutboxOptions { DatabasePath = DbPath }))
|
||||
{
|
||||
IReadOnlyList<DiagnosticOutboxRow> batch = await store.GetNextBatchAsync(10, 1024 * 1024, DateTimeOffset.UtcNow);
|
||||
Assert.Equal(row.EventId, Assert.Single(batch).EventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du DiagnosticOutbox : filtrage P0..P3, saturation, éviction, rétention, fail-open.</summary>
|
||||
public class DiagnosticOutboxTests
|
||||
{
|
||||
private static (DiagnosticOutbox Outbox, InMemoryDiagnosticOutboxStore Store, DiagnosticCounters Counters) Create(
|
||||
DiagnosticOutboxOptions? options = null)
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore();
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, options ?? new DiagnosticOutboxOptions(), counters);
|
||||
return (outbox, store, counters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_StoresP0ThroughP3_ByDefault()
|
||||
{
|
||||
(DiagnosticOutbox outbox, _, DiagnosticCounters counters) = Create();
|
||||
|
||||
int stored = await outbox.StoreAsync(
|
||||
[
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P0),
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P1),
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P2),
|
||||
OutboxTestHarness.NewEvent(TelemetryPriority.P3)
|
||||
]);
|
||||
|
||||
Assert.Equal(4, stored);
|
||||
Assert.Equal(4, counters.OutboxStored);
|
||||
Assert.Equal(0, counters.OutboxDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_DropsP4_ByDefault()
|
||||
{
|
||||
(DiagnosticOutbox outbox, _, DiagnosticCounters counters) = Create();
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P4)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(1, counters.OutboxDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_IncludeP4Option_StoresP4()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { IncludeP4 = true };
|
||||
(DiagnosticOutbox outbox, _, DiagnosticCounters counters) = Create(options);
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P4)]);
|
||||
|
||||
Assert.Equal(1, stored);
|
||||
Assert.Equal(1, counters.OutboxStored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_EvictsP3_BeforeP0_WhenSaturated()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { MaxRows = 10 };
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters) = Create(options);
|
||||
|
||||
// Remplit l'outbox avec 10 P3.
|
||||
List<OmikaEvent> p3s = Enumerable.Range(0, 10)
|
||||
.Select(_ => (OmikaEvent)OutboxTestHarness.NewEvent(TelemetryPriority.P3))
|
||||
.ToList();
|
||||
await outbox.StoreAsync(p3s);
|
||||
Assert.Equal(10, await store.CountAsync());
|
||||
|
||||
// Un nouveau P0 doit évincer un P3 (pas un P0).
|
||||
await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(10, await store.CountAsync());
|
||||
Assert.Contains(store.Snapshot(), r => r.Priority == TelemetryPriority.P0);
|
||||
Assert.Equal(1, counters.OutboxEvictedP3);
|
||||
Assert.Equal(0, counters.OutboxEvictedP0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_FullOfP0_DropsNewP0_WithCounter()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { MaxRows = 5 };
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters) = Create(options);
|
||||
|
||||
await outbox.StoreAsync(Enumerable.Range(0, 5)
|
||||
.Select(_ => OutboxTestHarness.NewEvent(TelemetryPriority.P0))
|
||||
.ToList());
|
||||
Assert.Equal(5, await store.CountAsync());
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(5, await store.CountAsync());
|
||||
Assert.Equal(1, counters.OutboxDroppedFull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_Disabled_StoresNothing()
|
||||
{
|
||||
var options = new DiagnosticOutboxOptions { Enabled = false };
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, _) = Create(options);
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupExpired_RemovesExpiredOnly()
|
||||
{
|
||||
(DiagnosticOutbox outbox, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters) = Create();
|
||||
|
||||
// Utilise une rétention courte pour forcer l'expiration.
|
||||
var options = new DiagnosticOutboxOptions { Retention = TimeSpan.FromMilliseconds(1) };
|
||||
(outbox, store, counters) = Create(options);
|
||||
await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P3)]);
|
||||
|
||||
await Task.Delay(20);
|
||||
int removed = await outbox.CleanupExpiredAsync();
|
||||
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Equal(1, counters.OutboxExpired);
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_ThrowingStore_FailOpen_DegradesAndCounts()
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore { ThrowOnStore = true };
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
|
||||
int stored = await outbox.StoreAsync([OutboxTestHarness.NewEvent(TelemetryPriority.P0)]);
|
||||
|
||||
Assert.Equal(0, stored);
|
||||
Assert.Equal(1, counters.OutboxDegraded);
|
||||
Assert.True(outbox.IsDegraded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du contrat de transport versionné (§26, §27, §10).</summary>
|
||||
public class DiagnosticTransportTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void SchemaVersion_IsOne()
|
||||
{
|
||||
Assert.Equal(1, DiagnosticTransportSchema.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_SerializesWithSchemaVersionAndBatchId()
|
||||
{
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
BatchId = Guid.Parse("00000000-0000-0000-0000-0000000000AA"),
|
||||
SentAtUtc = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = new DateTimeOffset(2024, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
Level = "Info",
|
||||
Priority = "P3"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(batch, JsonOptions);
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
|
||||
Assert.Equal(1, document.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal(
|
||||
"00000000-0000-0000-0000-0000000000aa",
|
||||
document.RootElement.GetProperty("batchId").GetString());
|
||||
Assert.Equal(1, document.RootElement.GetProperty("events").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_RoundTrips_BatchIdAndEventIdStable()
|
||||
{
|
||||
Guid batchId = Guid.NewGuid();
|
||||
Guid eventId = Guid.NewGuid();
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
BatchId = batchId,
|
||||
Events = [new DiagnosticEventDto { EventId = eventId, EventCode = "OMK-MOB-APP-001" }]
|
||||
};
|
||||
|
||||
string json = JsonSerializer.Serialize(batch, JsonOptions);
|
||||
DiagnosticBatchDto? back = JsonSerializer.Deserialize<DiagnosticBatchDto>(json, JsonOptions);
|
||||
|
||||
Assert.Equal(batchId, back!.BatchId);
|
||||
Assert.Equal(eventId, back.Events[0].EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_RoundTrips()
|
||||
{
|
||||
var response = new DiagnosticBatchResponseDto { BatchId = Guid.NewGuid(), AcceptedCount = 3 };
|
||||
string json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
DiagnosticBatchResponseDto? back = JsonSerializer.Deserialize<DiagnosticBatchResponseDto>(json, JsonOptions);
|
||||
|
||||
Assert.Equal(response.BatchId, back!.BatchId);
|
||||
Assert.Equal(3, back.AcceptedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du client HTTP d'upload : classification des statuts, timeout, anti-boucle.</summary>
|
||||
public class DiagnosticUploadClientTests
|
||||
{
|
||||
private static DiagnosticBatchDto NewBatch() =>
|
||||
new()
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }
|
||||
]
|
||||
};
|
||||
|
||||
private static DiagnosticUploadClient CreateClient(
|
||||
StubHandler handler,
|
||||
DiagnosticUploadOptions? options = null,
|
||||
string? token = "token-1") =>
|
||||
new(
|
||||
new HttpClient(handler),
|
||||
"http://localhost:5000",
|
||||
() => token,
|
||||
options ?? new DiagnosticUploadOptions { Timeout = TimeSpan.FromSeconds(5) });
|
||||
|
||||
[Fact]
|
||||
public async Task NoToken_ReturnsUnauthorized_WithoutHttp()
|
||||
{
|
||||
var handler = new StubHandler { StatusCode = HttpStatusCode.OK };
|
||||
DiagnosticUploadClient client = CreateClient(handler, token: null);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Unauthorized, result.Outcome);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Success_MapsToSuccess_WithAcceptedCount()
|
||||
{
|
||||
var handler = new StubHandler
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
ResponseBody = """{"batchId":"00000000-0000-0000-0000-000000000001","acceptedCount":3}"""
|
||||
};
|
||||
DiagnosticUploadClient client = CreateClient(handler);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Success, result.Outcome);
|
||||
Assert.Equal(3, result.AcceptedCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized, DiagnosticUploadOutcome.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden, DiagnosticUploadOutcome.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.BadRequest, DiagnosticUploadOutcome.Rejected)]
|
||||
[InlineData(HttpStatusCode.RequestEntityTooLarge, DiagnosticUploadOutcome.TooLarge)]
|
||||
[InlineData(HttpStatusCode.InternalServerError, DiagnosticUploadOutcome.Transient)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable, DiagnosticUploadOutcome.Transient)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout, DiagnosticUploadOutcome.Transient)]
|
||||
public async Task StatusCodes_MapToExpectedOutcome(HttpStatusCode status, DiagnosticUploadOutcome expected)
|
||||
{
|
||||
var handler = new StubHandler { StatusCode = status };
|
||||
DiagnosticUploadClient client = CreateClient(handler);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(expected, result.Outcome);
|
||||
Assert.Equal((int)status, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RateLimited_HonorsRetryAfter()
|
||||
{
|
||||
var handler = new StubHandler
|
||||
{
|
||||
StatusCode = HttpStatusCode.TooManyRequests,
|
||||
RetryAfterSeconds = 42
|
||||
};
|
||||
DiagnosticUploadClient client = CreateClient(handler);
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.RateLimited, result.Outcome);
|
||||
Assert.Equal(TimeSpan.FromSeconds(42), result.RetryAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_MapsToTransient()
|
||||
{
|
||||
var handler = new StubHandler { DelayMs = 2000, StatusCode = HttpStatusCode.OK };
|
||||
DiagnosticUploadClient client = CreateClient(handler, new DiagnosticUploadOptions { Timeout = TimeSpan.FromMilliseconds(10) });
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Transient, result.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_SendsBearerToken()
|
||||
{
|
||||
var handler = new StubHandler { StatusCode = HttpStatusCode.OK };
|
||||
DiagnosticUploadClient client = CreateClient(handler, token: "secret-token");
|
||||
|
||||
await client.UploadAsync(NewBatch());
|
||||
|
||||
Assert.Equal("Bearer secret-token", handler.LastAuthorization);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
|
||||
public int DelayMs { get; set; }
|
||||
|
||||
public string ResponseBody { get; set; } = "{}";
|
||||
|
||||
public int? RetryAfterSeconds { get; set; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public string? LastAuthorization { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
LastAuthorization = request.Headers.Authorization?.ToString();
|
||||
if (DelayMs > 0)
|
||||
{
|
||||
await Task.Delay(DelayMs, cancellationToken);
|
||||
}
|
||||
|
||||
var response = new HttpResponseMessage(StatusCode);
|
||||
if (RetryAfterSeconds is { } seconds)
|
||||
{
|
||||
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(seconds));
|
||||
}
|
||||
|
||||
response.Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>
|
||||
/// Tests du DiagnosticUploadClient par fournisseur d'URL : l'URL est résolue à
|
||||
/// chaque envoi depuis la même source de vérité que le client métier.
|
||||
/// </summary>
|
||||
public class DiagnosticUploadProviderTests
|
||||
{
|
||||
private sealed class UriCapturingHandler : HttpMessageHandler
|
||||
{
|
||||
public string? LastUri { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastUri = request.RequestUri?.ToString();
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"acceptedCount":1}""", Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_UtiliseUrlDuFournisseur()
|
||||
{
|
||||
var handler = new UriCapturingHandler();
|
||||
string? currentBaseUrl = "https://api.example.com";
|
||||
|
||||
var client = new DiagnosticUploadClient(
|
||||
new System.Net.Http.HttpClient(handler),
|
||||
() => currentBaseUrl,
|
||||
() => "token-1",
|
||||
new DiagnosticUploadOptions());
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(new DiagnosticBatchDto
|
||||
{
|
||||
Events = [new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }]
|
||||
});
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Success, result.Outcome);
|
||||
Assert.Equal("https://api.example.com/api/diagnostics", handler.LastUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_SansUrl_AucunAppelHttp()
|
||||
{
|
||||
var handler = new UriCapturingHandler();
|
||||
|
||||
var client = new DiagnosticUploadClient(
|
||||
new System.Net.Http.HttpClient(handler),
|
||||
() => null,
|
||||
() => "token-1",
|
||||
new DiagnosticUploadOptions());
|
||||
|
||||
DiagnosticUploadResult result = await client.UploadAsync(new DiagnosticBatchDto
|
||||
{
|
||||
Events = [new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }]
|
||||
});
|
||||
|
||||
Assert.Equal(DiagnosticUploadOutcome.Failure, result.Outcome);
|
||||
Assert.Null(handler.LastUri); // aucun fallback localhost, aucun envoi.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du worker d'upload : succès/suppression, retry, auth, poison, split, anti-loop.</summary>
|
||||
public class DiagnosticUploadWorkerTests
|
||||
{
|
||||
private static (DiagnosticUploadWorker Worker, InMemoryDiagnosticOutboxStore Store, DiagnosticCounters Counters, FakeUploadClient Client) Create(
|
||||
DiagnosticUploadOptions? uploadOptions = null,
|
||||
string? token = "token-1",
|
||||
Func<DiagnosticBatchDto, DiagnosticUploadResult>? handler = null)
|
||||
{
|
||||
var store = new InMemoryDiagnosticOutboxStore();
|
||||
var counters = new DiagnosticCounters();
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), counters);
|
||||
var client = new FakeUploadClient { Handler = handler ?? (_ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Success)) };
|
||||
var worker = new DiagnosticUploadWorker(
|
||||
outbox,
|
||||
client,
|
||||
uploadOptions ?? new DiagnosticUploadOptions(),
|
||||
counters,
|
||||
() => token);
|
||||
return (worker, store, counters, client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Success_DeletesRows_AndCounts()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) = Create();
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
bool attempted = await worker.FlushAsync();
|
||||
|
||||
Assert.True(attempted);
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadSuccess);
|
||||
Assert.Equal(1, counters.UploadEventsSent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransientFailure_ConservesRows_IncrementsAttempt()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: _ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Transient));
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadFailure);
|
||||
DiagnosticOutboxRow row = store.Snapshot().Single();
|
||||
Assert.Equal(1, row.AttemptCount);
|
||||
Assert.True(row.NextAttemptUtc > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unauthorized_ConservesRows_NoDelete()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: _ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Unauthorized));
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadUnauthorized);
|
||||
Assert.Equal(0, counters.UploadSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedSingleton_IsDroppedAsPoison()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: _ => new DiagnosticUploadResult(DiagnosticUploadOutcome.Rejected));
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
Assert.Equal(1, counters.UploadPoisonDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedLargeBatch_Splits_UntilSingletonsSucceed()
|
||||
{
|
||||
// Le serveur rejette tout lot de plus d'un événement (400) : le worker divise
|
||||
// jusqu'aux singletons, qui sont acceptés. Aucun poison, tout est envoyé.
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, DiagnosticCounters counters, _) =
|
||||
Create(handler: batch => batch.Events.Count > 1
|
||||
? new DiagnosticUploadResult(DiagnosticUploadOutcome.Rejected)
|
||||
: new DiagnosticUploadResult(DiagnosticUploadOutcome.Success));
|
||||
await SeedAsync(store, 4);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(0, await store.CountAsync());
|
||||
Assert.Equal(4, counters.UploadEventsSent);
|
||||
Assert.Equal(0, counters.UploadPoisonDropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoToken_DoesNotUpload_ConservesRows()
|
||||
{
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, _, FakeUploadClient client) = Create(token: null);
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
await worker.FlushAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(0, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_LoopDoesNotUpload()
|
||||
{
|
||||
var options = new DiagnosticUploadOptions { Enabled = false, Interval = TimeSpan.FromMilliseconds(10) };
|
||||
(DiagnosticUploadWorker worker, InMemoryDiagnosticOutboxStore store, _, FakeUploadClient client) = Create(uploadOptions: options);
|
||||
await SeedAsync(store, 1);
|
||||
|
||||
worker.Start();
|
||||
await Task.Delay(50);
|
||||
await worker.StopAsync();
|
||||
|
||||
Assert.Equal(1, await store.CountAsync());
|
||||
Assert.Equal(0, client.Calls);
|
||||
}
|
||||
|
||||
private static async Task SeedAsync(InMemoryDiagnosticOutboxStore store, int count)
|
||||
{
|
||||
// Insère des événements mappés dans le store pour isoler le comportement d'upload.
|
||||
var outbox = new DiagnosticOutbox(store, new DiagnosticOutboxOptions(), new DiagnosticCounters());
|
||||
await outbox.StoreAsync(Enumerable.Range(0, count)
|
||||
.Select(_ => OutboxTestHarness.NewEvent(TelemetryPriority.P3))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private sealed class FakeUploadClient : IDiagnosticUploadClient
|
||||
{
|
||||
public Func<DiagnosticBatchDto, DiagnosticUploadResult>? Handler { get; set; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<DiagnosticUploadResult> UploadAsync(DiagnosticBatchDto batch, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(Handler!(batch));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Text;
|
||||
using Omika.Core.Observability;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Fabrique de test partagée pour le diagnostic distant (Phase 4).</summary>
|
||||
internal static class OutboxTestHarness
|
||||
{
|
||||
public static TelemetryEvent NewEvent(
|
||||
TelemetryPriority priority = TelemetryPriority.P3,
|
||||
TelemetryLevel level = TelemetryLevel.Info,
|
||||
EventCode? code = null,
|
||||
Guid? eventId = null,
|
||||
TelemetryMetadata? metadata = null) =>
|
||||
new()
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
EventCode = code ?? TelemetryEventCodes.MobileAppStarted,
|
||||
Level = level,
|
||||
Priority = priority,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lit un fichier en cohabitation avec un writer encore actif. Les exporteurs
|
||||
/// (LocalFileTelemetryExporter / RemoteDiagnosticIngestionStore) ouvrent en
|
||||
/// <c>FileAccess.Write, FileShare.Read</c> (un writer, lectures autorisées) : un
|
||||
/// lecteur doit donc ouvrir avec <c>FileShare.ReadWrite</c> — jamais
|
||||
/// <see cref="File.ReadAllText"/> / <see cref="File.ReadAllLines"/> dont le
|
||||
/// <c>FileShare.Read</c> rejette l'écriture concurrente.
|
||||
/// </summary>
|
||||
public static string ReadAllTextShared(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
/// <summary>Équivalent lignes de <see cref="ReadAllTextShared"/> (UTF-8, partage d'écriture).</summary>
|
||||
public static string[] ReadAllLinesShared(string path)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
return lines.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store en mémoire pour les tests Core (aucune dépendance SQLite). Permet
|
||||
/// d'injecter une panne (ThrowOn) pour vérifier le fail-open de l'outbox.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryDiagnosticOutboxStore : IDiagnosticOutboxStore
|
||||
{
|
||||
private readonly List<DiagnosticOutboxRow> _rows = [];
|
||||
|
||||
public bool ThrowOnStore { get; set; }
|
||||
|
||||
public bool ThrowOnCount { get; set; }
|
||||
|
||||
public Task StoreAsync(IReadOnlyList<DiagnosticOutboxRow> rows, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ThrowOnStore)
|
||||
{
|
||||
throw new InvalidOperationException("store down");
|
||||
}
|
||||
|
||||
lock (_rows)
|
||||
{
|
||||
foreach (DiagnosticOutboxRow row in rows)
|
||||
{
|
||||
_rows.RemoveAll(r => r.EventId == row.EventId);
|
||||
_rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiagnosticOutboxRow>> GetNextBatchAsync(
|
||||
int maxCount,
|
||||
long maxBytes,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
var result = new List<DiagnosticOutboxRow>();
|
||||
long bytes = 0;
|
||||
foreach (DiagnosticOutboxRow row in _rows
|
||||
.Where(r => r.NextAttemptUtc <= now)
|
||||
.OrderBy(r => r.Priority)
|
||||
.ThenBy(r => r.CreatedAtUtc))
|
||||
{
|
||||
long rowBytes = Encoding.UTF8.GetByteCount(row.PayloadJson);
|
||||
if (result.Count > 0 && bytes + rowBytes > maxBytes)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
result.Add(row);
|
||||
bytes += rowBytes;
|
||||
if (result.Count >= maxCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DiagnosticOutboxRow>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(IReadOnlyList<Guid> eventIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
HashSet<Guid> ids = eventIds.ToHashSet();
|
||||
_rows.RemoveAll(r => ids.Contains(r.EventId));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAttemptAsync(
|
||||
IReadOnlyList<Guid> eventIds,
|
||||
int attemptCount,
|
||||
DateTimeOffset nextAttemptUtc,
|
||||
string? lastErrorCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
HashSet<Guid> ids = eventIds.ToHashSet();
|
||||
foreach (DiagnosticOutboxRow row in _rows.Where(r => ids.Contains(r.EventId)).ToList())
|
||||
{
|
||||
int index = _rows.IndexOf(row);
|
||||
_rows[index] = new DiagnosticOutboxRow
|
||||
{
|
||||
EventId = row.EventId,
|
||||
Priority = row.Priority,
|
||||
CreatedAtUtc = row.CreatedAtUtc,
|
||||
ExpiresAtUtc = row.ExpiresAtUtc,
|
||||
PayloadVersion = row.PayloadVersion,
|
||||
PayloadJson = row.PayloadJson,
|
||||
AttemptCount = attemptCount,
|
||||
NextAttemptUtc = nextAttemptUtc,
|
||||
LastAttemptUtc = DateTimeOffset.UtcNow,
|
||||
LastErrorCode = lastErrorCode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ThrowOnCount)
|
||||
{
|
||||
throw new InvalidOperationException("count down");
|
||||
}
|
||||
|
||||
lock (_rows)
|
||||
{
|
||||
return Task.FromResult(_rows.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
return Task.FromResult(_rows.RemoveAll(r => r.ExpiresAtUtc <= now));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> DeleteOldestAsync(TelemetryPriority priority, int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
List<Guid> toDelete = _rows
|
||||
.Where(r => r.Priority == priority)
|
||||
.OrderBy(r => r.CreatedAtUtc)
|
||||
.Take(count)
|
||||
.Select(r => r.EventId)
|
||||
.ToList();
|
||||
|
||||
_rows.RemoveAll(r => toDelete.Contains(r.EventId));
|
||||
return Task.FromResult(toDelete.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<long> GetDatabaseSizeBytesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
return Task.FromResult((long)_rows.Count * 100);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<DiagnosticOutboxRow> Snapshot()
|
||||
{
|
||||
lock (_rows)
|
||||
{
|
||||
return _rows.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Text.Json;
|
||||
using Omika.Core.Observability.Identity;
|
||||
using Omika.Core.Observability.Outbox;
|
||||
|
||||
namespace Omika.Tests.Observability.Outbox;
|
||||
|
||||
/// <summary>Tests du stockage d'ingestion serveur (JSONL canonique, borné, fail-safe).</summary>
|
||||
public class RemoteDiagnosticIngestionStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = CreateTempDirectory();
|
||||
|
||||
public void Dispose() => DeleteDirectory(_dir);
|
||||
|
||||
[Fact]
|
||||
public async Task Store_WritesCanonicalJsonLine_WithStableIds()
|
||||
{
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
|
||||
|
||||
Guid eventId = Guid.NewGuid();
|
||||
Guid traceId = Guid.NewGuid();
|
||||
Guid sessionId = Guid.NewGuid();
|
||||
Guid operationId = Guid.NewGuid();
|
||||
Guid batchId = Guid.NewGuid();
|
||||
string ingestionTraceId = Guid.NewGuid().ToString("D");
|
||||
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
BatchId = batchId,
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = eventId,
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Level = "Info",
|
||||
Priority = "P3",
|
||||
TraceId = traceId,
|
||||
SessionId = sessionId,
|
||||
OperationId = operationId,
|
||||
Component = "télémétrie"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
int accepted = await store.StoreAsync(batch, DiagnosticActorKey.Empty, ingestionTraceId, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(1, accepted);
|
||||
string line = Assert.Single(OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
JsonElement root = document.RootElement;
|
||||
Assert.Equal(batchId.ToString(), root.GetProperty("batchId").GetString());
|
||||
Assert.Equal(ingestionTraceId, root.GetProperty("ingestionTraceId").GetString());
|
||||
|
||||
JsonElement evt = root.GetProperty("event");
|
||||
Assert.Equal(eventId.ToString(), evt.GetProperty("eventId").GetString());
|
||||
Assert.Equal(traceId.ToString(), evt.GetProperty("traceId").GetString());
|
||||
Assert.Equal(sessionId.ToString(), evt.GetProperty("sessionId").GetString());
|
||||
Assert.Equal(operationId.ToString(), evt.GetProperty("operationId").GetString());
|
||||
Assert.Equal("télémétrie", evt.GetProperty("component").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_ActiveReader_ReadsCompleteJsonLines()
|
||||
{
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
|
||||
|
||||
// Écrit 2 événements SANS disposer le store (le writer reste actif et ouvert).
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
await store.StoreAsync(
|
||||
new DiagnosticBatchDto
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Component = $"event-{i}"
|
||||
}
|
||||
]
|
||||
},
|
||||
DiagnosticActorKey.Empty,
|
||||
"trace",
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
// Lecture concurrente pendant que le writer est encore ouvert : chaque ligne
|
||||
// flushée doit être un JSON complet (aucune demi-ligne, aucune corruption).
|
||||
string[] lines = OutboxTestHarness.ReadAllLinesShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
|
||||
|
||||
Assert.Equal(2, lines.Length);
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(lines[i]);
|
||||
Assert.True(document.RootElement.TryGetProperty("event", out JsonElement evt));
|
||||
Assert.Equal($"event-{i}", evt.GetProperty("component").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_NoDirectory_Throws()
|
||||
{
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = string.Empty });
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
store.StoreAsync(new DiagnosticBatchDto { Events = [new DiagnosticEventDto()] }, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_Rotates_AndEnforcesMaxFiles()
|
||||
{
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions
|
||||
{
|
||||
Directory = _dir,
|
||||
MaxFileSizeBytes = 256,
|
||||
MaxFiles = 2,
|
||||
MinFileAgeHours = 0 // rétention immédiate (test du plafond borné historique)
|
||||
});
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Component = new string('x', 80)
|
||||
}
|
||||
]
|
||||
};
|
||||
await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
int fileCount = Directory.EnumerateFiles(_dir, "*.jsonl").Count();
|
||||
Assert.True(fileCount <= 2, $"attendu ≤ 2 fichiers, trouvé {fileCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_MinFileAge_ProtectsRecentFilesFromRetention()
|
||||
{
|
||||
// Phase 5C.2C : avec une protection d'âge, les fichiers récents ne sont JAMAIS
|
||||
// supprimés par EnforceMaxFiles (le Worker local peut ne pas les avoir encore
|
||||
// importés). Le spool dépasse temporairement MaxFiles au lieu de perdre des
|
||||
// diagnostics non importés (fail-open).
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions
|
||||
{
|
||||
Directory = _dir,
|
||||
MaxFileSizeBytes = 256,
|
||||
MaxFiles = 2,
|
||||
MinFileAgeHours = 24 // protection d'âge active
|
||||
});
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Component = new string('x', 80)
|
||||
}
|
||||
]
|
||||
};
|
||||
await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
// Tous les fichiers viennent d'être écrits (< 24 h) : AUCUN ne doit être supprimé.
|
||||
int fileCount = Directory.EnumerateFiles(_dir, "*.jsonl").Count();
|
||||
Assert.True(fileCount > 2, $"la protection d'âge doit conserver les fichiers récents, trouvé {fileCount}");
|
||||
Assert.Equal(0, store.DroppedFileCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_DoesNotPersistRawRequestBodyOrUnknownFields()
|
||||
{
|
||||
// Le store n'écrit que le DTO typé (liste blanche). Un champ "password" reçu
|
||||
// dans le corps brut n'existe pas sur le DTO et n'est donc jamais persisté.
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Component = "ok"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow);
|
||||
|
||||
string content = OutboxTestHarness.ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
|
||||
Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("authorization", content, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_PreservesScreenCodeAndFeatureCode()
|
||||
{
|
||||
// T18 : le JSONL canonique serveur doit conserver ScreenCode/FeatureCode
|
||||
// (dimensions d'usage) reçus du mobile. Régression : StripIdentity les
|
||||
// abandonnait (screen_code/feature_code NULL en PostgreSQL).
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-UI-001",
|
||||
Component = "Navigation",
|
||||
ScreenCode = "SITE_DETAIL",
|
||||
FeatureCode = "REPORT_GENERATE"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow);
|
||||
|
||||
string line = OutboxTestHarness.ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
JsonElement evt = document.RootElement.GetProperty("event");
|
||||
|
||||
Assert.Equal("SITE_DETAIL", evt.GetProperty("screenCode").GetString());
|
||||
Assert.Equal("REPORT_GENERATE", evt.GetProperty("featureCode").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_NullScreenCodeAndFeatureCode_AreOmitted()
|
||||
{
|
||||
// Compatibilité : les payloads historiques sans usage fonctionnel continuent
|
||||
// de produire un JSONL SANS screenCode/featureCode (aucune invention, aucun null).
|
||||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
|
||||
var batch = new DiagnosticBatchDto
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new DiagnosticEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
EventCode = "OMK-MOB-APP-001",
|
||||
Component = "ok"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace", DateTimeOffset.UtcNow);
|
||||
|
||||
string line = OutboxTestHarness.ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
JsonElement evt = document.RootElement.GetProperty("event");
|
||||
|
||||
Assert.False(evt.TryGetProperty("screenCode", out _));
|
||||
Assert.False(evt.TryGetProperty("featureCode", out _));
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
string dir = Path.Combine(Path.GetTempPath(), "omika-remote-diag-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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user