136 lines
5.6 KiB
C#
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);
|
|
}
|
|
}
|