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
@@ -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"}
""");
});