fix(worker): Base-URL ohne Schema laesst die Ticket-Anbindung scheitern
Ein blanker Hostname ist eine relative URI; HttpClient lehnt sie mit InvalidOperationException ab. Die lief an dem zu engen catch-Filter vorbei, flog roh aus dem Hub und wurde von TryInvokeAsync zu null verschluckt - die UI meldete "Worker not reachable", obwohl der Worker lief und die Ursache eine Eingabe war. - TicketSystemConfig ergaenzt fehlendes http:// - BandelTicketClient uebersetzt jede Transport-Exception in eine lesbare TicketApiException - TestTicketConnection faengt alles und antwortet mit dem Grund - GetTicketSettings liefert die effektive URL, damit die Ergaenzung sichtbar ist
This commit is contained in:
@@ -1274,8 +1274,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
.ToList();
|
.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()
|
public TicketSettingsDto GetTicketSettings()
|
||||||
=> new(_cfg.TicketApiBaseUrl, _ticketConfig.Token is not null);
|
=> new(_ticketConfig.BaseUrl, _ticketConfig.Token is not null);
|
||||||
|
|
||||||
public void SetTicketApiBaseUrl(string? baseUrl)
|
public void SetTicketApiBaseUrl(string? baseUrl)
|
||||||
{
|
{
|
||||||
@@ -1301,7 +1303,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
var identity = await client.GetIdentityAsync(Context.ConnectionAborted);
|
var identity = await client.GetIdentityAsync(Context.ConnectionAborted);
|
||||||
return new TicketConnectionDto(true, identity.UserName, identity.Scopes, null);
|
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<string>(), ex.Message);
|
return new TicketConnectionDto(false, null, Array.Empty<string>(), ex.Message);
|
||||||
}
|
}
|
||||||
@@ -1311,9 +1316,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
{
|
{
|
||||||
var client = _ticketClients.Create();
|
var client = _ticketClients.Create();
|
||||||
if (client is null) return new List<TicketProjectDto>();
|
if (client is null) return new List<TicketProjectDto>();
|
||||||
|
try
|
||||||
|
{
|
||||||
var projects = await client.GetProjectsAsync(Context.ConnectionAborted);
|
var projects = await client.GetProjectsAsync(Context.ConnectionAborted);
|
||||||
return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList();
|
return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList();
|
||||||
}
|
}
|
||||||
|
catch (TicketApiException ex)
|
||||||
|
{
|
||||||
|
throw new HubException(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TicketImportResultDto> ImportTickets(string listId)
|
public async Task<TicketImportResultDto> ImportTickets(string listId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ public sealed class BandelTicketClient
|
|||||||
{
|
{
|
||||||
response = await _http.SendAsync(request, ct);
|
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}");
|
throw new TicketApiException($"Ticketsystem nicht erreichbar ({_baseUrl}): {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,25 @@ public sealed class TicketSystemConfig
|
|||||||
_pat = pat;
|
_pat = pat;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? BaseUrl => string.IsNullOrWhiteSpace(_worker.TicketApiBaseUrl)
|
/// <summary>
|
||||||
? null
|
/// Die Base-URL, wie HttpClient sie braucht: absolut, ohne Schrägstrich am Ende.
|
||||||
: _worker.TicketApiBaseUrl.Trim().TrimEnd('/');
|
///
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
public string? Token => _pat.Store.Read();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,33 @@ public sealed class BandelTicketClientTests
|
|||||||
private static BandelTicketClient Make(StubHandler handler)
|
private static BandelTicketClient Make(StubHandler handler)
|
||||||
=> new(new HttpClient(handler), "http://api.local", "tsp_x", NullLogger<BandelTicketClient>.Instance);
|
=> new(new HttpClient(handler), "http://api.local", "tsp_x", NullLogger<BandelTicketClient>.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<TicketApiException>(
|
||||||
|
() => client.GetIdentityAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Contains("http://api.local", ex.Message);
|
||||||
|
Assert.DoesNotContain("Exception", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UnexpectedTransportFailures : TheoryData<Exception>
|
||||||
|
{
|
||||||
|
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]
|
[Fact]
|
||||||
public async Task GetIdentity_returns_user_name()
|
public async Task GetIdentity_returns_user_name()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,12 +13,22 @@ internal sealed class StubHandler : HttpMessageHandler
|
|||||||
public readonly List<string> Bodies = new();
|
public readonly List<string> Bodies = new();
|
||||||
private readonly Func<HttpRequestMessage, (HttpStatusCode, string)> _respond;
|
private readonly Func<HttpRequestMessage, (HttpStatusCode, string)> _respond;
|
||||||
|
|
||||||
|
private readonly Exception? _throw;
|
||||||
|
|
||||||
public StubHandler(Func<HttpRequestMessage, (HttpStatusCode, string)> respond) => _respond = respond;
|
public StubHandler(Func<HttpRequestMessage, (HttpStatusCode, string)> respond) => _respond = respond;
|
||||||
|
|
||||||
|
/// <summary>Lets a test reproduce a transport that blows up instead of answering.</summary>
|
||||||
|
public StubHandler(Exception throwOnSend)
|
||||||
|
{
|
||||||
|
_respond = _ => (HttpStatusCode.OK, "");
|
||||||
|
_throw = throwOnSend;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Requests.Add(request);
|
Requests.Add(request);
|
||||||
Bodies.Add(request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct));
|
Bodies.Add(request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct));
|
||||||
|
if (_throw is not null) throw _throw;
|
||||||
var (code, body) = _respond(request);
|
var (code, body) = _respond(request);
|
||||||
return new HttpResponseMessage(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
return new HttpResponseMessage(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ public sealed class TicketSystemConfigTests : IDisposable
|
|||||||
public void Base_url_trailing_slash_is_trimmed()
|
public void Base_url_trailing_slash_is_trimmed()
|
||||||
=> Assert.Equal("http://api.local", Make("http://api.local/", "tsp_x").BaseUrl);
|
=> 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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
|
if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
|
||||||
|
|||||||
Reference in New Issue
Block a user