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
+25 -23
View File
@@ -46,33 +46,35 @@ verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran
## Offene Verifikation (2026-08-27, Ticketsystem-Anbindung) ## Offene Verifikation (2026-08-27, Ticketsystem-Anbindung)
> **PAUSIERT.** `TicketSettingsTabViewModel.FeatureEnabled = false` versteckt Settings-Tab, > **Reaktiviert am 2026-09-01.** `FeatureEnabled = true`. Die API läuft unter
> Projekt-Dropdown und Kontextmenü-Eintrag; zum Reaktivieren die Konstante umlegen. Grund: die > `http://192.168.45.161` — der Hostname `bandelapis.fb-tuning.local` lehnt die Verbindung
> API ist unter `http://bandelapis.fb-tuning.local` nicht erreichbar (*connection actively > weiterhin ab, die Base-URL muss also die **IP** sein.
> 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.
Tasks 1–9 sind implementiert und gemergt (Build + Tests grün, `_broadcaster.TaskUpdated` genau Alle vier Routen sind jetzt live gegen die echte API geprüft. Die aus dem `Bandel.APIs`-Quellcode
einmal in `TaskStateService.cs`), aber **kein Smoke-Test gegen die echte Bandel-API ist abgeleiteten Shapes stimmten bis auf zwei Punkte, beide gefixt und mit Tests belegt:
gelaufen**. Die fünf Punkte aus der „Manuellen Verifikation" des Specs
(`docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md`):
- [ ] PAT in den Settings hinterlegen, „Verbindung testen" → korrekter Benutzername + Scopes. - `PATCH /api/Ticket/{id}/status` braucht **`statusId` UND `statusName`** — ohne den Namen 400
- [ ] Liste mit Projekt `393 Bandel.LagerApp` verknüpfen, „Update from Ticketsystem" → nur („The StatusName field is required."), also hätte *jede* Write-Back fehlgeschlagen. Der Name
eigene, offene Tickets erscheinen als Tasks. wird nicht gegen die Id validiert, muss aber nicht-leer sein (`TicketStatusMap.NameOf`).
- [ ] Einen Task laufen lassen → Ticket steht im Ticketsystem auf `InBearbeitung`. - 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`. - [ ] Approve/Merge → Ticket steht auf `Fertig`.
- [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr. - [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr.
- [ ] Sichtprüfung des Settings-Tabs + des Ticket-Projekt-Dropdowns (nie gerendert gesehen).
**Wichtig:** die Response-Shapes der Bandel-API (`BandelEnvelope<T>`, `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.
## Offene Verifikation (2026-07-27) ## Offene Verifikation (2026-07-27)
@@ -8,20 +8,17 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class TicketSettingsTabViewModel : ViewModelBase public sealed partial class TicketSettingsTabViewModel : ViewModelBase
{ {
/// <summary> /// <summary>
/// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI — hier auf <c>true</c> /// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI, wie bei
/// für die Reaktivierung, wie bei <c>SettingsModalViewModel.ShowOnlineInbox</c>. /// <c>SettingsModalViewModel.ShowOnlineInbox</c>. 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 /// Pausiert am 2026-08-27 (API unter dem Hostnamen nicht erreichbar), reaktiviert am
/// erreichbar (Verbindung aktiv abgelehnt — vermutlich fehlende IIS-Hostname-Bindung oder /// 2026-09-01: die API läuft unter <c>http://192.168.45.161</c> und alle vier Routen sind
/// falscher Port, siehe deploy/deploy-iis-winrm.ps1). Solange das offen ist, würde der Tab /// live verifiziert. Base-URL ist deshalb eine IP, kein Hostname — <c>bandelapis.fb-tuning.local</c>
/// nur eine Einrichtung anbieten, die nicht funktionieren kann. /// lehnt die Verbindung weiterhin ab.
///
/// 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.
/// </summary> /// </summary>
public const bool FeatureEnabled = false; public const bool FeatureEnabled = true;
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
+14 -4
View File
@@ -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. 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 - **Import** (`TicketImportService`, hub `ImportTickets`) — per-list, manual only (no polling): pulls
the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets that are `StatusId the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets whose `StatusId` is
== 1` (Offen) *and* assigned to the PAT owner (case-insensitive name match), skips any `TicketRef` `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` 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 (so it gets a `Number` like every other task). Already-imported tickets are never updated — a
re-import would overwrite task notes. 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 - **Write-back** (`TicketStatusSync`) hangs off `TaskStateService.NotifyAsync` — the one place every
status transition passes through. Mapping: `Running`/`WaitingForReview` → ticket status `2 status transition passes through. Mapping: `Running`/`WaitingForReview` → ticket status `2
InBearbeitung`, `Done` → `3 Fertig`, everything else writes nothing (`1 Offen` is never written 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 - `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 /api/Navigation/sidebar`, `GET /api/Board/project/{id}`, `PATCH /api/Ticket/{id}/status`); every
response is unwrapped from `BandelEnvelope<T>` and a non-2xx/`success:false` throws response is unwrapped from `BandelEnvelope<T>` and a non-2xx/`success:false` throws
`TicketApiException` with a readable message. ⚠️ The endpoint shapes were taken from the `TicketApiException` with a readable message. All four are **live-verified (2026-09-01)** against
`Bandel.APIs` source, never confirmed against a live call — see `docs/open.md`. `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 ## Notes
@@ -49,10 +49,19 @@ public sealed class BandelTicketClient
=> await SendAsync<List<TicketBoardItem>>( => await SendAsync<List<TicketBoardItem>>(
HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct); HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct);
/// <summary>
/// ⚠️ Die Route verlangt <c>statusName</c> zusätzlich zu <c>statusId</c>, sonst 400
/// ("The StatusName field is required."). Der Wert wird nicht gegen die Id validiert —
/// gesetzt wird immer nach <c>statusId</c> — er darf aber nicht fehlen oder leer sein.
/// Live geprüft am 2026-09-01.
/// </summary>
public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct) public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct)
=> await SendAsync<JsonElement>( => await SendAsync<JsonElement>(
HttpMethod.Patch, $"/api/Ticket/{ticketId}/status", 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<T> SendAsync<T>(HttpMethod method, string path, HttpContent? body, CancellationToken ct) private async Task<T> SendAsync<T>(HttpMethod method, string path, HttpContent? body, CancellationToken ct)
{ {
+10
View File
@@ -16,6 +16,11 @@ public sealed record TicketIdentity(
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes); [property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
/// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht). /// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht).
/// ⚠️ <see cref="AssigneeId"/>/<see cref="AssigneeName"/> sind live durchgehend null (geprüft über
/// alle 63 Tickets der Abteilung Entwicklung, 2026-09-01). Die echten Zuweisungen stehen in
/// <see cref="TicketAssignees"/>; nur dessen <c>assigneeName</c> hat dasselbe Format wie
/// <see cref="TicketIdentity.UserName"/> (<c>mika.kuns</c>). <c>personInChargeName</c> wäre die
/// falsche Quelle — dort steht mal der Login, mal der Anzeigename ("Mika Kuns").
public sealed record TicketBoardItem( public sealed record TicketBoardItem(
[property: JsonPropertyName("id")] int Id, [property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("title")] string Title,
@@ -23,6 +28,11 @@ public sealed record TicketBoardItem(
[property: JsonPropertyName("statusId")] int StatusId, [property: JsonPropertyName("statusId")] int StatusId,
[property: JsonPropertyName("statusName")] string? StatusName, [property: JsonPropertyName("statusName")] string? StatusName,
[property: JsonPropertyName("assigneeID")] int? AssigneeId, [property: JsonPropertyName("assigneeID")] int? AssigneeId,
[property: JsonPropertyName("assigneeName")] string? AssigneeName,
[property: JsonPropertyName("ticketAssignees")] IReadOnlyList<TicketAssignee>? TicketAssignees = null);
public sealed record TicketAssignee(
[property: JsonPropertyName("assigneeId")] int AssigneeId,
[property: JsonPropertyName("assigneeName")] string? AssigneeName); [property: JsonPropertyName("assigneeName")] string? AssigneeName);
/// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten. /// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten.
@@ -16,7 +16,9 @@ public sealed record TicketImportResult(int Examined, int Created);
/// </summary> /// </summary>
public sealed class TicketImportService 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 TicketClientFactory _clients;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -48,8 +50,8 @@ public sealed class TicketImportService
var board = await client.GetProjectBoardAsync(projectId, ct); var board = await client.GetProjectBoardAsync(projectId, ct);
var mine = board var mine = board
.Where(t => t.StatusId == StatusOffen) .Where(t => ImportableStatuses.Contains(t.StatusId))
.Where(t => string.Equals(t.AssigneeName, identity.UserName, StringComparison.OrdinalIgnoreCase)) .Where(t => IsAssignedTo(t, identity.UserName))
.ToList(); .ToList();
var known = await ctx.Tasks.AsNoTracking() var known = await ctx.Tasks.AsNoTracking()
@@ -87,6 +89,16 @@ public sealed class TicketImportService
return new TicketImportResult(board.Count, created); return new TicketImportResult(board.Count, created);
} }
/// <summary>
/// Zuweisung ausschließlich über <c>ticketAssignees</c> — das flache <c>assigneeName</c> ist
/// live auf jedem Ticket null (siehe <see cref="TicketBoardItem"/>). Ein Ticket kann mehrere
/// Zuweisungen haben; eine davon muss der Token-Inhaber sein.
/// </summary>
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) private static string BuildDescription(TicketBoardItem ticket)
{ {
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n"; var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
@@ -13,6 +13,20 @@ public static class TicketStatusMap
private const string BandelPrefix = "bandel:"; private const string BandelPrefix = "bandel:";
/// <summary>
/// Namen wie von <c>GET /api/ticketsystem/statuses</c> geliefert. Braucht der PATCH-Body,
/// der <c>statusName</c> als required-Feld verlangt (siehe
/// <see cref="BandelTicketClient.SetStatusAsync"/>). Nie leer — ein leerer Wert wäre ein 400.
/// </summary>
public static string NameOf(int statusId) => statusId switch
{
1 => "Offen",
InBearbeitung => "InBearbeitung",
Fertig => "Fertig",
4 => "Archiviert",
_ => "Keine",
};
/// <summary> /// <summary>
/// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll. /// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll.
/// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen /// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen
@@ -55,7 +55,7 @@ public sealed class BandelTicketClientTests
public async Task GetProjectBoard_maps_items() public async Task GetProjectBoard_maps_items()
{ {
var h = new StubHandler(_ => (HttpStatusCode.OK, 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); var items = await Make(h).GetProjectBoardAsync(393, CancellationToken.None);
@@ -63,20 +63,25 @@ public sealed class BandelTicketClientTests
Assert.Equal(12, item.Id); Assert.Equal(12, item.Id);
Assert.Equal("Fix X", item.Title); Assert.Equal("Fix X", item.Title);
Assert.Equal(1, item.StatusId); 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()); Assert.Equal("http://api.local/api/Board/project/393", h.Requests[0].RequestUri!.ToString());
} }
[Fact] // statusName ist auf der echten Route ein required-Feld: fehlt es, antwortet die API mit
public async Task SetStatus_patches_the_right_url_and_body() // 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"}""")); 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(HttpMethod.Patch, h.Requests[0].Method);
Assert.Equal("http://api.local/api/Ticket/12/status", h.Requests[0].RequestUri!.ToString()); 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] [Fact]
@@ -28,15 +28,20 @@ public sealed class TicketImportServiceTests : IDisposable
} }
[Fact] [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(); var (import, ctx) = await HarnessAsync();
await SeedExistingTaskAsync(ctx, listId: ListId, ticketRef: "bandel:4");
var result = await import.ImportAsync(ListId, CancellationToken.None); var result = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(4, result.Examined); Assert.Equal(7, result.Examined);
Assert.Equal(1, result.Created); 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(); var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync();
Assert.Equal("Fix X", created.Title); Assert.Equal("Fix X", created.Title);
@@ -45,6 +50,30 @@ public sealed class TicketImportServiceTests : IDisposable
Assert.Contains("Ticket #1", created.Description); 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] [Fact]
public async Task Assignee_match_is_case_insensitive() 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); var result = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(1, result.Created); Assert.Equal(2, result.Created);
} }
[Fact] [Fact]
@@ -64,7 +93,7 @@ public sealed class TicketImportServiceTests : IDisposable
var first = await import.ImportAsync(ListId, CancellationToken.None); var first = await import.ImportAsync(ListId, CancellationToken.None);
var second = 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(0, second.Created);
Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1")); Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1"));
} }
@@ -106,11 +135,11 @@ public sealed class TicketImportServiceTests : IDisposable
} }
/// <summary> /// <summary>
/// Board liefert immer vier Tickets: #1 offen/mein (importierbar), #2 offen/fremd (Assignee- /// Board liefert immer sieben Tickets, je einer pro Filterzweig:
/// Filter), #3 in Bearbeitung/mein (Status-Filter), #4 offen/mein aber bereits importiert /// #1 offen/mein → importierbar · #2 offen/fremd → Assignee-Filter ·
/// (Dedup-Filter — hier vorab als bestehender Task angelegt, damit alle Default-Aufrufe /// #3 in Bearbeitung/mein → importierbar · #4 offen/mein, bereits importiert → Dedup
/// dieselbe, in sich konsistente Ausgangslage haben; Test 1 legt ihn zusätzlich explizit an, /// (vorab als Task angelegt) · #5 offen, nur flaches assigneeName gesetzt → muss draußen
/// was denselben Zustand nur redundant reproduziert). /// bleiben · #6 fertig/mein → Status-Filter · #7 offen, ticketAssignees null → draußen.
/// </summary> /// </summary>
private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync( private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync(
bool configured = true, 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, $$""" return (HttpStatusCode.OK, $$"""
{"success":true,"data":[ {"success":true,"data":[
{"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"}, {"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
{"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":8,"assigneeName":"jemand.anders"}, "ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":3,"title":"InProgress","description":null,"statusId":2,"statusName":"InBearbeitung","assigneeID":7,"assigneeName":"{{boardAssignee}}"}, {"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
{"id":4,"title":"AlreadyImported","description":null,"statusId":1,"statusName":"Offen","assigneeID":7,"assigneeName":"{{boardAssignee}}"} "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"} ],"message":"ok"}
"""); """);
}); });
@@ -28,6 +28,27 @@ public sealed class TicketStatusMapTests
Assert.NotEqual(1, TicketStatusMap.ToTicketStatus(status)); 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] [Theory]
[InlineData("bandel:1234", 1234)] [InlineData("bandel:1234", 1234)]
[InlineData("BANDEL:7", 7)] [InlineData("BANDEL:7", 7)]