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