feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user