diff --git a/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs b/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs index 53a0a89..f8ed8c8 100644 --- a/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs +++ b/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs @@ -44,47 +44,59 @@ namespace PolyTrader.Web.Hosting kestrel.Listen(ip, options.Port); }); - webBuilder.Configure((context, app) => - { - app.UseDefaultFiles(); - app.UseStaticFiles(); - - app.UseMiddleware(); - - app.UseRouting(); - - app.UseEndpoints(endpoints => - { - endpoints.MapAuthEndpoints(); - endpoints.MapDashboardEndpoints(); - endpoints.MapAccountsEndpoints(); - endpoints.MapJobsEndpoints(); - endpoints.MapLogsEndpoints(); - endpoints.MapSettingsEndpoints(); - endpoints.MapModulesEndpoints(); - endpoints.MapAnalyticsEndpoints(); - endpoints.MapCopyTradingEndpoints(); - endpoints.MapResolutionFarmingEndpoints(); - endpoints.MapSupervisorEndpoints(); - endpoints.MapAccountingEndpoints(); - - endpoints.MapGet("/login", async httpContext => - { - var env = httpContext.RequestServices.GetRequiredService(); - string file = Path.Combine(env.WebRootPath, "login.html"); - if (!File.Exists(file)) - file = Path.Combine(AppContext.BaseDirectory, "wwwroot", "login.html"); - - httpContext.Response.ContentType = "text/html; charset=utf-8"; - await httpContext.Response.SendFileAsync(file); - }); - - endpoints.MapFallbackToFile("index.html"); - }); - }); + webBuilder.Configure((context, app) => app.UsePolyTraderWebPipeline()); }); return hostBuilder; } + + /// + /// Die komplette Anfragekette des WebUI: statische Dateien, Token-Pruefung, Routen. + /// + /// Liegt bewusst ausserhalb von , damit der Testhost + /// genau diese Kette benutzt und nicht eine nachgebaute (#568). Ein Test, der die Routen + /// selbst zusammensteckt, prueft im Zweifel eine Reihenfolge, die es im Betrieb nicht gibt - + /// etwa eine Route vor der Token-Pruefung. + /// + public static IApplicationBuilder UsePolyTraderWebPipeline(this IApplicationBuilder app) + { + app.UseDefaultFiles(); + app.UseStaticFiles(); + + app.UseMiddleware(); + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapAuthEndpoints(); + endpoints.MapDashboardEndpoints(); + endpoints.MapAccountsEndpoints(); + endpoints.MapJobsEndpoints(); + endpoints.MapLogsEndpoints(); + endpoints.MapSettingsEndpoints(); + endpoints.MapModulesEndpoints(); + endpoints.MapAnalyticsEndpoints(); + endpoints.MapCopyTradingEndpoints(); + endpoints.MapResolutionFarmingEndpoints(); + endpoints.MapSupervisorEndpoints(); + endpoints.MapAccountingEndpoints(); + + endpoints.MapGet("/login", async httpContext => + { + var env = httpContext.RequestServices.GetRequiredService(); + string file = Path.Combine(env.WebRootPath, "login.html"); + if (!File.Exists(file)) + file = Path.Combine(AppContext.BaseDirectory, "wwwroot", "login.html"); + + httpContext.Response.ContentType = "text/html; charset=utf-8"; + await httpContext.Response.SendFileAsync(file); + }); + + endpoints.MapFallbackToFile("index.html"); + }); + + return app; + } } } diff --git a/tests/PolyTrader.Tests/PolyTrader.Tests.csproj b/tests/PolyTrader.Tests/PolyTrader.Tests.csproj index 0141b50..5aa75a6 100644 --- a/tests/PolyTrader.Tests/PolyTrader.Tests.csproj +++ b/tests/PolyTrader.Tests/PolyTrader.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/PolyTrader.Tests/WebUi/CopyTradingAndResolutionFarmingEndpointsTests.cs b/tests/PolyTrader.Tests/WebUi/CopyTradingAndResolutionFarmingEndpointsTests.cs index bfbe470..5d9628d 100644 --- a/tests/PolyTrader.Tests/WebUi/CopyTradingAndResolutionFarmingEndpointsTests.cs +++ b/tests/PolyTrader.Tests/WebUi/CopyTradingAndResolutionFarmingEndpointsTests.cs @@ -1,304 +1,211 @@ using System; -using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using PolyTrader.Modules.CopyTrading.Persistence; using PolyTrader.Modules.ResolutionFarming.Models; -using PolyTrader.Web.Endpoints; +using PolyTrader.Modules.ResolutionFarming.Persistence; using PolyTraderSharp.Models; using Xunit; namespace PolyTrader.Tests.WebUi { + /// + /// CopyTrading- und ResolutionFarming-Endpunkte ueber echte HTTP-Aufrufe (#568). + /// + /// Die Datei gleichen Namens enthielt vorher acht Tests, die keine einzige Zeile Produktionscode + /// aufriefen: Sie summierten Listen im Test und verglichen das Ergebnis mit sich selbst. Die + /// Absichten sind dieselben geblieben, geprueft wird jetzt die Antwort der Route. + /// public class CopyTradingAndResolutionFarmingEndpointsTests { - [Fact] - public void CopyTrading_TrackedTraderInput_MapsCorrectly() - { - var input = new CopyTradingEndpoints.TrackedTraderInput( - Id: 0, - DisplayName: "Whale Alpha", - WalletAddress: "0x1234567890abcdef1234567890abcdef12345678", - Category: "INSIDER", - Description: "Top Polymarket Trader", - Reasoning: "Consistent winrate above 70%", - IsActive: true, - IsHidden: false, - AutoPauseEnabled: true, - MakerEntry: true, - AssignedAccountIds: new List { 1, 2 } - ); + private static Task StartCopyTradingAsync() => + WebUiTestHost.StartAsync(WebUiTestHost.AddCopyTradingStore); - int id = input.Id <= 0 ? 1 : input.Id; - var trader = new TrackedTrader + private static Task StartResolutionFarmingAsync() => + WebUiTestHost.StartAsync(WebUiTestHost.AddResolutionFarmingStore); + + // ---- CopyTrading ----------------------------------------------------- + + [Fact] + public async Task CopyTrading_Trader_anlegen_und_wieder_lesen() + { + await using var web = await StartCopyTradingAsync(); + + var (status, created) = await web.PostJsonAsync("/api/modules/copytrading/traders", new { - Id = id, - DisplayName = input.DisplayName ?? $"Trader #{id}", - WalletAddress = input.WalletAddress?.Trim() ?? string.Empty, - Category = string.IsNullOrWhiteSpace(input.Category) ? "NEW_BIG_BET" : input.Category.Trim(), - Description = input.Description ?? string.Empty, - Reasoning = input.Reasoning ?? string.Empty, - IsActive = input.IsActive, - IsHidden = input.IsHidden, - AutoPauseEnabled = input.AutoPauseEnabled, - MakerEntry = input.MakerEntry, - AssignedAccountIds = new HashSet(input.AssignedAccountIds ?? new List()) - }; + id = 0, + displayName = "Whale Alpha", + walletAddress = " 0x1234567890abcdef1234567890abcdef12345678 ", + category = " ", + description = "Top Polymarket Trader", + reasoning = "Winrate ueber 70 Prozent", + isActive = true, + isHidden = false, + autoPauseEnabled = true, + makerEntry = true, + assignedAccountIds = new[] { 1, 2 } + }); + Assert.Equal(200, status); - Assert.Equal(1, trader.Id); - Assert.Equal("Whale Alpha", trader.DisplayName); - Assert.Equal("INSIDER", trader.Category); - Assert.True(trader.IsActive); - Assert.True(trader.MakerEntry); - Assert.Equal(2, trader.AssignedAccountIds.Count); - Assert.Contains(1, trader.AssignedAccountIds); - Assert.Contains(2, trader.AssignedAccountIds); + var trader = created.GetProperty("trader"); + Assert.Equal(1, trader.GetProperty("id").GetInt32()); + Assert.Equal("NEW_BIG_BET", trader.GetProperty("category").GetString()); // leere Kategorie -> Vorgabe + Assert.Equal("0x1234567890abcdef1234567890abcdef12345678", trader.GetProperty("walletAddress").GetString()); + + var all = await web.GetJsonAsync("/api/modules/copytrading/traders"); + var stored = Assert.Single(all.EnumerateArray()); + Assert.Equal("Whale Alpha", stored.GetProperty("displayName").GetString()); + Assert.True(stored.GetProperty("makerEntry").GetBoolean()); + Assert.Equal(new[] { 1, 2 }, + stored.GetProperty("assignedAccountIds").EnumerateArray().Select(e => e.GetInt32()).OrderBy(x => x).ToArray()); } [Fact] - public void CopyTrading_OpenTrades_CalculatesUnrealizedMetricsProperly() + public async Task CopyTrading_offene_Trades_rechnen_Buchgewinn_je_Position_und_gesamt() { - var positions = new List + await using var web = await StartCopyTradingAsync(); + + var acc1 = new AccountState { AccountId = 1, Name = "Konto 1" }; + acc1.OpenPositions["tok-1"] = new Position { - new Position - { - AccountId = 1, - SourceTraderId = 1, - SourceTraderName = "Whale 1", - MarketQuestion = "Will Trump win in 2024?", - Outcome = "Yes", - Side = "BUY", - EntryPrice = 0.50m, - CurrentPrice = 0.70m, - Size = 100m, - AmountUsd = 50m, - CurrentValueUsd = 70m - }, - new Position - { - AccountId = 2, - SourceTraderId = 2, - SourceTraderName = "Whale 2", - MarketQuestion = "Will Fed cut rates?", - Outcome = "Yes", - Side = "BUY", - EntryPrice = 0.80m, - CurrentPrice = 0.60m, - Size = 50m, - AmountUsd = 40m, - CurrentValueUsd = 30m - } + AccountId = 1, TokenId = "tok-1", SourceTraderId = 1, SourceTraderName = "Whale 1", + MarketQuestion = "Will Trump win in 2024?", Outcome = "Yes", + EntryPrice = 0.50m, CurrentPrice = 0.70m, Size = 100m, AmountUsd = 50m, CurrentValueUsd = 70m }; - - decimal totalAmount = positions.Sum(p => p.AmountUsd); - decimal totalUnrealized = positions.Sum(p => p.CurrentValueUsd - p.AmountUsd); - - Assert.Equal(90m, totalAmount); - Assert.Equal(10m, totalUnrealized); // (70-50) + (30-40) = 20 - 10 = 10 - - var p1 = positions[0]; - decimal u1 = p1.CurrentValueUsd - p1.AmountUsd; - decimal pct1 = p1.AmountUsd > 0 ? 100m * u1 / p1.AmountUsd : 0m; - Assert.Equal(20m, u1); - Assert.Equal(40m, pct1); - - var p2 = positions[1]; - decimal u2 = p2.CurrentValueUsd - p2.AmountUsd; - decimal pct2 = p2.AmountUsd > 0 ? 100m * u2 / p2.AmountUsd : 0m; - Assert.Equal(-10m, u2); - Assert.Equal(-25m, pct2); - } - - [Fact] - public void CopyTrading_ClosedTrades_FiltersCorrectly() - { - var closed = new List + var acc2 = new AccountState { AccountId = 2, Name = "Konto 2" }; + acc2.OpenPositions["tok-2"] = new Position { - new ClosedTrade - { - TradeId = 1, - AccountId = 1, - SourceTraderId = 10, - MarketQuestion = "Bitcoin reaches 100k?", - Outcome = "Yes", - RealizedPnl = 35m, - ClosedAt = DateTime.UtcNow.AddHours(-2) - }, - new ClosedTrade - { - TradeId = 2, - AccountId = 1, - SourceTraderId = 10, - MarketQuestion = "Solana reaches 300?", - Outcome = "No", - RealizedPnl = -12m, - ClosedAt = DateTime.UtcNow.AddHours(-5) - }, - new ClosedTrade - { - TradeId = 3, - AccountId = 2, - SourceTraderId = 20, - MarketQuestion = "Ethereum reaches 5k?", - Outcome = "Yes", - RealizedPnl = 50m, - ClosedAt = DateTime.UtcNow.AddHours(-1) - } + AccountId = 2, TokenId = "tok-2", SourceTraderId = 2, SourceTraderName = "Whale 2", + MarketQuestion = "Will Fed cut rates?", Outcome = "Yes", + EntryPrice = 0.80m, CurrentPrice = 0.60m, Size = 50m, AmountUsd = 40m, CurrentValueUsd = 30m }; + web.State.Accounts[1] = acc1; + web.State.Accounts[2] = acc2; - // Filter by Trader - var trader10Trades = closed.Where(t => t.SourceTraderId == 10).ToList(); - Assert.Equal(2, trader10Trades.Count); + var open = await web.GetJsonAsync("/api/modules/copytrading/open-trades"); - // Filter by Result - var winners = closed.Where(t => t.RealizedPnl > 0).ToList(); - Assert.Equal(2, winners.Count); - decimal totalWinPnl = winners.Sum(w => w.RealizedPnl); - Assert.Equal(85m, totalWinPnl); + Assert.Equal(2, open.GetProperty("count").GetInt32()); + Assert.Equal(90m, open.GetProperty("totalAmount").GetDecimal()); + Assert.Equal(10m, open.GetProperty("totalUnrealized").GetDecimal()); // (70-50) + (30-40) - // Search Filter - string search = "Bitcoin"; - var searchResults = closed.Where(t => t.MarketQuestion.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); - var matched = Assert.Single(searchResults); - Assert.Equal(1, matched.TradeId); + var byMarket = open.GetProperty("trades").EnumerateArray() + .ToDictionary(t => t.GetProperty("market").GetString()!); + Assert.Equal(20m, byMarket["Will Trump win in 2024?"].GetProperty("unrealizedPnl").GetDecimal()); + Assert.Equal(40m, byMarket["Will Trump win in 2024?"].GetProperty("unrealizedPct").GetDecimal()); + Assert.Equal(-10m, byMarket["Will Fed cut rates?"].GetProperty("unrealizedPnl").GetDecimal()); + Assert.Equal(-25m, byMarket["Will Fed cut rates?"].GetProperty("unrealizedPct").GetDecimal()); } [Fact] - public void CopyTrading_AccountSettings_DefaultValuesAreConsistent() + public async Task CopyTrading_geschlossene_Trades_filtern_nach_Trader_Ergebnis_und_Suche() { - var settings = new CopyTradingAccountSettings { AccountId = 42 }; + await using var web = await StartCopyTradingAsync(); + var log = web.Get(); + log.Insert(new ClosedTrade { TradeId = 1, AccountId = 1, SourceTraderId = 10, TokenId = "a", + MarketQuestion = "Bitcoin reaches 100k?", Outcome = "Yes", RealizedPnl = 35m, ClosedAt = DateTime.UtcNow.AddHours(-2) }); + log.Insert(new ClosedTrade { TradeId = 2, AccountId = 1, SourceTraderId = 10, TokenId = "b", + MarketQuestion = "Solana reaches 300?", Outcome = "No", RealizedPnl = -12m, ClosedAt = DateTime.UtcNow.AddHours(-5) }); + log.Insert(new ClosedTrade { TradeId = 3, AccountId = 2, SourceTraderId = 20, TokenId = "c", + MarketQuestion = "Ethereum reaches 5k?", Outcome = "Yes", RealizedPnl = 50m, ClosedAt = DateTime.UtcNow.AddHours(-1) }); - Assert.Equal(42, settings.AccountId); - Assert.Equal(5.0m, settings.PerMarketLimit); - Assert.Equal(10.0m, settings.PerMasterLimit); - Assert.Equal(0.98m, settings.MaxBuyPrice); - Assert.Equal(2.0m, settings.MaxPriceDifference); - Assert.Equal(15.0m, settings.SellFloorPct); + var byTrader = await web.GetJsonAsync("/api/modules/copytrading/closed-trades?traderId=10"); + Assert.Equal(2, byTrader.GetProperty("totalCount").GetInt32()); + + var winners = await web.GetJsonAsync("/api/modules/copytrading/closed-trades?result=Gewinner"); + Assert.Equal(2, winners.GetProperty("totalCount").GetInt32()); + Assert.Equal(85m, winners.GetProperty("summary").GetProperty("totalPnl").GetDecimal()); + + var search = await web.GetJsonAsync("/api/modules/copytrading/closed-trades?search=bitcoin"); + var hit = Assert.Single(search.GetProperty("items").EnumerateArray()); + Assert.Equal(1, hit.GetProperty("tradeId").GetInt32()); } [Fact] - public void ResolutionFarming_Candidates_MappingAndEdgeFiltering() + public async Task CopyTrading_Kontoeinstellungen_ohne_Eintrag_liefern_die_Vorgaben() { - var candidates = new List - { - new RfCandidate - { - Id = 1, - AccountId = 1, - MarketQuestion = "Will candidate A win election?", - Outcome = "Yes", - Category = "Politics", - Ask = 0.94m, - NetEdgePct = 2.5m, - Accepted = true, - RejectReason = "" - }, - new RfCandidate - { - Id = 2, - AccountId = 1, - MarketQuestion = "Will crypto token reach 100?", - Outcome = "Yes", - Category = "Crypto", - Ask = 0.91m, - NetEdgePct = 0.5m, - Accepted = false, - RejectReason = "Fee too high for edge" - } - }; + await using var web = await StartCopyTradingAsync(); - var acceptedOnly = candidates.Where(c => c.Accepted).ToList(); - var accepted = Assert.Single(acceptedOnly); - Assert.Equal("Politics", accepted.Category); - Assert.True(accepted.NetEdgePct >= 1.5m); + var settings = await web.GetJsonAsync("/api/modules/copytrading/settings/42"); - var rejected = candidates.First(c => !c.Accepted); - Assert.Equal("Fee too high for edge", rejected.RejectReason); + Assert.Equal(42, settings.GetProperty("accountId").GetInt32()); + Assert.Equal(5.0m, settings.GetProperty("perMarketLimit").GetDecimal()); + Assert.Equal(10.0m, settings.GetProperty("perMasterLimit").GetDecimal()); + Assert.Equal(0.98m, settings.GetProperty("maxBuyPrice").GetDecimal()); + Assert.Equal(15.0m, settings.GetProperty("sellFloorPct").GetDecimal()); + } + + // ---- ResolutionFarming ----------------------------------------------- + + [Fact] + public async Task ResolutionFarming_Kandidaten_liefern_Annahme_und_Ablehnungsgrund() + { + await using var web = await StartResolutionFarmingAsync(); + web.State.Accounts[1] = new AccountState { AccountId = 1, Name = "Demo", IsDemo = true }; + var repo = web.Get(); + repo.Insert(new RfCandidate { AccountId = 1, TokenId = "t1", ScannedAt = DateTime.UtcNow, + MarketQuestion = "Will candidate A win election?", Outcome = "Yes", Category = "Politics", + Ask = 0.94m, NetEdgePct = 2.5m, Accepted = true, RejectReason = "" }); + repo.Insert(new RfCandidate { AccountId = 1, TokenId = "t2", ScannedAt = DateTime.UtcNow, + MarketQuestion = "Will crypto token reach 100?", Outcome = "Yes", Category = "Crypto", + Ask = 0.91m, NetEdgePct = 0.5m, Accepted = false, RejectReason = "Fee too high for edge" }); + + var result = await web.GetJsonAsync("/api/modules/resolutionfarming/candidates?accountId=1"); + + Assert.Equal(2, result.GetProperty("count").GetInt32()); + var items = result.GetProperty("candidates").EnumerateArray().ToList(); + var accepted = Assert.Single(items, c => c.GetProperty("accepted").GetBoolean()); + Assert.Equal("Politics", accepted.GetProperty("category").GetString()); + var rejected = Assert.Single(items, c => !c.GetProperty("accepted").GetBoolean()); + Assert.Equal("Fee too high for edge", rejected.GetProperty("rejectReason").GetString()); } [Fact] - public void ResolutionFarming_Positions_SummaryMetrics() + public async Task ResolutionFarming_Positionen_summieren_den_Einsatz() { - var positions = new List - { - new RfPosition - { - AccountId = 1, - TokenId = "tok-1", - MarketQuestion = "Match 1", - EntryPrice = 0.95m, - Size = 100m, - AmountUsd = 95m, - Status = "Open" - }, - new RfPosition - { - AccountId = 1, - TokenId = "tok-2", - MarketQuestion = "Match 2", - EntryPrice = 0.92m, - Size = 50m, - AmountUsd = 46m, - Status = "Open" - } - }; + await using var web = await StartResolutionFarmingAsync(); + var repo = web.Get(); + repo.Upsert(new RfPosition { AccountId = 1, TokenId = "tok-1", MarketQuestion = "Match 1", + EntryPrice = 0.95m, Size = 100m, AmountUsd = 95m, Status = "Open", OpenedAt = DateTime.UtcNow }); + repo.Upsert(new RfPosition { AccountId = 1, TokenId = "tok-2", MarketQuestion = "Match 2", + EntryPrice = 0.92m, Size = 50m, AmountUsd = 46m, Status = "Open", OpenedAt = DateTime.UtcNow }); - decimal totalInvested = positions.Sum(p => p.AmountUsd); - Assert.Equal(141m, totalInvested); - Assert.Equal(2, positions.Count); + var result = await web.GetJsonAsync("/api/modules/resolutionfarming/positions?accountId=1"); + + Assert.Equal(2, result.GetProperty("count").GetInt32()); + Assert.Equal(141m, result.GetProperty("totalAmount").GetDecimal()); } [Fact] - public void ResolutionFarming_History_CalculatesPnlAndWinRate() + public async Task ResolutionFarming_Historie_rechnet_PnL_und_Trefferquote() { - var history = new List - { - new RfClosedTrade - { - Id = 101, - AccountId = 1, - MarketQuestion = "Match A", - RealizedPnl = 4.50m, - RedeemStatus = "Redeemed" - }, - new RfClosedTrade - { - Id = 102, - AccountId = 1, - MarketQuestion = "Match B", - RealizedPnl = 3.80m, - RedeemStatus = "Redeemed" - }, - new RfClosedTrade - { - Id = 103, - AccountId = 1, - MarketQuestion = "Match C", - RealizedPnl = -20.00m, - RedeemStatus = "None" - } - }; + await using var web = await StartResolutionFarmingAsync(); + var repo = web.Get(); + repo.Insert(new RfClosedTrade { AccountId = 1, TokenId = "a", MarketQuestion = "Match A", RealizedPnl = 4.50m, RedeemStatus = "Redeemed", ClosedAt = DateTime.UtcNow }); + repo.Insert(new RfClosedTrade { AccountId = 1, TokenId = "b", MarketQuestion = "Match B", RealizedPnl = 3.80m, RedeemStatus = "Redeemed", ClosedAt = DateTime.UtcNow }); + repo.Insert(new RfClosedTrade { AccountId = 1, TokenId = "c", MarketQuestion = "Match C", RealizedPnl = -20.00m, RedeemStatus = "None", ClosedAt = DateTime.UtcNow }); - decimal totalPnl = history.Sum(t => t.RealizedPnl); - int wins = history.Count(t => t.RealizedPnl > 0); - decimal winrate = 100m * wins / history.Count; + var result = await web.GetJsonAsync("/api/modules/resolutionfarming/history?accountId=1"); + var summary = result.GetProperty("summary"); - Assert.Equal(-11.70m, totalPnl); - Assert.Equal(2, wins); - Assert.Equal(66.7m, Math.Round(winrate, 1)); + Assert.Equal(3, summary.GetProperty("count").GetInt32()); + Assert.Equal(-11.70m, summary.GetProperty("pnl").GetDecimal()); + Assert.Equal(2, summary.GetProperty("wins").GetInt32()); + Assert.Equal(66.7m, Math.Round(summary.GetProperty("winRate").GetDecimal(), 1)); } [Fact] - public void ResolutionFarming_Settings_DefaultValuesValid() + public async Task ResolutionFarming_Einstellungen_ohne_Eintrag_liefern_die_Vorgaben() { - var settings = new RfSettings { AccountId = 5 }; + await using var web = await StartResolutionFarmingAsync(); - Assert.Equal(5, settings.AccountId); - Assert.False(settings.Enabled); - Assert.Equal(0.90m, settings.MinPrice); - Assert.Equal(0.98m, settings.MaxPrice); - Assert.Equal(48, settings.MaxHoursToResolution); - Assert.Equal(1.5m, settings.MinEdgePct); - Assert.Equal(25m, settings.MaxPerMarketUsd); - Assert.Contains("Sports", settings.CategoryWhitelistCsv); + var settings = await web.GetJsonAsync("/api/modules/resolutionfarming/settings/5"); + + Assert.Equal(5, settings.GetProperty("accountId").GetInt32()); + Assert.False(settings.GetProperty("enabled").GetBoolean()); + Assert.Equal(0.90m, settings.GetProperty("minPrice").GetDecimal()); + Assert.Equal(0.98m, settings.GetProperty("maxPrice").GetDecimal()); + Assert.Equal(48, settings.GetProperty("maxHoursToResolution").GetInt32()); + Assert.Contains("Sports", settings.GetProperty("categoryWhitelistCsv").GetString()); } } } diff --git a/tests/PolyTrader.Tests/WebUi/ModulesAndSettingsEndpointsTests.cs b/tests/PolyTrader.Tests/WebUi/ModulesAndSettingsEndpointsTests.cs index f317a61..0e9754c 100644 --- a/tests/PolyTrader.Tests/WebUi/ModulesAndSettingsEndpointsTests.cs +++ b/tests/PolyTrader.Tests/WebUi/ModulesAndSettingsEndpointsTests.cs @@ -1,64 +1,130 @@ using System; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Core.Modularity; using PolyTrader.Web.Configuration; -using PolyTrader.Web.Endpoints; +using PolyTrader.Web.Security; +using PolyTraderSharp.Models; using PolyTraderSharp.Services; using Xunit; namespace PolyTrader.Tests.WebUi { + /// + /// Zugangssperre, Module, Jobs und Supervisor ueber echte HTTP-Aufrufe (#568). + /// Ersetzt zwei Tests, die nicht fehlschlagen konnten: einer pruefte, ob ein Array seine eigenen + /// Elemente enthaelt, der andere die Portbegrenzung an einer im Test nachgebauten Lambda. + /// public class ModulesAndSettingsEndpointsTests { - [Fact] - public void Modules_KnownModules_IncludesAllCoreStrategyAndAnalysisModules() + private sealed class FakeModule : IPolyTraderModule { - var expected = new[] - { - "CopyTrading", - "ResolutionFarming", - "Supervisor", - "Accounting" - }; + public FakeModule(string name) => Name = name; + public string Name { get; } + public string DbPrefix => "fake_"; + public void RegisterServices(IServiceCollection services, IConfiguration configuration) { } + public void RegisterUi(IModuleUiHost host, IServiceProvider services) { } + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } - foreach (var mod in expected) - { - Assert.Contains(mod, expected); - } + // ---- Zugangssperre --------------------------------------------------- + + [Fact] + public async Task Api_ohne_Token_wird_abgewiesen() + { + await using var web = await WebUiTestHost.StartAsync(); + + using var res = await web.Anonymous.GetAsync("/api/modules"); + + Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); } [Fact] - public void Settings_UpdateRequest_ValidationAndPortClamping() + public async Task Api_mit_falschem_Token_wird_abgewiesen() { - var validReq = new SettingsEndpoints.UpdateWebUiSettingsRequest( - Enabled: true, - Port: 8080, - LocalOnly: false, - AuthToken: " custom_token_123 " - ); + await using var web = await WebUiTestHost.StartAsync(); - Assert.True(validReq.Enabled); - Assert.Equal(8080, validReq.Port); - Assert.False(validReq.LocalOnly); - Assert.Equal("custom_token_123", validReq.AuthToken?.Trim()); + using var req = new HttpRequestMessage(HttpMethod.Get, "/api/modules"); + req.Headers.Add(TokenAuthMiddleware.TokenHeaderName, "falsch"); + using var res = await web.Anonymous.SendAsync(req); - // Port boundary handling - int clampPort(int p) => p > 0 && p <= 65535 ? p : 6001; - - Assert.Equal(8080, clampPort(validReq.Port)); - Assert.Equal(6001, clampPort(0)); - Assert.Equal(6001, clampPort(-5)); - Assert.Equal(6001, clampPort(70000)); - Assert.Equal(65535, clampPort(65535)); + Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); } + [Fact] + public async Task Ohne_konfiguriertes_Token_ist_alles_gesperrt() + { + await using var web = await WebUiTestHost.StartAsync(configureOptions: o => o.AuthToken = ""); + + using var res = await web.Client.GetAsync("/api/modules"); + + Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); + } + + // ---- Module ---------------------------------------------------------- + + [Fact] + public async Task Module_zeigt_alle_bekannten_und_markiert_die_geladenen() + { + await using var web = await WebUiTestHost.StartAsync(s => + s.AddSingleton(new FakeModule("CopyTrading"))); + + var modules = await web.GetJsonAsync("/api/modules"); + + var byName = modules.EnumerateArray().ToDictionary( + m => m.GetProperty("name").GetString()!, + m => m.GetProperty("isActive").GetBoolean()); + + Assert.Equal(new[] { "Accounting", "CopyTrading", "ResolutionFarming", "Supervisor" }, + byName.Keys.OrderBy(k => k).ToArray()); + Assert.True(byName["CopyTrading"]); + Assert.False(byName["Supervisor"]); + } + + // ---- Jobs ------------------------------------------------------------ + + [Fact] + public async Task Job_umschalten_aendert_den_Zustand_und_unbekannter_Job_liefert_404() + { + await using var web = await WebUiTestHost.StartAsync(); + var row = new JobStatusRow { JobName = "Trader Analytics", IsEnabled = true }; + web.Jobs.Jobs.Add(row); + + var (status, body) = await web.PostJsonAsync("/api/jobs/Trader%20Analytics/toggle"); + Assert.Equal(200, status); + Assert.False(body.GetProperty("isEnabled").GetBoolean()); + Assert.False(row.IsEnabled); + + var (unknown, _) = await web.PostJsonAsync("/api/jobs/GibtEsNicht/toggle"); + Assert.Equal(404, unknown); + } + + // ---- Supervisor ------------------------------------------------------ + + [Fact] + public async Task Supervisor_lehnt_leere_Frage_ab_bevor_ein_Modell_gefragt_wird() + { + await using var web = await WebUiTestHost.StartAsync(); + + var (status, _) = await web.PostJsonAsync("/api/modules/supervisor/ask", new { question = " " }); + + Assert.Equal(400, status); + } + + // ---- Hilfsfunktionen, die schon echten Code pruefen ------------------ + [Fact] public void Settings_GenerateSecureToken_ProducesCryptographicallyStrongString() { string token1 = WebUiConfigManager.GenerateSecureToken(); string token2 = WebUiConfigManager.GenerateSecureToken(); - Assert.NotNull(token1); - Assert.NotNull(token2); Assert.NotEmpty(token1); Assert.NotEqual(token1, token2); Assert.True(token1.Length >= 32); @@ -71,11 +137,8 @@ namespace PolyTrader.Tests.WebUi logger.Info("Testnachricht 1 vor 5 Minuten"); logger.Warning("Testnachricht 2"); - var history = logger.GetHistory(TimeSpan.FromMinutes(10)); - Assert.NotNull(history); - Assert.True(history.Count >= 2); + var recentMsgs = logger.GetHistory(TimeSpan.FromMinutes(10)).Select(h => h.Message).ToList(); - var recentMsgs = history.Select(h => h.Message).ToList(); Assert.Contains("Testnachricht 1 vor 5 Minuten", recentMsgs); Assert.Contains("Testnachricht 2", recentMsgs); } diff --git a/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs b/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs new file mode 100644 index 0000000..7b7080c --- /dev/null +++ b/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs @@ -0,0 +1,163 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PolyTrader.Modules.CopyTrading.Persistence; +using PolyTrader.Modules.CopyTrading.Persistence.Ef; +using PolyTrader.Modules.ResolutionFarming.Persistence; +using PolyTrader.Modules.ResolutionFarming.Persistence.Ef; +using PolyTrader.Tests.TestSupport; +using PolyTrader.Web.Configuration; +using PolyTrader.Web.Hosting; +using PolyTrader.Web.Security; +using PolyTraderSharp; +using PolyTraderSharp.Services; + +namespace PolyTrader.Tests.WebUi +{ + /// + /// Startet das WebUI in einem Testserver und spricht es ueber echtes HTTP an (#568). + /// + /// Die Anfragekette kommt aus , + /// also exakt aus dem Betrieb: statische Dateien, Token-Pruefung, Routen. Ersetzt wird nur, was + /// ausserhalb des Prozesses liegt - Kestrel durch den Testserver, MySQL durch den + /// InMemory-Provider. + /// + /// Vorher rief keiner der WebUI-Tests eine Route auf. Sie bauten die Logik der Endpunkte im + /// Test nach und prueften den Nachbau - ein solcher Test bleibt gruen, egal was der Endpunkt tut. + /// + internal sealed class WebUiTestHost : IAsyncDisposable + { + public const string Token = "webui-test-token-7f3a9c1e5b2d4f6a8c0e"; + + private readonly IHost _host; + + public TradingState State { get; } + public CopyTradingState CopyState { get; } + public TerminalLogger Logger { get; } + public JobManager Jobs { get; } + public WebUiOptions WebOptions { get; } + + /// Mit gueltigem Token. + public HttpClient Client { get; } + + /// Ohne Token - fuer die Pruefung der Zugangssperre. + public HttpClient Anonymous { get; } + + private WebUiTestHost(IHost host, TradingState state, CopyTradingState copyState, + TerminalLogger logger, JobManager jobs, WebUiOptions options) + { + _host = host; + State = state; + CopyState = copyState; + Logger = logger; + Jobs = jobs; + WebOptions = options; + + var server = host.GetTestServer(); + Client = server.CreateClient(); + Client.DefaultRequestHeaders.Add(TokenAuthMiddleware.TokenHeaderName, Token); + Anonymous = server.CreateClient(); + } + + /// + /// Startet den Host. Die Typen, die Endpunkte direkt als Parameter nehmen (TradingState, + /// CopyTradingState, TerminalLogger, JobManager), sind immer registriert: Minimal APIs werten + /// einen nicht registrierten Parametertyp sonst als Anfragekoerper, und dann scheitert jede + /// GET-Route schon beim Aufbau. + /// + public static async Task StartAsync( + Action? configureServices = null, + Action? configureOptions = null) + { + var state = new TradingState(); + var copyState = new CopyTradingState(); + var logger = new TerminalLogger(); + var jobs = new JobManager(); + var options = new WebUiOptions { Enabled = true, AuthToken = Token }; + configureOptions?.Invoke(options); + + var host = await new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(Microsoft.Extensions.Options.Options.Create(options)); + services.AddSingleton(state); + services.AddSingleton(copyState); + services.AddSingleton(logger); + services.AddSingleton(jobs); + configureServices?.Invoke(services); + }); + web.Configure(app => app.UsePolyTraderWebPipeline()); + }) + .StartAsync(); + + return new WebUiTestHost(host, state, copyState, logger, jobs, options); + } + + /// CopyTrading-Repositories auf einer eigenen InMemory-Datenbank. + public static void AddCopyTradingStore(IServiceCollection services) + { + services.AddSingleton>( + new InMemoryContextFactory(o => new CopyTradingDbContext(o))); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + /// ResolutionFarming-Repositories auf einer eigenen InMemory-Datenbank. + public static void AddResolutionFarmingStore(IServiceCollection services) + { + services.AddSingleton>( + new InMemoryContextFactory(o => new ResolutionFarmingDbContext(o))); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + public T Get() where T : notnull => _host.Services.GetRequiredService(); + + /// GET mit Token; wirft mit Statuscode und Antworttext, wenn die Route scheitert. + public async Task GetJsonAsync(string path) + { + using var res = await Client.GetAsync(path); + string body = await res.Content.ReadAsStringAsync(); + if (!res.IsSuccessStatusCode) + throw new InvalidOperationException($"GET {path} -> {(int)res.StatusCode}: {body}"); + return Parse(body); + } + + /// POST mit Token; liefert Status und Antwort, ohne bei Fehlern zu werfen. + public async Task<(int Status, JsonElement Body)> PostJsonAsync(string path, object? payload = null) + { + using var content = new StringContent( + payload == null ? "{}" : JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using var res = await Client.PostAsync(path, content); + string body = await res.Content.ReadAsStringAsync(); + return ((int)res.StatusCode, Parse(body)); + } + + private static JsonElement Parse(string body) => + string.IsNullOrWhiteSpace(body) + ? default + : JsonDocument.Parse(body).RootElement.Clone(); + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + Anonymous.Dispose(); + await _host.StopAsync(); + _host.Dispose(); + } + } +}