WebUI: Update-Status anzeigen, installiert wird im Desktop

Umsetzung von #565.

GET /api/updates liefert die laufende Fassung (BuildInfo), die verfuegbare,
den Hinweis auf ein Sicherheitsupdate und den Changelog. Geprueft wird mit
demselben Aufruf wie beim Start des Desktops (UpdateClient gegen das
Deploymentcenter). Den Lizenzschluessel fuer den Paketabruf liest der Server,
er verlaesst ihn nicht.

Zwischenspeicher: UpdateStatusService haelt das Ergebnis 30 Minuten vor.
"Jetzt pruefen" umgeht das, aber fruehestens nach 60 Sekunden; bis dahin kommt
der vorige Stand mit der Wartezeit zurueck. Gleichzeitige Aufrufe loesen eine
einzige Pruefung aus. Ohne das fragte jeder Seitenaufruf, jedes offene
Browserfenster, das Deploymentcenter. Die Pruefung liegt hinter IUpdateChecker
und ist damit ohne Netz testbar.

Kein Installieren ueber das WebUI, und ein Test haelt fest, dass es keine
solche Route gibt: Die Installation startet den Update-Agent und beendet
danach die Anwendung und mit ihr den Webserver. Richards Vorgabe beschraenkt
das Beenden ueber das WebUI auf das Trading (#563).

Oberflaeche:
- Karte "Version & Updates" in den Einstellungen, mit "Jetzt pruefen".
- Im Dashboard ein Banner wie im Desktop-Launcher, sobald ein Update vorliegt.
  Er verweist zur Installation auf den Desktop.

Nicht durch Unit-Tests abgedeckt: DeploymentcenterUpdateChecker selbst, weil
er ueber das Netz geht. Er wird auf der VM gegen das echte Deploymentcenter
geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-13 14:20:07 +02:00
co-authored by Claude Opus 5
parent 0067e0b761
commit bc96807d81
6 changed files with 417 additions and 0 deletions
@@ -0,0 +1,148 @@
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
{
/// <summary>
/// Update-Status im WebUI (#565): Zwischenspeicher, Mindestabstand für "Jetzt prüfen", Anzeige.
/// </summary>
public class UpdateStatusTests
{
private sealed class FakeChecker : IUpdateChecker
{
private readonly Func<DateTime> _now;
public int Calls;
public UpdateCheckSnapshot Result { get; set; }
public FakeChecker(Func<DateTime> now)
{
_now = now;
Result = new UpdateCheckSnapshot(DateTime.MinValue, true, true, false, "0.2.0", "Neu: Log-Viewer", true, null);
}
public async Task<UpdateCheckSnapshot> 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 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<Microsoft.AspNetCore.Routing.EndpointDataSource>().Endpoints
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
.Select(e => e.RoutePattern.RawText ?? string.Empty)
.Where(r => r.Contains("update", StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.Equal(new[] { "/api/updates" }, routes);
}
}
}