Files
PolyTraderSharp/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs
T
RichardandClaude Opus 5 b70d5e5f00 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>
2026-09-13 13:38:19 +02:00

164 lines
7.2 KiB
C#

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