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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user