using System.Text.Json; using Omika.Core.Observability; using Omika.Core.Observability.Outbox; namespace Omika.Tests.Observability.Outbox; /// Tests du mapping événement → DTO transport (§27, §28, §29, §63). 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); } }