fix(worker): fix Bandel ticket sync and reactivate the integration

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.
This commit is contained in:
mika kuns
2026-09-02 09:59:06 +02:00
parent f640580463
commit 30d0611b8e
10 changed files with 185 additions and 64 deletions
@@ -55,7 +55,7 @@ public sealed class BandelTicketClientTests
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"}"""));
"""{"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);
@@ -63,20 +63,25 @@ public sealed class BandelTicketClientTests
Assert.Equal(12, item.Id);
Assert.Equal("Fix X", item.Title);
Assert.Equal(1, item.StatusId);
Assert.Equal("mika.kuns", item.AssigneeName);
Assert.Equal("mika.kuns", Assert.Single(item.TicketAssignees!).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()
// 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, 2, CancellationToken.None);
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\":2", h.Bodies[0]);
Assert.Contains($"\"statusId\":{statusId}", h.Bodies[0]);
Assert.Contains($"\"statusName\":\"{expectedName}\"", h.Bodies[0]);
}
[Fact]
@@ -28,15 +28,20 @@ public sealed class TicketImportServiceTests : IDisposable
}
[Fact]
public async Task Imports_only_open_tickets_assigned_to_the_token_owner()
public async Task Imports_open_and_in_progress_tickets_assigned_to_the_token_owner()
{
var (import, ctx) = await HarnessAsync();
await SeedExistingTaskAsync(ctx, listId: ListId, ticketRef: "bandel:4");
var result = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(4, result.Examined);
Assert.Equal(1, result.Created);
Assert.Equal(7, result.Examined);
Assert.Equal(2, result.Created);
var refs = await ctx.Tasks
.Where(t => t.CreatedBy == "ticketsystem")
.Select(t => t.TicketRef)
.ToListAsync();
Assert.Equal(["bandel:1", "bandel:3"], refs.Order());
var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync();
Assert.Equal("Fix X", created.Title);
@@ -45,6 +50,30 @@ public sealed class TicketImportServiceTests : IDisposable
Assert.Contains("Ticket #1", created.Description);
}
// Der eigentliche Fehler der ersten Fassung: gefiltert wurde auf das flache assigneeName,
// das live auf jedem Ticket null ist. Ticket #5 hat es gesetzt, aber leere ticketAssignees —
// wer wieder aufs flache Feld zurückfällt, importiert es und lässt diesen Test scheitern.
[Fact]
public async Task Flat_assigneeName_is_not_an_assignment()
{
var (import, ctx) = await HarnessAsync();
await import.ImportAsync(ListId, CancellationToken.None);
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:5"));
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:7"));
}
[Fact]
public async Task Finished_tickets_are_never_imported()
{
var (import, ctx) = await HarnessAsync();
await import.ImportAsync(ListId, CancellationToken.None);
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:6"));
}
[Fact]
public async Task Assignee_match_is_case_insensitive()
{
@@ -53,7 +82,7 @@ public sealed class TicketImportServiceTests : IDisposable
var result = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(1, result.Created);
Assert.Equal(2, result.Created);
}
[Fact]
@@ -64,7 +93,7 @@ public sealed class TicketImportServiceTests : IDisposable
var first = await import.ImportAsync(ListId, CancellationToken.None);
var second = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(1, first.Created);
Assert.Equal(2, first.Created);
Assert.Equal(0, second.Created);
Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1"));
}
@@ -106,11 +135,11 @@ public sealed class TicketImportServiceTests : IDisposable
}
/// <summary>
/// Board liefert immer vier Tickets: #1 offen/mein (importierbar), #2 offen/fremd (Assignee-
/// Filter), #3 in Bearbeitung/mein (Status-Filter), #4 offen/mein aber bereits importiert
/// (Dedup-Filter — hier vorab als bestehender Task angelegt, damit alle Default-Aufrufe
/// dieselbe, in sich konsistente Ausgangslage haben; Test 1 legt ihn zusätzlich explizit an,
/// was denselben Zustand nur redundant reproduziert).
/// Board liefert immer sieben Tickets, je einer pro Filterzweig:
/// #1 offen/mein → importierbar · #2 offen/fremd → Assignee-Filter ·
/// #3 in Bearbeitung/mein → importierbar · #4 offen/mein, bereits importiert → Dedup
/// (vorab als Task angelegt) · #5 offen, nur flaches assigneeName gesetzt → muss draußen
/// bleiben · #6 fertig/mein → Status-Filter · #7 offen, ticketAssignees null → draußen.
/// </summary>
private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync(
bool configured = true,
@@ -149,12 +178,24 @@ public sealed class TicketImportServiceTests : IDisposable
""");
}
// Die flachen assigneeID/assigneeName sind hier absichtlich null — genau so liefert
// die echte API sie auf JEDEM Ticket. Die Zuweisung steckt nur in ticketAssignees.
return (HttpStatusCode.OK, $$"""
{"success":true,"data":[
{"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"},
{"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":8,"assigneeName":"jemand.anders"},
{"id":3,"title":"InProgress","description":null,"statusId":2,"statusName":"InBearbeitung","assigneeID":7,"assigneeName":"{{boardAssignee}}"},
{"id":4,"title":"AlreadyImported","description":null,"statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"}
{"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":8,"assigneeName":"jemand.anders"}]},
{"id":3,"title":"InProgress","description":null,"statusId":2,"statusName":"In Bearbeitung","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":8,"assigneeName":"jemand.anders"},{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":4,"title":"AlreadyImported","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":5,"title":"FlatAssigneeOnly","description":null,"statusId":1,"statusName":"Offen","assigneeID":26,"assigneeName":"{{boardAssignee}}",
"ticketAssignees":[]},
{"id":6,"title":"Done","description":null,"statusId":3,"statusName":"Fertig","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":7,"title":"Unassigned","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":null}
],"message":"ok"}
""");
});
@@ -28,6 +28,27 @@ public sealed class TicketStatusMapTests
Assert.NotEqual(1, TicketStatusMap.ToTicketStatus(status));
}
// Der PATCH-Body braucht statusName; ein leerer Name wäre ein 400 der API.
[Fact]
public void NameOf_is_never_empty_for_any_status_we_write()
{
foreach (var status in Enum.GetValues<TaskStatus>())
{
if (TicketStatusMap.ToTicketStatus(status) is not int id) continue;
Assert.False(string.IsNullOrWhiteSpace(TicketStatusMap.NameOf(id)));
}
Assert.False(string.IsNullOrWhiteSpace(TicketStatusMap.NameOf(99)));
}
[Theory]
[InlineData(1, "Offen")]
[InlineData(2, "InBearbeitung")]
[InlineData(3, "Fertig")]
[InlineData(4, "Archiviert")]
public void NameOf_matches_the_api_status_list(int id, string expected)
=> Assert.Equal(expected, TicketStatusMap.NameOf(id));
[Theory]
[InlineData("bandel:1234", 1234)]
[InlineData("BANDEL:7", 7)]