using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using PolyTrader.Web.Updates; using Xunit; namespace PolyTrader.Tests.WebUi { /// /// Update-Status im WebUI (#565): Zwischenspeicher, Mindestabstand für "Jetzt prüfen", Anzeige. /// public class UpdateStatusTests { private sealed class FakeChecker : IUpdateChecker { private readonly Func _now; public int Calls; public UpdateCheckSnapshot Result { get; set; } public FakeChecker(Func now) { _now = now; Result = new UpdateCheckSnapshot(DateTime.MinValue, true, true, false, "0.2.0", "Neu: Log-Viewer", true, null); } public async Task CheckAsync(CancellationToken ct) { Interlocked.Increment(ref Calls); await Task.Delay(20, ct); return Result with { CheckedAtUtc = _now() }; } } private sealed class Clock { public DateTime Now = new(2026, 9, 13, 12, 0, 0, DateTimeKind.Utc); } [Fact] public async Task Innerhalb_der_Haltezeit_wird_nicht_erneut_gefragt() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); var service = new UpdateStatusService(checker, () => clock.Now); var first = await service.GetAsync(force: false, CancellationToken.None); clock.Now = clock.Now.AddMinutes(29); var second = await service.GetAsync(force: false, CancellationToken.None); Assert.False(first.FromCache); Assert.True(second.FromCache); Assert.Equal(1, checker.Calls); } [Fact] public async Task Nach_Ablauf_der_Haltezeit_wird_frisch_gefragt() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); var service = new UpdateStatusService(checker, () => clock.Now); await service.GetAsync(false, CancellationToken.None); clock.Now = clock.Now + UpdateStatusService.CacheDuration; var again = await service.GetAsync(false, CancellationToken.None); Assert.False(again.FromCache); Assert.Equal(2, checker.Calls); } [Fact] public async Task Jetzt_pruefen_zu_frueh_liefert_den_vorigen_Stand_mit_Wartezeit() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); var service = new UpdateStatusService(checker, () => clock.Now); await service.GetAsync(false, CancellationToken.None); clock.Now = clock.Now.AddSeconds(20); var tooEarly = await service.GetAsync(force: true, CancellationToken.None); Assert.True(tooEarly.FromCache); Assert.Equal(TimeSpan.FromSeconds(40), tooEarly.RetryAfter); Assert.Equal(1, checker.Calls); clock.Now = clock.Now.AddSeconds(40); var allowed = await service.GetAsync(force: true, CancellationToken.None); Assert.False(allowed.FromCache); Assert.Null(allowed.RetryAfter); Assert.Equal(2, checker.Calls); } [Fact] public async Task Gleichzeitige_Aufrufe_fragen_nur_einmal() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); var service = new UpdateStatusService(checker, () => clock.Now); await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => service.GetAsync(false, CancellationToken.None))); Assert.Equal(1, checker.Calls); } [Fact] public async Task Checked_feuert_nur_bei_frischer_Pruefung() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); var service = new UpdateStatusService(checker, () => clock.Now); int events = 0; service.Checked += _ => events++; await service.GetAsync(false, CancellationToken.None); await service.GetAsync(false, CancellationToken.None); // aus dem Zwischenspeicher Assert.Equal(1, events); Assert.Equal("0.2.0", service.Last?.LatestVersion); } [Fact] public async Task Hintergrundpruefung_meldet_jede_neue_Fassung_genau_einmal_und_installiert_nichts() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); var status = new UpdateStatusService(checker, () => clock.Now); var logger = new PolyTraderSharp.Services.TerminalLogger(); var notices = new System.Collections.Generic.List(); logger.OnLogMessage += (_, e) => { if (e.Message.Contains("Update verfügbar")) notices.Add(e.Message); }; var background = new UpdateCheckBackgroundService(status, logger, null); await background.CheckOnceAsync(CancellationToken.None); clock.Now = clock.Now + UpdateStatusService.CacheDuration; await background.CheckOnceAsync(CancellationToken.None); // dieselbe Fassung: kein zweiter Hinweis checker.Result = checker.Result with { LatestVersion = "0.3.0" }; clock.Now = clock.Now + UpdateStatusService.CacheDuration; await background.CheckOnceAsync(CancellationToken.None); Assert.Equal(2, notices.Count); Assert.Contains("v0.2.0", notices[0]); Assert.Contains("v0.3.0", notices[1]); Assert.All(notices, n => Assert.Contains("nur auf Wunsch", n)); } [Fact] public async Task Ohne_Update_kein_Hinweis() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); checker.Result = checker.Result with { UpdateAvailable = false, LatestVersion = null }; var logger = new PolyTraderSharp.Services.TerminalLogger(); int notices = 0; logger.OnLogMessage += (_, e) => { if (e.Message.Contains("Update verfügbar")) notices++; }; await new UpdateCheckBackgroundService(new UpdateStatusService(checker, () => clock.Now), logger, null) .CheckOnceAsync(CancellationToken.None); Assert.Equal(0, notices); } [Theory] [InlineData(null, 6)] [InlineData(0, 1)] [InlineData(12, 12)] [InlineData(10000, 168)] public void Pruefintervall_ist_begrenzt(int? hours, int expected) { var options = hours == null ? null : new PolyTrader.Core.Configuration.DeploymentcenterOptions { UpdateCheckIntervalHours = hours.Value }; Assert.Equal(TimeSpan.FromHours(expected), UpdateCheckBackgroundService.Interval(options)); } [Fact] public void Update_Pruefung_ist_ab_Werk_an() { Assert.True(new PolyTrader.Core.Configuration.DeploymentcenterOptions().UpdateCheckEnabled); } [Fact] public async Task Route_liefert_Fassung_Stand_und_verweist_zur_Installation_auf_den_Desktop() { var clock = new Clock(); var checker = new FakeChecker(() => clock.Now); await using var web = await WebUiTestHost.StartAsync(s => s.AddSingleton(new UpdateStatusService(checker, () => clock.Now))); var status = await web.GetJsonAsync("/api/updates"); Assert.Equal(PolyTrader.Core.BuildInfo.Version, status.GetProperty("currentVersion").GetString()); Assert.True(status.GetProperty("updateAvailable").GetBoolean()); Assert.Equal("0.2.0", status.GetProperty("latestVersion").GetString()); Assert.True(status.GetProperty("isCritical").GetBoolean()); Assert.Equal("desktop", status.GetProperty("installVia").GetString()); } [Fact] public async Task Ohne_Dienst_503() { await using var web = await WebUiTestHost.StartAsync(); using var res = await web.Client.GetAsync("/api/updates"); Assert.Equal(503, (int)res.StatusCode); } [Fact] public async Task Das_WebUI_kennt_keinen_Weg_zum_Installieren() { await using var web = await WebUiTestHost.StartAsync(); var routes = web.Get().Endpoints .OfType() .Select(e => e.RoutePattern.RawText ?? string.Empty) .Where(r => r.Contains("update", StringComparison.OrdinalIgnoreCase)) .ToList(); Assert.Equal(new[] { "/api/updates" }, routes); } } }