feat(worker): BandelTicketClient fuer die Ticketsystem-REST-API

This commit is contained in:
mika kuns
2026-08-27 12:42:22 +02:00
parent e9dfea905f
commit 1065278021
6 changed files with 302 additions and 0 deletions
+2
View File
@@ -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<TicketSystemConfig>();
builder.Services.AddHttpClient("tickets");
builder.Services.AddSingleton<TicketClientFactory>();
if (cfg.OnlineInbox.Enabled)
{
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<BandelTicketClient> _logger;
public BandelTicketClient(HttpClient http, string baseUrl, string token, ILogger<BandelTicketClient> 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<TicketIdentity> GetIdentityAsync(CancellationToken ct)
=> SendAsync<TicketIdentity>(HttpMethod.Get, "/api/ticketsystem/pat/me", null, ct);
public async Task<IReadOnlyList<TicketProject>> GetProjectsAsync(CancellationToken ct)
{
var departments = await SendAsync<List<SidebarDepartment>>(
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<IReadOnlyList<TicketBoardItem>> GetProjectBoardAsync(int projectId, CancellationToken ct)
=> await SendAsync<List<TicketBoardItem>>(
HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct);
public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct)
=> await SendAsync<JsonElement>(
HttpMethod.Patch, $"/api/Ticket/{ticketId}/status",
new StringContent($"{{\"statusId\":{statusId}}}", Encoding.UTF8, "application/json"), ct);
private async Task<T> SendAsync<T>(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<T>? envelope;
try
{
envelope = JsonSerializer.Deserialize<BandelEnvelope<T>>(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;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace ClaudeDo.Worker.Tickets;
/// Antwort-Hülle jeder Bandel.APIs-Route.
public sealed record BandelEnvelope<T>(
[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<string> 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<SidebarProject> 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) { }
}
@@ -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<BandelTicketClient> _logger;
public TicketClientFactory(TicketSystemConfig config, IHttpClientFactory httpFactory, ILogger<BandelTicketClient> 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);
}
}
@@ -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<BandelTicketClient>.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<TicketApiException>(
() => 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<TicketApiException>(
() => 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<TicketApiException>(
() => 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);
}
}
@@ -0,0 +1,25 @@
using System.Net;
using System.Text;
namespace ClaudeDo.Worker.Tests.Tickets;
/// <summary>
/// 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.
/// </summary>
internal sealed class StubHandler : HttpMessageHandler
{
public readonly List<HttpRequestMessage> Requests = new();
public readonly List<string> Bodies = new();
private readonly Func<HttpRequestMessage, (HttpStatusCode, string)> _respond;
public StubHandler(Func<HttpRequestMessage, (HttpStatusCode, string)> respond) => _respond = respond;
protected override async Task<HttpResponseMessage> 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") };
}
}