Files
Finlytic/FinlyticEngine.Tests/Services/Trading/TradeLifecycleServiceTests.cs
T

307 lines
12 KiB
C#

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)));
}
}