Files
2026-10-23 18:46:50 +02:00

274 lines
9.4 KiB
C#

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);
}
}