The status PATCH needs statusName alongside statusId or the API 400s, so every write-back had been silently failing. The board's flat assigneeName is null on every live ticket; the real assignee list is ticketAssignees[]. Import now also picks up In Bearbeitung tickets, not just Offen ones. Both issues are live-verified against the real API, so the feature flag flips back on.
135 lines
5.6 KiB
C#
135 lines
5.6 KiB
C#
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);
|
|
|
|
// 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]
|
|
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":null,"assigneeName":null,"ticketAssignees":[{"assigneeId":26,"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", Assert.Single(item.TicketAssignees!).AssigneeName);
|
|
Assert.Equal("http://api.local/api/Board/project/393", h.Requests[0].RequestUri!.ToString());
|
|
}
|
|
|
|
// statusName ist auf der echten Route ein required-Feld: fehlt es, antwortet die API mit
|
|
// 400 "The StatusName field is required." und kein Statuswechsel wird geschrieben.
|
|
[Theory]
|
|
[InlineData(2, "InBearbeitung")]
|
|
[InlineData(3, "Fertig")]
|
|
public async Task SetStatus_patches_the_right_url_and_sends_both_status_fields(int statusId, string expectedName)
|
|
{
|
|
var h = new StubHandler(_ => (HttpStatusCode.OK, """{"success":true,"data":{},"message":"ok"}"""));
|
|
|
|
await Make(h).SetStatusAsync(12, statusId, 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\":{statusId}", h.Bodies[0]);
|
|
Assert.Contains($"\"statusName\":\"{expectedName}\"", 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);
|
|
}
|
|
}
|