feat(worker): BandelTicketClient fuer die Ticketsystem-REST-API
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user