using Omika.Diagnostics.Models; using Omika.Diagnostics.Prompt; namespace Omika.Diagnostics.Tests; /// /// T24-A Partie A — génération LOCALE du prompt IA. Aucun appel IA externe : le builder /// transforme un diagnostic en texte lisible. Tests : précision /dev/throw, corrélation /// (TraceId/OperationId), données NULL, bornes, et sécurité (0 fuite de valeurs interdites, /// réutilisation de ). /// public class DiagnosticPromptBuilderTests { private static readonly DateTimeOffset Base = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero); private static DiagnosticEventRecord Ev( string eventCode = "TEST", string level = "Error", string priority = "P2", string? errorCode = null, string? message = null, string? exceptionType = null, string? stackTrace = null, Guid? traceId = null, Guid? operationId = null, Guid? sessionId = null, string? actorKey = null, string? clientCode = null, string? environment = null, string? serverInstance = null, string? source = null, string? component = null, string? platform = null, string? operatingSystem = null, string? operatingSystemVersion = null, string? manufacturer = null, string? model = null, string? appVersion = null, string? appBuild = null, string? screenCode = null, string? featureCode = null, string? licenseNumber = null, string? deviceKey = null, long? durationMs = null, string? metadataJson = null) => new() { Id = 1, EventId = Guid.NewGuid(), EventCode = eventCode, TimestampUtc = Base, Level = level, Priority = priority, ErrorCode = errorCode, Message = message, ExceptionType = exceptionType, StackTrace = stackTrace, TraceId = traceId, OperationId = operationId, SessionId = sessionId, ActorKey = actorKey, ClientCode = clientCode, Environment = environment, ServerInstance = serverInstance, Source = source ?? DiagnosticSources.Mobile, Component = component, Platform = platform, OperatingSystem = operatingSystem, OperatingSystemVersion = operatingSystemVersion, DeviceManufacturer = manufacturer, DeviceModel = model, AppVersion = appVersion, AppBuild = appBuild, ScreenCode = screenCode, FeatureCode = featureCode, LicenseNumber = licenseNumber, DeviceKey = deviceKey, DurationMs = durationMs, MetadataJson = metadataJson, IngestedAtUtc = DateTimeOffset.UtcNow }; private static DiagnosticEventRecord Correlated(long id, DateTimeOffset ts, string eventCode, string level, string component, string message) => new() { Id = id, EventId = Guid.NewGuid(), EventCode = eventCode, TimestampUtc = ts, Level = level, Priority = "P2", Component = component, Message = message, Source = DiagnosticSources.Api, IngestedAtUtc = DateTimeOffset.UtcNow }; // ------------------------------------------- A : /dev/throw (volontaire/test) ---- [Fact] public void DevThrow_PromptContainsEnoughContextToInferVoluntaryTest() { DiagnosticEventRecord ev = Ev( eventCode: "API_HTTP_ERROR", errorCode: "TELEMETRY_TEST_ERROR", exceptionType: "InvalidOperationException", environment: "Development", serverInstance: "API-A-01", source: DiagnosticSources.Api, component: "Omika.Api", metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}"""); string prompt = DiagnosticPromptBuilder.Build(ev); // Aucune règle codée en dur « /dev/throw » : le résultat vient des données. Assert.Contains("API_HTTP_ERROR", prompt, StringComparison.Ordinal); Assert.Contains("InvalidOperationException", prompt, StringComparison.Ordinal); Assert.Contains("/dev/throw", prompt, StringComparison.Ordinal); Assert.Contains("TELEMETRY_TEST_ERROR", prompt, StringComparison.Ordinal); Assert.Contains("Development", prompt, StringComparison.Ordinal); Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal); } // ------------------------------------------- B : TraceId + corrélés ---- [Fact] public void TraceId_PromptIncludesCorrelatedSection() { Guid traceId = Guid.NewGuid(); DiagnosticEventRecord ev = Ev(traceId: traceId, eventCode: "ERR", exceptionType: "System.Exception"); var correlated = new[] { Correlated(1, Base.AddMinutes(-1), "OMK-API-HTTP-001", "Info", "Omika.Api", "api request"), Correlated(2, Base, "ERR", "Error", "Omika.Api", "boom") }; string prompt = DiagnosticPromptBuilder.Build(ev, correlated); Assert.Contains(traceId.ToString("D"), prompt, StringComparison.Ordinal); Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal); Assert.Contains("OMK-API-HTTP-001", prompt, StringComparison.Ordinal); } // ------------------------------------------- C : OperationId ---- [Fact] public void OperationId_PromptIncludesOperationId() { Guid operationId = Guid.NewGuid(); DiagnosticEventRecord ev = Ev(operationId: operationId); string prompt = DiagnosticPromptBuilder.Build(ev); Assert.Contains(operationId.ToString("D"), prompt, StringComparison.Ordinal); } // ------------------------------------------- D : mobile ScreenCode/DeviceInfo ---- [Fact] public void MobileEvent_PromptIncludesScreenAndDeviceInfo() { DiagnosticEventRecord ev = Ev( screenCode: "AGENDA", featureCode: "INTERVENTION_SEARCH_RUN", platform: "Android", manufacturer: "Samsung", model: "SM-S928B", operatingSystem: "Android", operatingSystemVersion: "15", appVersion: "1.1.0", deviceKey: "devk-pseudonymized-abcdef"); string prompt = DiagnosticPromptBuilder.Build(ev); Assert.Contains("AGENDA", prompt, StringComparison.Ordinal); Assert.Contains("INTERVENTION_SEARCH_RUN", prompt, StringComparison.Ordinal); Assert.Contains("Samsung", prompt, StringComparison.Ordinal); Assert.Contains("SM-S928B", prompt, StringComparison.Ordinal); Assert.Contains("Android", prompt, StringComparison.Ordinal); } // ------------------------------------------- E : beaucoup de NULL ---- [Fact] public void AllNull_PromptStillBuildsWithoutException() { DiagnosticEventRecord ev = Ev(eventCode: "OLD", level: "Info"); ev.TraceId = null; ev.OperationId = null; ev.SessionId = null; ev.StackTrace = null; ev.Message = null; ev.MetadataJson = null; string prompt = DiagnosticPromptBuilder.Build(ev); Assert.StartsWith("Analyse cette erreur Omika", prompt, StringComparison.Ordinal); Assert.Contains("DEMANDE :", prompt, StringComparison.Ordinal); Assert.DoesNotContain("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal); } // ------------------------------------------- F : sécurité métadonnées (0 fuite) ---- [Fact] public void ForbiddenMetadata_IsNeverLeakedInPrompt() { DiagnosticEventRecord ev = Ev( eventCode: "ERR", metadataJson: """{"password":"secret123","token":"tok-abc","apiKey":"key-xyz","connectionString":"Server=x;Password=y","deviceId":"dev-raw-1","method":"POST","statusCode":500,"routeTemplate":"/api/v1/x"}"""); string prompt = DiagnosticPromptBuilder.Build(ev); Assert.DoesNotContain("secret123", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("tok-abc", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("key-xyz", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("Password=y", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("dev-raw-1", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("password", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("apiKey", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("connectionString", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("deviceId", prompt, StringComparison.OrdinalIgnoreCase); // Les clés whitelistées et saines restent présentes. Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal); Assert.Contains("/api/v1/x", prompt, StringComparison.Ordinal); } [Fact] public void ForbiddenValueUnderWhitelistedKey_IsDropped() { DiagnosticEventRecord ev = Ev( eventCode: "ERR", metadataJson: """{"method":"ConnectionString=Server=x;Password=hidden","statusCode":200}"""); string prompt = DiagnosticPromptBuilder.Build(ev); Assert.DoesNotContain("ConnectionString", prompt, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("Password=hidden", prompt, StringComparison.OrdinalIgnoreCase); Assert.Contains("statusCode", prompt, StringComparison.Ordinal); } // ------------------------------------------- §48 : sans corrélation ---- [Fact] public void NoTraceOperationSession_PromptStillBuilds() { DiagnosticEventRecord ev = Ev(); ev.TraceId = null; ev.OperationId = null; ev.SessionId = null; string prompt = DiagnosticPromptBuilder.Build(ev); Assert.Contains("Analyse cette erreur Omika", prompt, StringComparison.Ordinal); Assert.DoesNotContain("TraceId", prompt, StringComparison.Ordinal); } // ------------------------------------------- §49 : sans stack trace ---- [Fact] public void NoStackTrace_PromptStillBuilds() { DiagnosticEventRecord ev = Ev(exceptionType: "System.Exception", stackTrace: null); string prompt = DiagnosticPromptBuilder.Build(ev); Assert.Contains("ExceptionType", prompt, StringComparison.Ordinal); Assert.DoesNotContain("StackTrace", prompt, StringComparison.Ordinal); } // ------------------------------------------- §50 : bornes corrélés ---- [Fact] public void CorrelatedEvents_AreCappedAtMax() { DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid()); var many = new List(); for (int i = 0; i < 60; i++) { many.Add(Correlated(i + 1, Base.AddSeconds(i), "E", "Info", "C", "m" + i)); } string prompt = DiagnosticPromptBuilder.Build(ev, many); Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal); // 60 événements fournis, mais la section est bornée à MaxCorrelatedEvents. int count = prompt.Split("- ", StringSplitOptions.None).Length - 1; Assert.Equal(DiagnosticPromptBuilder.MaxCorrelatedEvents, count); Assert.DoesNotContain("m59", prompt, StringComparison.Ordinal); // au-delà de la borne. } [Fact] public void CorrelatedEvents_SelectionneLesPlusProchesDeLErreur() { DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid()); var many = new List(); // 30 événements ANTÉRIEURS (de 30 à 1 minutes avant l'erreur) : ce sont eux que // la logique « les N premiers de la liste » reproduisait, sans jamais montrer // ce qui s'est passé juste après l'incident. for (int i = 30; i >= 1; i--) { many.Add(Correlated(100 + i, Base.AddMinutes(-i), "E", "Info", "C", $"avant{i:00}")); } // 5 événements POSTÉRIEURS : le contexte immédiat de l'erreur. for (int i = 1; i <= 5; i++) { many.Add(Correlated(200 + i, Base.AddMinutes(i), "E", "Info", "C", $"apres{i:00}")); } string prompt = DiagnosticPromptBuilder.Build(ev, many); // Retenus : les 5 postérieurs + les 20 antérieurs les plus proches. Assert.Contains("apres05", prompt, StringComparison.Ordinal); Assert.Contains("avant01", prompt, StringComparison.Ordinal); Assert.Contains("avant20", prompt, StringComparison.Ordinal); Assert.DoesNotContain("avant21", prompt, StringComparison.Ordinal); Assert.DoesNotContain("avant30", prompt, StringComparison.Ordinal); // Restitution chronologique : le passé avant le futur. Assert.True( prompt.IndexOf("avant01", StringComparison.Ordinal) < prompt.IndexOf("apres01", StringComparison.Ordinal)); } [Fact] public void CorrelatedEvents_PromptIndiqueQueLaListeNEstPasExhaustive() { DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid()); string prompt = DiagnosticPromptBuilder.Build( ev, [Correlated(1, Base.AddMinutes(-1), "E", "Info", "C", "m")]); // Le lecteur (humain ou IA) ne doit pas conclure à une absence d'activité : // les événements Info sont remontés après les erreurs (file prioritaire). Assert.Contains("NON exhaustive", prompt, StringComparison.Ordinal); Assert.Contains("remontés APRÈS les erreurs", prompt, StringComparison.Ordinal); } // ------------------------------------------- longueur raisonnable ---- [Fact] public void Prompt_IsReasonablySized() { DiagnosticEventRecord ev = Ev( eventCode: "API_HTTP_ERROR", exceptionType: "InvalidOperationException", stackTrace: new string('x', 10000), message: new string('y', 5000), metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}"""); string prompt = DiagnosticPromptBuilder.Build(ev); Assert.True(prompt.Length < 9000, $"Prompt trop grand : {prompt.Length}"); // La stack trace est tronquée, pas reproduite intégralement. Assert.DoesNotContain(new string('x', 10000), prompt, StringComparison.Ordinal); } }