diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 3d8a94a0..cc55c429 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -1274,8 +1274,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub .ToList(); } + // Liefert die EFFEKTIVE Base-URL (inkl. ergänztem Schema), nicht den rohen Feldwert: sonst + // sieht der Nutzer im Tab weiter "host.local", während der Client "http://host.local" ruft. public TicketSettingsDto GetTicketSettings() - => new(_cfg.TicketApiBaseUrl, _ticketConfig.Token is not null); + => new(_ticketConfig.BaseUrl, _ticketConfig.Token is not null); public void SetTicketApiBaseUrl(string? baseUrl) { @@ -1301,7 +1303,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var identity = await client.GetIdentityAsync(Context.ConnectionAborted); return new TicketConnectionDto(true, identity.UserName, identity.Scopes, null); } - catch (TicketApiException ex) + // "Verbindung testen" ist die eine Stelle, an der ein Fehler eine ANTWORT ist und keine + // Ausnahme: wirft der Hub, schluckt WorkerClient.TryInvokeAsync das Ergebnis zu null und + // die UI zeigt "Worker not reachable" statt des Grundes. Deshalb hier alles einfangen. + catch (Exception ex) { return new TicketConnectionDto(false, null, Array.Empty(), ex.Message); } @@ -1311,8 +1316,15 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { var client = _ticketClients.Create(); if (client is null) return new List(); - var projects = await client.GetProjectsAsync(Context.ConnectionAborted); - return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList(); + try + { + var projects = await client.GetProjectsAsync(Context.ConnectionAborted); + return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList(); + } + catch (TicketApiException ex) + { + throw new HubException(ex.Message); + } } public async Task ImportTickets(string listId) diff --git a/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs index 634d1693..739d8c1b 100644 --- a/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs +++ b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs @@ -64,7 +64,12 @@ public sealed class BandelTicketClient { response = await _http.SendAsync(request, ct); } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested) + // Bewusst JEDE Exception, nicht nur HttpRequestException/TaskCanceledException: eine + // fehlerhafte Base-URL wirft InvalidOperationException oder UriFormatException, und die + // flogen vorher roh aus dem Hub. Der Aufrufer bekam dadurch statt eines Grundes nur ein + // verschlucktes null — die UI meldete "Worker not reachable", obwohl der Worker lief. + // Eine echte Abbruchanforderung wird davon nicht angefasst. + catch (Exception ex) when (!ct.IsCancellationRequested) { throw new TicketApiException($"Ticketsystem nicht erreichbar ({_baseUrl}): {ex.Message}"); } diff --git a/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs b/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs index 97254227..437e8c86 100644 --- a/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs +++ b/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs @@ -25,9 +25,25 @@ public sealed class TicketSystemConfig _pat = pat; } - public string? BaseUrl => string.IsNullOrWhiteSpace(_worker.TicketApiBaseUrl) - ? null - : _worker.TicketApiBaseUrl.Trim().TrimEnd('/'); + /// + /// Die Base-URL, wie HttpClient sie braucht: absolut, ohne Schrägstrich am Ende. + /// + /// Ein Feld namens "API base URL" lädt dazu ein, einen blanken Hostnamen einzutippen. + /// Ohne Schema ist das aber eine RELATIVE URI, und HttpClient lehnt sie mit einer + /// InvalidOperationException ab, lange bevor ein Paket fließt. Deshalb wird hier — und nur + /// hier, weil das der einzige Leser ist — `http://` ergänzt. `http` und nicht `https`, weil + /// die API im Intranet ohne TLS läuft; wer TLS will, tippt das Schema hin und es bleibt stehen. + /// + public string? BaseUrl + { + get + { + var raw = _worker.TicketApiBaseUrl?.Trim(); + if (string.IsNullOrEmpty(raw)) return null; + if (!raw.Contains("://", StringComparison.Ordinal)) raw = "http://" + raw; + return raw.TrimEnd('/'); + } + } public string? Token => _pat.Store.Read(); diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs index 3fa32983..d4a74e79 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs @@ -9,6 +9,33 @@ public sealed class BandelTicketClientTests private static BandelTicketClient Make(StubHandler handler) => new(new HttpClient(handler), "http://api.local", "tsp_x", NullLogger.Instance); + // Der Client verspricht der UI eine lesbare Meldung. Vorher wurden nur HttpRequestException + // und TaskCanceledException übersetzt — eine InvalidOperationException (die HttpClient bei + // einer relativen URI wirft) flog roh durch den Hub und kam als "Worker not reachable" an. + [Theory] + [ClassData(typeof(UnexpectedTransportFailures))] + public async Task Any_transport_failure_becomes_a_readable_TicketApiException(Exception failure) + { + var client = Make(new StubHandler(failure)); + + var ex = await Assert.ThrowsAsync( + () => client.GetIdentityAsync(CancellationToken.None)); + + Assert.Contains("http://api.local", ex.Message); + Assert.DoesNotContain("Exception", ex.Message); + } + + public sealed class UnexpectedTransportFailures : TheoryData + { + public UnexpectedTransportFailures() + { + Add(new InvalidOperationException("An invalid request URI was provided.")); + Add(new HttpRequestException("no such host")); + Add(new NotSupportedException("scheme not supported")); + Add(new UriFormatException("bad uri")); + } + } + [Fact] public async Task GetIdentity_returns_user_name() { diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs b/tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs index 9df048d8..db5df3cc 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs @@ -13,12 +13,22 @@ internal sealed class StubHandler : HttpMessageHandler public readonly List Bodies = new(); private readonly Func _respond; + private readonly Exception? _throw; + public StubHandler(Func respond) => _respond = respond; + /// Lets a test reproduce a transport that blows up instead of answering. + public StubHandler(Exception throwOnSend) + { + _respond = _ => (HttpStatusCode.OK, ""); + _throw = throwOnSend; + } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { Requests.Add(request); Bodies.Add(request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct)); + if (_throw is not null) throw _throw; var (code, body) = _respond(request); return new HttpResponseMessage(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketSystemConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketSystemConfigTests.cs index dfba78fb..a924b180 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/TicketSystemConfigTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketSystemConfigTests.cs @@ -36,6 +36,23 @@ public sealed class TicketSystemConfigTests : IDisposable public void Base_url_trailing_slash_is_trimmed() => Assert.Equal("http://api.local", Make("http://api.local/", "tsp_x").BaseUrl); + // Ein blanker Hostname ist das, was man in ein Feld namens "API base URL" tippt. Ohne Schema + // ist das eine RELATIVE URI, und HttpClient lehnt sie mit InvalidOperationException ab — + // genau daran ist die erste Einrichtung gescheitert. + [Theory] + [InlineData("bandelapis.fb-tuning.local", "http://bandelapis.fb-tuning.local")] + [InlineData("bandelapis.fb-tuning.local/", "http://bandelapis.fb-tuning.local")] + [InlineData(" bandelapis.fb-tuning.local ", "http://bandelapis.fb-tuning.local")] + [InlineData("api.local:8080", "http://api.local:8080")] + public void Missing_scheme_defaults_to_http(string entered, string expected) + => Assert.Equal(expected, Make(entered, "tsp_x").BaseUrl); + + [Theory] + [InlineData("https://api.local")] + [InlineData("http://api.local")] + public void Explicit_scheme_is_left_alone(string entered) + => Assert.Equal(entered, Make(entered, "tsp_x").BaseUrl); + public void Dispose() { if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);