using System; using System.Reflection; using FinlyticCore.Dtos.Trading; using FinlyticEngine.Services.Ai; using Xunit; namespace FinlyticEngine.Tests.Services.Ai; /// /// Regression coverage for the n8n validation-webhook response parser. The contract was redesigned to match /// 's own field names 1:1 (camelCase isApproved/thesisSummary/ /// invalidationReason/keyCatalysts/identifiedRisks) instead of a separate, undocumented /// vocabulary (status/action_recommendation/nested raw_validation_result) that no prompt /// ever actually specified. System.Text.Json does not throw on a field-name mismatch - it silently builds a /// record from parameter defaults, which then LOOKS like a real, successfully-parsed AI result even though /// nothing was extracted (this previously reached SaveChangesAsync with a null ThesisSummary and /// crashed on the NOT NULL constraint on engine_evaluation_snapshots.AiThesisSummary) - hence the /// explicit "missing isApproved/thesisSummary -> null" guard these tests exercise. Tests the private parser /// directly via reflection since it is an internal implementation detail of the service, not part of its /// public contract. /// public class AiReasoningGateServiceTests { private static AiValidationResultDto? Parse(string json) { var method = typeof(AiReasoningGateService).GetMethod( "ParseN8nValidationResponse", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); return (AiValidationResultDto?)method!.Invoke(null, new object[] { json }); } [Fact] public void ParseN8nValidationResponse_RejectedPayload_ExtractsThesisAndRisksWithoutNulls() { // n8n's "Respond to Webhook" node commonly wraps a single result in a one-element array ("All // Incoming Items") - the parser must unwrap that transparently. const string payload = """ [ { "isApproved": false, "confidence": 0.72, "thesisSummary": "Diskrepanz zwischen technischem Volatilitäts-Breakout und fehlender fundamentaler/sentimentaler Bestätigung.", "invalidationReason": "Ausbruch ohne Nachrichtenkatalysator - hohe Wahrscheinlichkeit eines Fehlausbruchs.", "keyCatalysts": [], "identifiedRisks": [ "Der Ausbruch findet in einem nachrichtenarmen Umfeld statt.", "Risikostufe laut Validator: MEDIUM" ] } ] """; var result = Parse(payload); Assert.NotNull(result); // The core regression: ThesisSummary must never be null/empty for a parseable response — this is // exactly the value that used to violate the NOT NULL constraint. Assert.False(string.IsNullOrWhiteSpace(result!.ThesisSummary)); Assert.Contains("Diskrepanz", result.ThesisSummary); Assert.False(result.IsApproved); Assert.Equal(0.72m, result.Confidence); Assert.Equal(ValidationSource.Ai, result.Source); Assert.Contains(result.IdentifiedRisks, r => r.Contains("nachrichtenarmen")); Assert.Contains(result.IdentifiedRisks, r => r.Contains("MEDIUM")); Assert.Empty(result.KeyCatalysts); } [Fact] public void ParseN8nValidationResponse_ApprovedNoConfidence_IsApprovedTrueAndConfidenceNull() { const string payload = """{"isApproved": true, "thesisSummary": "Alles im gruenen Bereich."}"""; var result = Parse(payload); Assert.NotNull(result); Assert.True(result!.IsApproved); // No numeric confidence was sent - none must be invented (Rules.md §4). Assert.Null(result.Confidence); // Not supplied by the webhook in this payload - must default to empty, not fabricated. Assert.Empty(result.KeyCatalysts); Assert.Empty(result.IdentifiedRisks); } [Fact] public void ParseN8nValidationResponse_MissingIsApproved_ReturnsNull() { // A validator that supplies a thesis but never actually says yes/no is not a usable verdict - fail // closed rather than defaulting IsApproved to false while looking like a fully-parsed result. const string payload = """{"thesisSummary": "Setup sieht grundsaetzlich brauchbar aus."}"""; var result = Parse(payload); Assert.Null(result); } [Fact] public void ParseN8nValidationResponse_MissingThesisSummary_ReturnsNull() { const string payload = """{"isApproved": true}"""; var result = Parse(payload); Assert.Null(result); } [Fact] public void ParseN8nValidationResponse_CompletelyUnrelatedSchema_ReturnsNull() { // Simulates any future webhook contract drift that shares zero field names with what this parser // knows about. Must degrade to "no usable result" (null), never to a garbage non-null object with // an empty ThesisSummary - the caller's guard only protects against the latter if this returns null // or a result whose ThesisSummary is blank. const string payload = """{"foo": "bar", "baz": 42}"""; var result = Parse(payload); Assert.True(result is null || string.IsNullOrWhiteSpace(result.ThesisSummary)); } [Fact] public void ParseN8nValidationResponse_NotJson_ReturnsNull() { var result = Parse("this is not json at all"); Assert.Null(result); } [Fact] public void ParseN8nValidationResponse_EmptyArray_ReturnsNull() { var result = Parse("[]"); Assert.Null(result); } }