From 106527802133baff972bc76f7ba3209c2510e4b3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 27 Aug 2026 12:42:22 +0200 Subject: [PATCH] feat(worker): BandelTicketClient fuer die Ticketsystem-REST-API --- src/ClaudeDo.Worker/Program.cs | 2 + .../Tickets/BandelTicketClient.cs | 107 ++++++++++++++++++ src/ClaudeDo.Worker/Tickets/TicketDtos.cs | 44 +++++++ .../Tickets/TicketSystemConfig.cs | 22 ++++ .../Tickets/BandelTicketClientTests.cs | 102 +++++++++++++++++ .../Tickets/StubHandler.cs | 25 ++++ 6 files changed, 302 insertions(+) create mode 100644 src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs create mode 100644 src/ClaudeDo.Worker/Tickets/TicketDtos.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 64ce20e8..fde7e44a 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -217,6 +217,8 @@ builder.Services.AddSingleton(new OnlineRefreshTokenStore(DpapiTokenStore.InAppD builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("ticket.pat"))); #pragma warning restore CA1416 builder.Services.AddSingleton(); +builder.Services.AddHttpClient("tickets"); +builder.Services.AddSingleton(); if (cfg.OnlineInbox.Enabled) { diff --git a/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs new file mode 100644 index 00000000..634d1693 --- /dev/null +++ b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ClaudeDo.Worker.Tickets; + +/// +/// Der eine HTTP-Zugang zum Bandel-Ticketsystem. Bewusst kein Interface: es gibt genau eine +/// Implementierung. Kommt ein zweites Ticketsystem (Jira), ist der Extract ein Refactoring. +/// +public sealed class BandelTicketClient +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly HttpClient _http; + private readonly string _baseUrl; + private readonly string _token; + private readonly ILogger _logger; + + public BandelTicketClient(HttpClient http, string baseUrl, string token, ILogger logger) + { + _http = http; + _baseUrl = baseUrl.TrimEnd('/'); + _token = token; + _logger = logger; + if (_http.Timeout == Timeout.InfiniteTimeSpan || _http.Timeout > TimeSpan.FromSeconds(10)) + _http.Timeout = TimeSpan.FromSeconds(10); + } + + public Task GetIdentityAsync(CancellationToken ct) + => SendAsync(HttpMethod.Get, "/api/ticketsystem/pat/me", null, ct); + + public async Task> GetProjectsAsync(CancellationToken ct) + { + var departments = await SendAsync>( + HttpMethod.Get, "/api/Navigation/sidebar", null, ct); + + return departments + .SelectMany(d => d.Projects.Select(p => new TicketProject(p.Id, p.Title, d.DepartmentName))) + .ToList(); + } + + public async Task> GetProjectBoardAsync(int projectId, CancellationToken ct) + => await SendAsync>( + HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct); + + public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct) + => await SendAsync( + HttpMethod.Patch, $"/api/Ticket/{ticketId}/status", + new StringContent($"{{\"statusId\":{statusId}}}", Encoding.UTF8, "application/json"), ct); + + private async Task SendAsync(HttpMethod method, string path, HttpContent? body, CancellationToken ct) + { + using var request = new HttpRequestMessage(method, _baseUrl + path) { Content = body }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + + HttpResponseMessage response; + try + { + response = await _http.SendAsync(request, ct); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested) + { + throw new TicketApiException($"Ticketsystem nicht erreichbar ({_baseUrl}): {ex.Message}"); + } + + using (response) + { + var payload = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + var hint = response.StatusCode switch + { + HttpStatusCode.Unauthorized => "Token fehlt oder ist ungültig", + HttpStatusCode.Forbidden => "Token fehlt der nötige Scope", + HttpStatusCode.NotFound => "Nicht gefunden", + _ => "Fehler", + }; + throw new TicketApiException($"{hint} ({(int)response.StatusCode} bei {method} {path})."); + } + + BandelEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize>(payload, JsonOpts); + } + catch (JsonException ex) + { + throw new TicketApiException($"Unerwartete Antwort von {method} {path}: {ex.Message}"); + } + + if (envelope is null || !envelope.Success) + throw new TicketApiException(envelope?.Message ?? $"Ticketsystem meldete einen Fehler bei {method} {path}."); + + if (envelope.Data is null) + throw new TicketApiException($"Ticketsystem lieferte keine Daten bei {method} {path}."); + + return envelope.Data; + } + } +} diff --git a/src/ClaudeDo.Worker/Tickets/TicketDtos.cs b/src/ClaudeDo.Worker/Tickets/TicketDtos.cs new file mode 100644 index 00000000..55b40d81 --- /dev/null +++ b/src/ClaudeDo.Worker/Tickets/TicketDtos.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace ClaudeDo.Worker.Tickets; + +/// Antwort-Hülle jeder Bandel.APIs-Route. +public sealed record BandelEnvelope( + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("data")] T? Data, + [property: JsonPropertyName("message")] string? Message, + [property: JsonPropertyName("errorCode")] string? ErrorCode); + +/// GET /api/ticketsystem/pat/me +public sealed record TicketIdentity( + [property: JsonPropertyName("userName")] string UserName, + [property: JsonPropertyName("isPat")] bool IsPat, + [property: JsonPropertyName("scopes")] IReadOnlyList Scopes); + +/// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht). +public sealed record TicketBoardItem( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("statusId")] int StatusId, + [property: JsonPropertyName("statusName")] string? StatusName, + [property: JsonPropertyName("assigneeID")] int? AssigneeId, + [property: JsonPropertyName("assigneeName")] string? AssigneeName); + +/// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten. +public sealed record SidebarDepartment( + [property: JsonPropertyName("departmentId")] int DepartmentId, + [property: JsonPropertyName("departmentName")] string DepartmentName, + [property: JsonPropertyName("projects")] IReadOnlyList Projects); + +public sealed record SidebarProject( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string Title); + +/// Flachgeklopftes Projekt für die UI-Auswahl. +public sealed record TicketProject(int Id, string Title, string DepartmentName); + +public sealed class TicketApiException : Exception +{ + public TicketApiException(string message) : base(message) { } +} diff --git a/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs b/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs index 94a83ed0..97254227 100644 --- a/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs +++ b/src/ClaudeDo.Worker/Tickets/TicketSystemConfig.cs @@ -1,4 +1,5 @@ using ClaudeDo.Worker.Config; +using Microsoft.Extensions.Logging; namespace ClaudeDo.Worker.Tickets; @@ -32,3 +33,24 @@ public sealed class TicketSystemConfig public bool IsConfigured => BaseUrl is not null && !string.IsNullOrEmpty(Token); } + +/// Baut einen Client aus der aktuellen Konfiguration, oder null, wenn nichts eingerichtet ist. +public sealed class TicketClientFactory +{ + private readonly TicketSystemConfig _config; + private readonly IHttpClientFactory _httpFactory; + private readonly ILogger _logger; + + public TicketClientFactory(TicketSystemConfig config, IHttpClientFactory httpFactory, ILogger logger) + { + _config = config; + _httpFactory = httpFactory; + _logger = logger; + } + + public BandelTicketClient? Create() + { + if (!_config.IsConfigured) return null; + return new BandelTicketClient(_httpFactory.CreateClient("tickets"), _config.BaseUrl!, _config.Token!, _logger); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs new file mode 100644 index 00000000..3fa32983 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs @@ -0,0 +1,102 @@ +using System.Net; +using ClaudeDo.Worker.Tickets; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class BandelTicketClientTests +{ + private static BandelTicketClient Make(StubHandler handler) + => new(new HttpClient(handler), "http://api.local", "tsp_x", NullLogger.Instance); + + [Fact] + public async Task GetIdentity_returns_user_name() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":true,"data":{"userName":"mika.kuns","isPat":true,"scopes":["pat:board:read"]},"message":"ok"}""")); + + var identity = await Make(h).GetIdentityAsync(CancellationToken.None); + + Assert.Equal("mika.kuns", identity.UserName); + Assert.Contains("pat:board:read", identity.Scopes); + Assert.Equal("http://api.local/api/ticketsystem/pat/me", h.Requests[0].RequestUri!.ToString()); + Assert.Equal("tsp_x", h.Requests[0].Headers.Authorization!.Parameter); + Assert.Equal("Bearer", h.Requests[0].Headers.Authorization!.Scheme); + } + + [Fact] + public async Task GetProjectBoard_maps_items() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":true,"data":[{"id":12,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"mika.kuns"}],"message":"ok"}""")); + + var items = await Make(h).GetProjectBoardAsync(393, CancellationToken.None); + + var item = Assert.Single(items); + Assert.Equal(12, item.Id); + Assert.Equal("Fix X", item.Title); + Assert.Equal(1, item.StatusId); + Assert.Equal("mika.kuns", item.AssigneeName); + Assert.Equal("http://api.local/api/Board/project/393", h.Requests[0].RequestUri!.ToString()); + } + + [Fact] + public async Task SetStatus_patches_the_right_url_and_body() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, """{"success":true,"data":{},"message":"ok"}""")); + + await Make(h).SetStatusAsync(12, 2, CancellationToken.None); + + Assert.Equal(HttpMethod.Patch, h.Requests[0].Method); + Assert.Equal("http://api.local/api/Ticket/12/status", h.Requests[0].RequestUri!.ToString()); + Assert.Contains("\"statusId\":2", h.Bodies[0]); + } + + [Fact] + public async Task Unauthorized_throws_with_a_readable_message() + { + var h = new StubHandler(_ => (HttpStatusCode.Unauthorized, "")); + + var ex = await Assert.ThrowsAsync( + () => Make(h).GetIdentityAsync(CancellationToken.None)); + + Assert.Contains("401", ex.Message); + } + + [Fact] + public async Task Forbidden_names_the_missing_scope_situation() + { + var h = new StubHandler(_ => (HttpStatusCode.Forbidden, "")); + + var ex = await Assert.ThrowsAsync( + () => Make(h).GetProjectBoardAsync(1, CancellationToken.None)); + + Assert.Contains("403", ex.Message); + } + + [Fact] + public async Task Success_false_in_the_envelope_throws() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":false,"data":null,"message":"Projekt nicht gefunden","errorCode":"NOT_FOUND"}""")); + + var ex = await Assert.ThrowsAsync( + () => Make(h).GetProjectBoardAsync(999, CancellationToken.None)); + + Assert.Contains("Projekt nicht gefunden", ex.Message); + } + + [Fact] + public async Task GetProjects_flattens_departments() + { + var h = new StubHandler(_ => (HttpStatusCode.OK, + """{"success":true,"data":[{"departmentId":1,"departmentName":"Entwicklung","projects":[{"id":393,"title":"Bandel.LagerApp"},{"id":398,"title":"Bandel.Hub"}]}],"message":"ok"}""")); + + var projects = await Make(h).GetProjectsAsync(CancellationToken.None); + + Assert.Equal(2, projects.Count); + Assert.Equal(393, projects[0].Id); + Assert.Equal("Entwicklung", projects[0].DepartmentName); + Assert.Equal("Bandel.Hub", projects[1].Title); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs b/tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs new file mode 100644 index 00000000..9df048d8 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/StubHandler.cs @@ -0,0 +1,25 @@ +using System.Net; +using System.Text; + +namespace ClaudeDo.Worker.Tests.Tickets; + +/// +/// Fakes the HTTP transport for BandelTicketClient tests. No test using this may touch the +/// network — reused by later tasks that also exercise the ticket client. +/// +internal sealed class StubHandler : HttpMessageHandler +{ + public readonly List Requests = new(); + public readonly List Bodies = new(); + private readonly Func _respond; + + public StubHandler(Func respond) => _respond = respond; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requests.Add(request); + Bodies.Add(request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct)); + var (code, body) = _respond(request); + return new HttpResponseMessage(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + } +}