307 lines
12 KiB
C#
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)));
|
|
}
|
|
}
|