Files
Finlytic/FinlyticEngine.Tests/Services/Ai/AiReasoningGateServiceTests.cs
T

136 lines
5.6 KiB
C#

using System;
using System.Reflection;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Services.Ai;
using Xunit;
namespace FinlyticEngine.Tests.Services.Ai;
/// <summary>
/// Regression coverage for the n8n validation-webhook response parser. The contract was redesigned to match
/// <see cref="AiValidationResultDto"/>'s own field names 1:1 (camelCase <c>isApproved</c>/<c>thesisSummary</c>/
/// <c>invalidationReason</c>/<c>keyCatalysts</c>/<c>identifiedRisks</c>) instead of a separate, undocumented
/// vocabulary (<c>status</c>/<c>action_recommendation</c>/nested <c>raw_validation_result</c>) 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 <c>SaveChangesAsync</c> with a null <c>ThesisSummary</c> and
/// crashed on the NOT NULL constraint on <c>engine_evaluation_snapshots.AiThesisSummary</c>) - 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.
/// </summary>
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);
}
}