WebUI-Tests rufen jetzt echte Routen auf
Behebt #568. Keine der Testdateien mit "EndpointsTests" im Namen hat je einen Endpunkt aufgerufen. CopyTradingAndResolutionFarmingEndpointsTests beruehrte gar keinen Produktionscode: Die Tests summierten Listen im Test und verglichen das Ergebnis mit sich selbst. Modules_KnownModules pruefte, ob ein Array seine eigenen Elemente enthaelt. Settings_UpdateRequest pruefte die Portbegrenzung an einer im Test nachgebauten Lambda. Solche Tests bleiben gruen, egal was der Endpunkt tut. Neu startet WebUiTestHost das WebUI in einem Testserver (Microsoft.AspNetCore.TestHost) und spricht es ueber HTTP an. Damit der Test die Kette aus dem Betrieb prueft und keine nachgebaute, liegt sie jetzt in UsePolyTraderWebPipeline(): statische Dateien, Token-Pruefung, alle Routen. ConfigurePolyTraderWeb ruft genau diese Methode. Ersetzt wird nur, was ausserhalb des Prozesses liegt - Kestrel durch den Testserver, MySQL durch den InMemory-Provider. Die acht CopyTrading/ResolutionFarming-Tests pruefen dieselben Absichten wie vorher, jetzt an der Antwort der Route. Neu hinzugekommen sind Tests fuer: - Zugangssperre: ohne Token, mit falschem Token, ohne konfiguriertes Token - die Modulliste - Job-Umschalten samt 404 - die Ablehnung einer leeren Supervisor-Frage Die beiden Tests, die schon echten Code prueften, bleiben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<int> { 1, 2 }
|
||||
);
|
||||
private static Task<WebUiTestHost> StartCopyTradingAsync() =>
|
||||
WebUiTestHost.StartAsync(WebUiTestHost.AddCopyTradingStore);
|
||||
|
||||
int id = input.Id <= 0 ? 1 : input.Id;
|
||||
var trader = new TrackedTrader
|
||||
private static Task<WebUiTestHost> 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<int>(input.AssignedAccountIds ?? new List<int>())
|
||||
};
|
||||
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<Position>
|
||||
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<ClosedTrade>
|
||||
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<ICopyTradeLogRepository>();
|
||||
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<RfCandidate>
|
||||
{
|
||||
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<IRfCandidateRepository>();
|
||||
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<RfPosition>
|
||||
{
|
||||
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<IRfPositionRepository>();
|
||||
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<RfClosedTrade>
|
||||
{
|
||||
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<IRfClosedTradeRepository>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IPolyTraderModule>(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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Startet das WebUI in einem Testserver und spricht es ueber echtes HTTP an (#568).
|
||||
///
|
||||
/// <para>Die Anfragekette kommt aus <see cref="WebUiHostingExtensions.UsePolyTraderWebPipeline"/>,
|
||||
/// 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.</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Mit gueltigem Token.</summary>
|
||||
public HttpClient Client { get; }
|
||||
|
||||
/// <summary>Ohne Token - fuer die Pruefung der Zugangssperre.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static async Task<WebUiTestHost> StartAsync(
|
||||
Action<IServiceCollection>? configureServices = null,
|
||||
Action<WebUiOptions>? 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);
|
||||
}
|
||||
|
||||
/// <summary>CopyTrading-Repositories auf einer eigenen InMemory-Datenbank.</summary>
|
||||
public static void AddCopyTradingStore(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDbContextFactory<CopyTradingDbContext>>(
|
||||
new InMemoryContextFactory<CopyTradingDbContext>(o => new CopyTradingDbContext(o)));
|
||||
services.AddSingleton<ITrackedTraderRepository, EfTrackedTraderRepository>();
|
||||
services.AddSingleton<ICopyTradeLogRepository, EfCopyTradeLogRepository>();
|
||||
services.AddSingleton<ICopyTradingAccountSettingsRepository, EfCopyTradingAccountSettingsRepository>();
|
||||
}
|
||||
|
||||
/// <summary>ResolutionFarming-Repositories auf einer eigenen InMemory-Datenbank.</summary>
|
||||
public static void AddResolutionFarmingStore(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDbContextFactory<ResolutionFarmingDbContext>>(
|
||||
new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o)));
|
||||
services.AddSingleton<IRfSettingsRepository, EfRfSettingsRepository>();
|
||||
services.AddSingleton<IRfCandidateRepository, EfRfCandidateRepository>();
|
||||
services.AddSingleton<IRfPositionRepository, EfRfPositionRepository>();
|
||||
services.AddSingleton<IRfClosedTradeRepository, EfRfClosedTradeRepository>();
|
||||
}
|
||||
|
||||
public T Get<T>() where T : notnull => _host.Services.GetRequiredService<T>();
|
||||
|
||||
/// <summary>GET mit Token; wirft mit Statuscode und Antworttext, wenn die Route scheitert.</summary>
|
||||
public async Task<JsonElement> 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);
|
||||
}
|
||||
|
||||
/// <summary>POST mit Token; liefert Status und Antwort, ohne bei Fehlern zu werfen.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user