feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests

This commit is contained in:
2026-08-24 21:37:05 +02:00
parent a4959658a2
commit 5c95dd182c
49 changed files with 7709 additions and 0 deletions
@@ -0,0 +1,135 @@
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);
}
}
@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Database;
using FinlyticEngine.Services.Ai;
using FinlyticEngine.Services.Derivatives;
using FinlyticEngine.Services.Mqtt;
using FinlyticEngine.Services.Scoring;
using FinlyticEngine.Services.Trading;
using FinlyticEngine.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace FinlyticEngine.Tests.Services.Trading;
/// <summary>
/// Regression coverage for the proposal-spam bug found in production: <c>EvaluateAssetAsync</c> did not check
/// for an already-active proposal on the same ISIN before creating a new <c>EngineTradeProposalEntity</c>, so
/// the autonomous <c>OpportunityPollerBackgroundService</c> re-evaluating the same technical top-picks every
/// scan cycle created a fresh, near-identical proposal (and re-broadcast <c>finlytic/engine/proposals/created</c>)
/// every single cycle for as long as one asset stayed above the approval threshold - confirmed as the cause of
/// a single ISIN generating 1,310 proposal rows in roughly two hours.
/// <para>
/// Unlike <see cref="FinlyticEngine.Tests.Services.Trading.TradeLifecycleServiceTests"/> (which deliberately
/// never reaches <c>EvaluateAssetAsync</c> and uses fakes that throw if it is), these tests need the pipeline
/// to actually run end to end, so they wire up small always-approving stubs instead.
/// </para>
/// </summary>
public class EvaluateAssetAsync_ProposalDedupTests
{
private const string Isin = "US0378331005";
private static StrategyResultDto BuildApprovedSetup() => new(
SetupId: Guid.NewGuid(),
Isin: Isin,
Symbol: "AAPL",
Timeframe: "1h",
StrategyKey: "TestStrategy",
StrategyName: "Test Strategy",
Direction: SignalDirection.Buy,
QualityScore: 90m,
CurrentPrice: 100m,
EntryPrice: 100m,
InvalidationPrice: 90m,
CurrentAtr: 1m,
EstimatedRiskRewardRatio: 2m,
ExitPlan: TestData.SimpleExitPlan(90m, 110m),
TechnicalRationale: "Test rationale",
TriggeringPatterns: new List<PatternResultDto>(),
IndicatorSnapshot: new Dictionary<string, decimal>(),
CreatedAt: DateTime.UtcNow,
ExpiresAt: DateTime.UtcNow.AddHours(1)
);
/// <summary>
/// Answers only the one RPC channel this pipeline needs a real value from
/// (<see cref="FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin"/>); everything else (sentiment,
/// fundamentals, simulation-reliability) resolves to <see langword="null"/>, which
/// <see cref="StubApprovingScorer"/> below simply ignores.
/// </summary>
private sealed class StubEngineRpcClient : IEngineRpcClient
{
public List<(string Topic, object? Data)> PublishedMessages { get; } = new();
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest requestData, TimeSpan? timeout = null)
where TResponse : class
where TRequest : class
{
if (channel == FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin)
{
var setups = new List<StrategyResultDto> { BuildApprovedSetup() };
return Task.FromResult((object)setups as TResponse);
}
return Task.FromResult<TResponse?>(null);
}
public Task PublishAsync<T>(string topic, T data, bool retain = false)
{
PublishedMessages.Add((topic, data));
return Task.CompletedTask;
}
}
/// <summary>Always reports a high, gate-clearing composite score, regardless of the (null) sentiment/fundamentals/reliability inputs.</summary>
private sealed class StubApprovingScorer : ICompositeOpportunityScorer
{
public Task<ScoringResult> CalculateCompositeScoreAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ScoringResult(
CompositeScore: 90m,
TechnicalScore: 90m,
SentimentScore: 50m,
FundamentalScore: 50m,
PassedEarningsLockout: true,
DaysToNextEarnings: null,
ReliabilityBonus: 0m,
PassedSimulationVeto: true));
}
/// <summary>Always approves - mirrors <see cref="TestData.ApprovedAiValidation"/>.</summary>
private sealed class StubApprovingAiGate : IAiReasoningGateService
{
public Task<AiValidationResultDto> ValidateOpportunityAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
ScoringResult score,
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(TestData.ApprovedAiValidation());
}
/// <summary>No derivative resolution needed for this test - always "no derivative selected".</summary>
private sealed class StubNoDerivativeResolver : IKnockOutDerivativeResolver
{
public Task<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
string underlyingIsin,
SignalDirection direction,
decimal chartStopLoss,
decimal currentPrice,
CancellationToken cancellationToken = default)
=> Task.FromResult<DerivativeSelectionDto?>(null);
}
/// <summary>
/// Builds a real <see cref="TradeLifecycleService"/> against an InMemory <see cref="EngineDbContext"/>, with
/// every dependency stubbed to always approve, so <c>EvaluateAssetAsync</c> runs the full pipeline instead
/// of short-circuiting or throwing.
/// </summary>
private static (TradeLifecycleService Sut, IServiceScopeFactory ScopeFactory, StubEngineRpcClient RpcClient) BuildApprovingHarness()
{
// An explicit, shared InMemoryDatabaseRoot guarantees every EngineDbContext instance resolved from
// this provider's scopes (including the ones TradeLifecycleService creates internally per call) sees
// the SAME named in-memory store, regardless of exactly when/how often the UseInMemoryDatabase
// configuration delegate itself gets re-invoked.
var databaseRoot = new Microsoft.EntityFrameworkCore.Storage.InMemoryDatabaseRoot();
var dbName = Guid.NewGuid().ToString("N");
var services = new ServiceCollection();
services.AddDbContext<EngineDbContext>(o => o.UseInMemoryDatabase(dbName, databaseRoot));
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var settings = new FakeSettingsService();
var rpcClient = new StubEngineRpcClient();
var sut = new TradeLifecycleService(
scopeFactory,
new StubApprovingScorer(),
new StubApprovingAiGate(),
new StubNoDerivativeResolver(),
rpcClient,
settings,
new FakeFinlyticLogger<TradeLifecycleService>());
return (sut, scopeFactory, rpcClient);
}
[Fact]
public async Task EvaluateAssetAsync_CalledTwiceForSameIsinWhileApproved_CreatesOnlyOneActiveProposal()
{
var (sut, scopeFactory, rpcClient) = BuildApprovingHarness();
// Simulates two consecutive OpportunityPollerBackgroundService scan cycles both seeing the same
// top-pick ISIN while its score stays above the approval threshold.
var first = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
var second = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
Assert.NotNull(first.Proposal);
Assert.NotNull(second.Proposal);
// The second call must NOT have created a second row - it should report the SAME proposal the first
// call created, not a fresh one.
Assert.Equal(first.Proposal!.ProposalId, second.Proposal!.ProposalId);
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var proposalsForIsin = await db.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync();
Assert.Single(proposalsForIsin);
var snapshotsForIsin = await db.Snapshots.AsNoTracking().Where(s => s.Isin == Isin).OrderBy(s => s.EvaluatedAtUtc).ToListAsync();
Assert.Equal(2, snapshotsForIsin.Count);
Assert.Equal(OutcomeReason.Approved, snapshotsForIsin[0].OutcomeReason);
Assert.Equal(OutcomeReason.DuplicateActiveProposal, snapshotsForIsin[1].OutcomeReason);
// Both snapshot rows must point at the one real proposal, including the deduplicated second one.
Assert.Equal(proposalsForIsin[0].Id, snapshotsForIsin[0].ProposalId);
Assert.Equal(proposalsForIsin[0].Id, snapshotsForIsin[1].ProposalId);
// Exactly one "created" broadcast must have fired - the duplicate attempt must not re-broadcast.
Assert.Single(rpcClient.PublishedMessages, m => m.Topic == "finlytic/engine/proposals/created");
}
[Fact]
public async Task EvaluateAssetAsync_SecondCallAfterFirstProposalExpired_CreatesANewProposal()
{
var (sut, scopeFactory, _) = BuildApprovingHarness();
var first = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
Assert.NotNull(first.Proposal);
// Force the first proposal to already be expired, simulating a much later scan cycle.
using (var scope = scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var proposal = await db.TradeProposals.SingleAsync(p => p.UnderlyingIsin == Isin);
proposal.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1);
await db.SaveChangesAsync();
}
var second = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
Assert.NotNull(second.Proposal);
// Once the first proposal has genuinely expired, a fresh opportunity is not a duplicate - a new
// proposal row is expected.
Assert.NotEqual(first.Proposal!.ProposalId, second.Proposal!.ProposalId);
using var verifyScope = scopeFactory.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<EngineDbContext>();
var allProposals = await verifyDb.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync();
Assert.Equal(2, allProposals.Count);
}
}
@@ -0,0 +1,306 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Database.Entities;
using FinlyticEngine.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace FinlyticEngine.Tests.Services.Trading;
/// <summary>
/// Tenant-boundary tests for <see cref="FinlyticEngine.Services.Trading.TradeLifecycleService"/> — the
/// highest-value, previously entirely unverified surface named in the test-authoring brief. Every test here
/// exercises the real service against a real (InMemory-backed) <see cref="FinlyticEngine.Database.EngineDbContext"/>
/// so the actual LINQ tenant-filter predicates run, not a hand-rolled substitute.
/// </summary>
public class TradeLifecycleServiceTests
{
// ---------------------------------------------------------------------
// GetActiveTradesAsync: tenant isolation on read
// ---------------------------------------------------------------------
[Fact]
public async Task GetActiveTradesAsync_DoesNotReturnAnotherUsersTrades()
{
using var harness = new TradeLifecycleServiceHarness();
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.Trades.Add(TestData.ActiveTrade(userA));
db.Trades.Add(TestData.ActiveTrade(userB));
db.Trades.Add(TestData.ActiveTrade(userB));
await db.SaveChangesAsync();
}
var aTrades = await harness.Sut.GetActiveTradesAsync(userA);
// This is the core assertion this whole task exists for: user A must see exactly their own trade,
// never user B's, regardless of how many other users have trades in the same table.
Assert.Single(aTrades);
Assert.All(aTrades, t => Assert.NotEqual(Guid.Empty, t.TradeId));
}
[Fact]
public async Task GetActiveTradesAsync_ExcludesTerminalStatuses()
{
using var harness = new TradeLifecycleServiceHarness();
var userA = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Active));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Closed));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.StoppedOut));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Invalidated));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Expired));
await db.SaveChangesAsync();
}
var result = await harness.Sut.GetActiveTradesAsync(userA);
Assert.Single(result);
Assert.Equal(TradeStatus.Active, result[0].Status);
}
// ---------------------------------------------------------------------
// AddTradeFillAsync / UpdateStopLossAsync / CloseTradeAsync: tenant isolation on mutation.
// A trade owned by another user must behave exactly like a non-existent trade — same exception,
// same message shape — so ownership is never disclosed to the caller.
// ---------------------------------------------------------------------
[Fact]
public async Task AddTradeFillAsync_ThrowsSameErrorForAnotherUsersTradeAsForMissingTrade()
{
using var harness = new TradeLifecycleServiceHarness();
var owner = Guid.NewGuid();
var attacker = Guid.NewGuid();
var trade = TestData.ActiveTrade(owner);
using (var db = harness.OpenDbContext())
{
db.Trades.Add(trade);
await db.SaveChangesAsync();
}
var exOtherUsersTrade = await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.AddTradeFillAsync(attacker, trade.Id, 105m, 1m));
var missingTradeId = Guid.NewGuid();
var exMissingTrade = await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.AddTradeFillAsync(attacker, missingTradeId, 105m, 1m));
// Same wording template for both — no information leak about whether the trade exists at all.
Assert.Equal($"Trade with ID {trade.Id} not found.", exOtherUsersTrade.Message);
Assert.Equal($"Trade with ID {missingTradeId} not found.", exMissingTrade.Message);
// And the legitimate owner must still be able to act on it — proves the trade genuinely exists and
// the previous failures were purely ownership-driven, not e.g. a broken seed.
var dto = await harness.Sut.AddTradeFillAsync(owner, trade.Id, 105m, 1m);
Assert.Equal(trade.Id, dto.TradeId);
}
[Fact]
public async Task UpdateStopLossAsync_ThrowsForAnotherUsersTrade_AndSucceedsForOwner()
{
using var harness = new TradeLifecycleServiceHarness();
var owner = Guid.NewGuid();
var attacker = Guid.NewGuid();
var trade = TestData.ActiveTrade(owner);
using (var db = harness.OpenDbContext())
{
db.Trades.Add(trade);
await db.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.UpdateStopLossAsync(attacker, trade.Id, 95m, "attacker attempt"));
var dto = await harness.Sut.UpdateStopLossAsync(owner, trade.Id, 95m, "owner adjustment");
Assert.Equal(95m, dto.CurrentStopLoss);
}
[Fact]
public async Task CloseTradeAsync_ThrowsForAnotherUsersTrade_AndSucceedsForOwnerWithCorrectPnl()
{
using var harness = new TradeLifecycleServiceHarness();
var owner = Guid.NewGuid();
var attacker = Guid.NewGuid();
var trade = TestData.ActiveTrade(owner, averageBuyIn: 100m);
using (var db = harness.OpenDbContext())
{
db.Trades.Add(trade);
await db.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.CloseTradeAsync(attacker, trade.Id, 120m, "attacker attempt"));
var dto = await harness.Sut.CloseTradeAsync(owner, trade.Id, 120m, "target hit");
Assert.Equal(TradeStatus.Closed, dto.Status);
// Buy direction: (closePrice - averageBuyIn) * quantity - fees = (120-100)*1 - 0 = 20.
Assert.Equal(20m, dto.RealizedPnlEur);
}
// ---------------------------------------------------------------------
// CreateTradeFromProposalAsync / AcceptProposalAsync: multi-tenant proposal acceptance semantics.
// ---------------------------------------------------------------------
[Fact]
public async Task CreateTradeFromProposalAsync_TwoDifferentUsers_EachGetOwnTrade_ProposalStaysActive()
{
using var harness = new TradeLifecycleServiceHarness();
var proposal = TestData.ActiveProposal();
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(proposal);
await db.SaveChangesAsync();
}
var tradeA = await harness.Sut.CreateTradeFromProposalAsync(userA, proposal.Id, ExecutionMode.ManualTradeRepublic);
var tradeB = await harness.Sut.CreateTradeFromProposalAsync(userB, proposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.NotNull(tradeA);
Assert.NotNull(tradeB);
Assert.NotEqual(tradeA!.TradeId, tradeB!.TradeId);
Assert.Equal(proposal.Id, tradeA.ProposalId);
Assert.Equal(proposal.Id, tradeB.ProposalId);
using (var db = harness.OpenDbContext())
{
// A proposal is a system-wide opportunity: accepting it must NOT deactivate it for other users.
var stillActive = await db.TradeProposals.AsNoTracking().SingleAsync(p => p.Id == proposal.Id);
Assert.True(stillActive.IsActive);
var tradesForProposal = await db.Trades.AsNoTracking().Where(t => t.ProposalId == proposal.Id).ToListAsync();
Assert.Equal(2, tradesForProposal.Count);
Assert.Contains(tradesForProposal, t => t.UserId == userA);
Assert.Contains(tradesForProposal, t => t.UserId == userB);
}
}
[Fact]
public async Task CreateTradeFromProposalAsync_SameUserAcceptsTwice_ThrowsWithoutCreatingSecondTrade()
{
using var harness = new TradeLifecycleServiceHarness();
var proposal = TestData.ActiveProposal();
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(proposal);
await db.SaveChangesAsync();
}
var first = await harness.Sut.CreateTradeFromProposalAsync(user, proposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.NotNull(first);
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.CreateTradeFromProposalAsync(user, proposal.Id, ExecutionMode.ManualTradeRepublic));
using (var db = harness.OpenDbContext())
{
var tradesForUser = await db.Trades.AsNoTracking()
.Where(t => t.UserId == user && t.ProposalId == proposal.Id)
.ToListAsync();
Assert.Single(tradesForUser);
}
}
[Fact]
public async Task CreateTradeFromProposalAsync_ExpiredProposal_ReturnsNull_NoTradeCreated()
{
using var harness = new TradeLifecycleServiceHarness();
var expiredProposal = TestData.ActiveProposal(expiresAtUtc: DateTime.UtcNow.AddHours(-1));
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(expiredProposal);
await db.SaveChangesAsync();
}
var result = await harness.Sut.CreateTradeFromProposalAsync(user, expiredProposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.Null(result);
using (var db = harness.OpenDbContext())
{
Assert.False(await db.Trades.AsNoTracking().AnyAsync(t => t.ProposalId == expiredProposal.Id));
}
}
[Fact]
public async Task CreateTradeFromProposalAsync_InactiveProposal_ReturnsNull()
{
using var harness = new TradeLifecycleServiceHarness();
var inactiveProposal = TestData.ActiveProposal(isActive: false);
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(inactiveProposal);
await db.SaveChangesAsync();
}
var result = await harness.Sut.CreateTradeFromProposalAsync(user, inactiveProposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.Null(result);
}
[Fact]
public async Task CreateTradeFromProposalAsync_UnknownProposalId_ReturnsNull()
{
using var harness = new TradeLifecycleServiceHarness();
var user = Guid.NewGuid();
var result = await harness.Sut.CreateTradeFromProposalAsync(user, Guid.NewGuid(), ExecutionMode.ManualTradeRepublic);
Assert.Null(result);
}
[Fact]
public async Task AcceptProposalAsync_WrapsCreateTradeFromProposal_AndAlwaysUsesManualTradeRepublicMode()
{
using var harness = new TradeLifecycleServiceHarness();
var proposal = TestData.ActiveProposal();
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(proposal);
await db.SaveChangesAsync();
}
var dto = await harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, proposal.Id));
Assert.Equal(ExecutionMode.ManualTradeRepublic, dto.ExecutionMode);
}
[Fact]
public async Task AcceptProposalAsync_ThrowsForExpiredProposal()
{
using var harness = new TradeLifecycleServiceHarness();
var expiredProposal = TestData.ActiveProposal(expiresAtUtc: DateTime.UtcNow.AddMinutes(-1));
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(expiredProposal);
await db.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, expiredProposal.Id)));
}
}