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; /// /// Tenant-boundary tests for — 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) /// so the actual LINQ tenant-filter predicates run, not a hand-rolled substitute. /// 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( () => harness.Sut.AddTradeFillAsync(attacker, trade.Id, 105m, 1m)); var missingTradeId = Guid.NewGuid(); var exMissingTrade = await Assert.ThrowsAsync( () => 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( () => 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( () => 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( () => 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( () => harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, expiredProposal.Id))); } }