From 30d0611b8e2e2037c9fadf5987a12add83afa990 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 2 Sep 2026 09:59:06 +0200 Subject: [PATCH] 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. --- docs/open.md | 48 +++++++------ .../Settings/TicketSettingsTabViewModel.cs | 21 +++--- src/ClaudeDo.Worker/CLAUDE.md | 18 +++-- .../Tickets/BandelTicketClient.cs | 11 ++- src/ClaudeDo.Worker/Tickets/TicketDtos.cs | 10 +++ .../Tickets/TicketImportService.cs | 18 ++++- .../Tickets/TicketStatusMap.cs | 14 ++++ .../Tickets/BandelTicketClientTests.cs | 17 +++-- .../Tickets/TicketImportServiceTests.cs | 71 +++++++++++++++---- .../Tickets/TicketStatusMapTests.cs | 21 ++++++ 10 files changed, 185 insertions(+), 64 deletions(-) diff --git a/docs/open.md b/docs/open.md index 831e12c4..95007564 100644 --- a/docs/open.md +++ b/docs/open.md @@ -46,33 +46,35 @@ verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran ## Offene Verifikation (2026-08-27, Ticketsystem-Anbindung) -> **PAUSIERT.** `TicketSettingsTabViewModel.FeatureEnabled = false` versteckt Settings-Tab, -> Projekt-Dropdown und Kontextmenü-Eintrag; zum Reaktivieren die Konstante umlegen. Grund: die -> API ist unter `http://bandelapis.fb-tuning.local` nicht erreichbar (*connection actively -> refused* — nichts lauscht dort). Kein ClaudeDo-Bug und kein CORS; wahrscheinlich fehlt eine -> IIS-Hostname-Bindung oder der Port stimmt nicht (`deploy/deploy-iis-winrm.ps1` der API notiert, -> dass srv-04b diese Sites *by IP:port rather than by hostname* erreicht). Wird mit dem -> API-Kollegen geklärt. Die Punkte unten bleiben stehen, bis die Erreichbarkeit steht. +> **Reaktiviert am 2026-09-01.** `FeatureEnabled = true`. Die API läuft unter +> `http://192.168.45.161` — der Hostname `bandelapis.fb-tuning.local` lehnt die Verbindung +> weiterhin ab, die Base-URL muss also die **IP** sein. -Tasks 1–9 sind implementiert und gemergt (Build + Tests grün, `_broadcaster.TaskUpdated` genau -einmal in `TaskStateService.cs`), aber **kein Smoke-Test gegen die echte Bandel-API ist -gelaufen**. Die fünf Punkte aus der „Manuellen Verifikation" des Specs -(`docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md`): +Alle vier Routen sind jetzt live gegen die echte API geprüft. Die aus dem `Bandel.APIs`-Quellcode +abgeleiteten Shapes stimmten bis auf zwei Punkte, beide gefixt und mit Tests belegt: -- [ ] PAT in den Settings hinterlegen, „Verbindung testen" → korrekter Benutzername + Scopes. -- [ ] Liste mit Projekt `393 Bandel.LagerApp` verknüpfen, „Update from Ticketsystem" → nur - eigene, offene Tickets erscheinen als Tasks. -- [ ] Einen Task laufen lassen → Ticket steht im Ticketsystem auf `InBearbeitung`. +- `PATCH /api/Ticket/{id}/status` braucht **`statusId` UND `statusName`** — ohne den Namen 400 + („The StatusName field is required."), also hätte *jede* Write-Back fehlgeschlagen. Der Name + wird nicht gegen die Id validiert, muss aber nicht-leer sein (`TicketStatusMap.NameOf`). +- Das flache `assigneeName` im Board-Item ist auf **jedem** Ticket null (alle 63 in Abteilung + Entwicklung geprüft). Der Assignee-Filter des Imports hätte damit immer 0 Tasks erzeugt. Echte + Quelle: `ticketAssignees[].assigneeName`, das exakt das Format von `/pat/me`s `userName` hat. + `personInChargeName` ist die falsche Quelle (mal Login, mal Anzeigename). + +Import zieht jetzt `Offen` **und** `InBearbeitung`, weiterhin nur eigene Tickets. + +Bleibt manuell zu prüfen (UI-Pfad, nie sichtbar gelaufen): + +- [ ] PAT in Settings → Ticketsystem hinterlegen (Base-URL `http://192.168.45.161`), + „Verbindung testen" → `mika.kuns` + 9 Scopes. +- [ ] Liste mit einem Projekt verknüpfen (Rechtsklick → Listeneinstellungen → Ticket-Projekt), + „Update from Ticketsystem" → nur eigene offene/laufende Tickets erscheinen als Tasks. + Achtung: aktuell ist **kein** offenes Ticket in Entwicklung dir zugewiesen — zum Testen + erst im Ticketsystem zuweisen, sonst ist 0 importiert das korrekte Ergebnis. +- [ ] Einen Task laufen lassen → Ticket steht auf `InBearbeitung`. - [ ] Approve/Merge → Ticket steht auf `Fertig`. - [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr. - -**Wichtig:** die Response-Shapes der Bandel-API (`BandelEnvelope`, `TicketIdentity`, -`TicketBoardItem`, `SidebarDepartment`/`SidebarProject`, `TicketDtos.cs`) wurden aus dem -**Quellcode** von `Bandel.APIs` abgeleitet, **nie live gegen die echte API verifiziert** — es gab -keinen einzigen echten Netzwerkaufruf während der Implementierung. Die Punkte 1–4 oben sind -zugleich der erste Test dieser Annahme: schlägt einer davon mit einem Deserialisierungs- oder -404/Formatfehler fehl, ist das erwartbar und kein Regressions-Bug — dann müssen die DTOs in -`src/ClaudeDo.Worker/Tickets/TicketDtos.cs` an die tatsächliche Antwort angepasst werden. +- [ ] Sichtprüfung des Settings-Tabs + des Ticket-Projekt-Dropdowns (nie gerendert gesehen). ## Offene Verifikation (2026-07-27) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs index a0a2a6e9..bc428091 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs @@ -8,20 +8,17 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings; public sealed partial class TicketSettingsTabViewModel : ViewModelBase { /// - /// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI — hier auf true - /// für die Reaktivierung, wie bei SettingsModalViewModel.ShowOnlineInbox. + /// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI, wie bei + /// SettingsModalViewModel.ShowOnlineInbox. Er versteckt drei Einstiegspunkte: diesen + /// Settings-Tab, das Ticket-Projekt-Dropdown in den Listen-Einstellungen und den + /// Kontextmenü-Eintrag zum Importieren. /// - /// Pausiert am 2026-08-27: die Bandel-API war unter dem eingetragenen Hostnamen nicht - /// erreichbar (Verbindung aktiv abgelehnt — vermutlich fehlende IIS-Hostname-Bindung oder - /// falscher Port, siehe deploy/deploy-iis-winrm.ps1). Solange das offen ist, würde der Tab - /// nur eine Einrichtung anbieten, die nicht funktionieren kann. - /// - /// Der Schalter versteckt drei Einstiegspunkte: diesen Settings-Tab, das Ticket-Projekt- - /// Dropdown in den Listen-Einstellungen und den Kontextmenü-Eintrag zum Importieren. - /// Gespeicherte Werte (Base-URL, PAT, verknüpfte Projekte) bleiben unangetastet und greifen - /// beim Umlegen sofort wieder. + /// Pausiert am 2026-08-27 (API unter dem Hostnamen nicht erreichbar), reaktiviert am + /// 2026-09-01: die API läuft unter http://192.168.45.161 und alle vier Routen sind + /// live verifiziert. Base-URL ist deshalb eine IP, kein Hostname — bandelapis.fb-tuning.local + /// lehnt die Verbindung weiterhin ab. /// - public const bool FeatureEnabled = false; + public const bool FeatureEnabled = true; private readonly IWorkerClient _worker; diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index bec5b1be..bb3be982 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -275,11 +275,16 @@ refresh token uses, one instance per file, DI markers `OnlineRefreshTokenStore`/ distinguish the two singletons). The token never touches `worker.config.json` or the DB. - **Import** (`TicketImportService`, hub `ImportTickets`) — per-list, manual only (no polling): pulls - the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets that are `StatusId - == 1` (Offen) *and* assigned to the PAT owner (case-insensitive name match), skips any `TicketRef` + the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets whose `StatusId` is + `1` (Offen) or `2` (InBearbeitung) *and* that are assigned to the PAT owner, skips any `TicketRef` already present on the list, and creates one `Idle` task per remainder via `TaskRepository.AddAsync` (so it gets a `Number` like every other task). Already-imported tickets are never updated — a re-import would overwrite task notes. + ⚠️ **The assignee lives in `ticketAssignees[]`, never in the flat `assigneeName`** — that field is + null on every ticket the live API returns (checked across all 63 in Abteilung Entwicklung, + 2026-09-01), so the original filter imported nothing, ever. `personInChargeName` is the wrong + source too: it holds the login on some rows and the display name ("Mika Kuns") on others, while + `ticketAssignees[].assigneeName` matches `/pat/me`'s `userName` format exactly. - **Write-back** (`TicketStatusSync`) hangs off `TaskStateService.NotifyAsync` — the one place every status transition passes through. Mapping: `Running`/`WaitingForReview` → ticket status `2 InBearbeitung`, `Done` → `3 Fertig`, everything else writes nothing (`1 Offen` is never written @@ -290,8 +295,13 @@ distinguish the two singletons). The token never touches `worker.config.json` or - `BandelTicketClient` wraps the four REST calls (`GET /api/ticketsystem/pat/me`, `GET /api/Navigation/sidebar`, `GET /api/Board/project/{id}`, `PATCH /api/Ticket/{id}/status`); every response is unwrapped from `BandelEnvelope` and a non-2xx/`success:false` throws - `TicketApiException` with a readable message. ⚠️ The endpoint shapes were taken from the - `Bandel.APIs` source, never confirmed against a live call — see `docs/open.md`. + `TicketApiException` with a readable message. All four are **live-verified (2026-09-01)** against + `http://192.168.45.161`. + ⚠️ `PATCH /api/Ticket/{id}/status` needs **both** `statusId` and `statusName` — omitting the name + is a 400 (`"The StatusName field is required."`) and no status is written. The name is *not* + validated against the id (`statusId` wins, any non-empty string passes), but it must be there; + `TicketStatusMap.NameOf` supplies the canonical name. + ⚠️ The base URL must be the **IP** — `bandelapis.fb-tuning.local` still refuses the connection. ## Notes diff --git a/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs index 739d8c1b..3e08b322 100644 --- a/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs +++ b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs @@ -49,10 +49,19 @@ public sealed class BandelTicketClient => await SendAsync>( HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct); + /// + /// ⚠️ Die Route verlangt statusName zusätzlich zu statusId, sonst 400 + /// ("The StatusName field is required."). Der Wert wird nicht gegen die Id validiert — + /// gesetzt wird immer nach statusId — er darf aber nicht fehlen oder leer sein. + /// Live geprüft am 2026-09-01. + /// public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct) => await SendAsync( HttpMethod.Patch, $"/api/Ticket/{ticketId}/status", - new StringContent($"{{\"statusId\":{statusId}}}", Encoding.UTF8, "application/json"), ct); + new StringContent( + $"{{\"statusId\":{statusId},\"statusName\":\"{TicketStatusMap.NameOf(statusId)}\"}}", + Encoding.UTF8, "application/json"), + ct); private async Task SendAsync(HttpMethod method, string path, HttpContent? body, CancellationToken ct) { diff --git a/src/ClaudeDo.Worker/Tickets/TicketDtos.cs b/src/ClaudeDo.Worker/Tickets/TicketDtos.cs index 55b40d81..b3a720a0 100644 --- a/src/ClaudeDo.Worker/Tickets/TicketDtos.cs +++ b/src/ClaudeDo.Worker/Tickets/TicketDtos.cs @@ -16,6 +16,11 @@ public sealed record TicketIdentity( [property: JsonPropertyName("scopes")] IReadOnlyList Scopes); /// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht). +/// ⚠️ / sind live durchgehend null (geprüft über +/// alle 63 Tickets der Abteilung Entwicklung, 2026-09-01). Die echten Zuweisungen stehen in +/// ; nur dessen assigneeName hat dasselbe Format wie +/// (mika.kuns). personInChargeName wäre die +/// falsche Quelle — dort steht mal der Login, mal der Anzeigename ("Mika Kuns"). public sealed record TicketBoardItem( [property: JsonPropertyName("id")] int Id, [property: JsonPropertyName("title")] string Title, @@ -23,6 +28,11 @@ public sealed record TicketBoardItem( [property: JsonPropertyName("statusId")] int StatusId, [property: JsonPropertyName("statusName")] string? StatusName, [property: JsonPropertyName("assigneeID")] int? AssigneeId, + [property: JsonPropertyName("assigneeName")] string? AssigneeName, + [property: JsonPropertyName("ticketAssignees")] IReadOnlyList? TicketAssignees = null); + +public sealed record TicketAssignee( + [property: JsonPropertyName("assigneeId")] int AssigneeId, [property: JsonPropertyName("assigneeName")] string? AssigneeName); /// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten. diff --git a/src/ClaudeDo.Worker/Tickets/TicketImportService.cs b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs index 8379a50f..985ffc73 100644 --- a/src/ClaudeDo.Worker/Tickets/TicketImportService.cs +++ b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs @@ -16,7 +16,9 @@ public sealed record TicketImportResult(int Examined, int Created); /// public sealed class TicketImportService { - private const int StatusOffen = 1; + // Offen UND In Bearbeitung: ein Ticket, an dem schon gearbeitet wird, ist genauso ein + // Arbeitsauftrag wie ein neues. Fertig/Archiviert bleiben draußen. + private static readonly int[] ImportableStatuses = [1, TicketStatusMap.InBearbeitung]; private readonly TicketClientFactory _clients; private readonly IDbContextFactory _dbFactory; @@ -48,8 +50,8 @@ public sealed class TicketImportService var board = await client.GetProjectBoardAsync(projectId, ct); var mine = board - .Where(t => t.StatusId == StatusOffen) - .Where(t => string.Equals(t.AssigneeName, identity.UserName, StringComparison.OrdinalIgnoreCase)) + .Where(t => ImportableStatuses.Contains(t.StatusId)) + .Where(t => IsAssignedTo(t, identity.UserName)) .ToList(); var known = await ctx.Tasks.AsNoTracking() @@ -87,6 +89,16 @@ public sealed class TicketImportService return new TicketImportResult(board.Count, created); } + /// + /// Zuweisung ausschließlich über ticketAssignees — das flache assigneeName ist + /// live auf jedem Ticket null (siehe ). Ein Ticket kann mehrere + /// Zuweisungen haben; eine davon muss der Token-Inhaber sein. + /// + private static bool IsAssignedTo(TicketBoardItem ticket, string userName) + => ticket.TicketAssignees?.Any( + a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase)) + ?? false; + private static string BuildDescription(TicketBoardItem ticket) { var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n"; diff --git a/src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs b/src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs index 460b73f5..ea79e119 100644 --- a/src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs +++ b/src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs @@ -13,6 +13,20 @@ public static class TicketStatusMap private const string BandelPrefix = "bandel:"; + /// + /// Namen wie von GET /api/ticketsystem/statuses geliefert. Braucht der PATCH-Body, + /// der statusName als required-Feld verlangt (siehe + /// ). Nie leer — ein leerer Wert wäre ein 400. + /// + public static string NameOf(int statusId) => statusId switch + { + 1 => "Offen", + InBearbeitung => "InBearbeitung", + Fertig => "Fertig", + 4 => "Archiviert", + _ => "Keine", + }; + /// /// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll. /// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs index d4a74e79..264e6f86 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/BandelTicketClientTests.cs @@ -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] diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs index 1d11c46d..08b390b2 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs @@ -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 } /// - /// 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. /// 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"} """); }); diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusMapTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusMapTests.cs index 762b18bd..f062767a 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusMapTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusMapTests.cs @@ -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()) + { + 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)]