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); // 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() { 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); } }