diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index fde7e44a..1268c3ad 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -125,7 +125,8 @@ builder.Services.AddSingleton(sp => new TaskStateService( sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService(), - sp.GetRequiredService>())); + sp.GetRequiredService>(), + sp.GetRequiredService())); // Agent file management. var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents"); @@ -219,6 +220,7 @@ builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("tick builder.Services.AddSingleton(); builder.Services.AddHttpClient("tickets"); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); if (cfg.OnlineInbox.Enabled) { diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 2a683bb8..b58e70be 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -5,6 +5,7 @@ using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Queue; +using ClaudeDo.Worker.Tickets; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -20,6 +21,7 @@ public sealed class TaskStateService : ITaskStateService private readonly Func _mergeState; private readonly BaseDirtyChecker _baseDirtyChecker; private readonly ILogger _logger; + private readonly TicketStatusSync _ticketSync; public TaskStateService( IDbContextFactory dbFactory, @@ -29,7 +31,8 @@ public sealed class TaskStateService : ITaskStateService RunCancellationRegistry runCancels, Func mergeState, BaseDirtyChecker baseDirtyChecker, - ILogger logger) + ILogger logger, + TicketStatusSync ticketSync) { _dbFactory = dbFactory; _broadcaster = broadcaster; @@ -39,6 +42,7 @@ public sealed class TaskStateService : ITaskStateService _mergeState = mergeState; _baseDirtyChecker = baseDirtyChecker; _logger = logger; + _ticketSync = ticketSync; } public async Task EnqueueAsync(string taskId, CancellationToken ct) @@ -59,7 +63,7 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(false, "Task not found or already running."); _waker.Wake(); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); // Heads-up only: a worktree forks from the commit tip (WorktreeManager.ResolveBaseCommitAsync), // not the working tree, so uncommitted changes sitting in the list's repo right now are @@ -92,7 +96,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task already running or not found."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -112,7 +116,7 @@ public sealed class TaskStateService : ITaskStateService } await OnChildTerminalAsync(taskId, TaskStatus.Done); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -129,7 +133,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task not running; cannot submit for review."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -149,7 +153,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task is not Idle or Failed; cannot submit for review."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -166,7 +170,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task not running; cannot submit for children."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -186,7 +190,7 @@ public sealed class TaskStateService : ITaskStateService } await OnChildTerminalAsync(taskId, TaskStatus.Done); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -208,7 +212,7 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(false, "Task is not waiting for review; cannot reject."); _waker.Wake(); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -224,7 +228,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task is not waiting for review; cannot park."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -265,7 +269,7 @@ public sealed class TaskStateService : ITaskStateService } await OnChildTerminalAsync(taskId, TaskStatus.Failed); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -329,9 +333,9 @@ public sealed class TaskStateService : ITaskStateService _runCancels.TryCancel(childId); await OnChildTerminalAsync(taskId, TaskStatus.Cancelled); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); foreach (var childId in cancelledChildIds) - await _broadcaster.TaskUpdated(childId); + await NotifyAsync(childId); return new TransitionResult(true, null); } @@ -349,7 +353,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task is running; cannot reset."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -375,7 +379,7 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(false, "Task not found."); if (status == TaskStatus.Queued) _waker.Wake(); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -396,7 +400,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task is running or no longer exists; cannot mark done."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -410,7 +414,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task is not Done; cannot unmark."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -426,7 +430,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task is not queued; cannot remove from queue."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -443,7 +447,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task not in plannable state."); - await _broadcaster.TaskUpdated(parentId); + await NotifyAsync(parentId); return new TransitionResult(true, null); } @@ -464,7 +468,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "No active planning session."); - await _broadcaster.TaskUpdated(parentId); + await NotifyAsync(parentId); return new TransitionResult(true, null); } @@ -478,7 +482,7 @@ public sealed class TaskStateService : ITaskStateService if (affected == 0) return new TransitionResult(false, "Task not found."); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -493,7 +497,7 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(false, "Task not found."); _waker.Wake(); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -537,7 +541,7 @@ public sealed class TaskStateService : ITaskStateService // Clearing a dependency may free up a Queued task the picker was skipping. if (dependsOnTaskId is null) _waker.Wake(); - await _broadcaster.TaskUpdated(taskId); + await NotifyAsync(taskId); return new TransitionResult(true, null); } @@ -672,6 +676,17 @@ public sealed class TaskStateService : ITaskStateService .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.WaitingForReview) .SetProperty(t => t.Result, newResult), CancellationToken.None); - await _broadcaster.TaskUpdated(parentId); + await NotifyAsync(parentId); + } + + /// + /// Der einzige Broadcast-Pfad dieser Klasse. Zusätzlich zum UI-Update meldet er den neuen + /// Status ans verknüpfte Ticket. Nie direkt _broadcaster.TaskUpdated aufrufen — sonst + /// fällt genau dieser Übergang aus dem Ticket-Sync heraus. + /// + private async Task NotifyAsync(string taskId) + { + await _broadcaster.TaskUpdated(taskId); + await _ticketSync.SyncAsync(taskId, CancellationToken.None); } } diff --git a/src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs b/src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs new file mode 100644 index 00000000..460b73f5 --- /dev/null +++ b/src/ClaudeDo.Worker/Tickets/TicketStatusMap.cs @@ -0,0 +1,36 @@ +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tickets; + +/// +/// Reine Abbildung Task-Status → Ticket-Status. Ticket-Status im Bandel-System: +/// 0 Keine, 1 Offen, 2 InBearbeitung, 3 Fertig, 4 Archiviert. +/// +public static class TicketStatusMap +{ + public const int InBearbeitung = 2; + public const int Fertig = 3; + + private const string BandelPrefix = "bandel:"; + + /// + /// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll. + /// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen + /// das Ticket bewusst auf InBearbeitung stehen — die Arbeit ist angefangen, nicht zurückgegeben. + /// + public static int? ToTicketStatus(TaskStatus status) => status switch + { + TaskStatus.Running or TaskStatus.WaitingForReview => InBearbeitung, + TaskStatus.Done => Fertig, + _ => null, + }; + + /// Ticket-Id aus einem "bandel:<id>"-Ref, oder null bei jedem anderen Format. + public static int? ParseBandelTicketId(string? ticketRef) + { + if (string.IsNullOrWhiteSpace(ticketRef)) return null; + if (!ticketRef.StartsWith(BandelPrefix, StringComparison.OrdinalIgnoreCase)) return null; + var raw = ticketRef[BandelPrefix.Length..]; + return int.TryParse(raw, out var id) && id > 0 ? id : null; + } +} diff --git a/src/ClaudeDo.Worker/Tickets/TicketStatusSync.cs b/src/ClaudeDo.Worker/Tickets/TicketStatusSync.cs new file mode 100644 index 00000000..a1e69260 --- /dev/null +++ b/src/ClaudeDo.Worker/Tickets/TicketStatusSync.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Tickets; + +/// +/// Meldet den Task-Status ans verknüpfte Ticket zurück. Hängt an genau einem Punkt im System: +/// TaskStateService.NotifyAsync, dem einzigen Ort, an dem Statuswechsel zusammenlaufen. +/// +/// Zwei Invarianten: +/// * Wirft nie. Ein Ticketsystem-Ausfall darf keinen Statuswechsel und keine Queue anhalten. +/// * Kostet nichts, wenn nichts eingerichtet ist — IsConfigured bricht vor jeder DB-Abfrage ab. +/// +public sealed class TicketStatusSync +{ + private readonly TicketSystemConfig _config; + private readonly TicketClientFactory _clients; + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + + // taskId → zuletzt geschriebener Ticket-Status. Verhindert den redundanten PATCH bei + // Running → WaitingForReview (beide sind "InBearbeitung"). Bewusst nur im Prozessspeicher: + // nach einem Neustart ist ein überzähliger PATCH harmlos. + private readonly ConcurrentDictionary _lastWritten = new(); + + public TicketStatusSync( + TicketSystemConfig config, + TicketClientFactory clients, + IDbContextFactory dbFactory, + ILogger logger) + { + _config = config; + _clients = clients; + _dbFactory = dbFactory; + _logger = logger; + } + + public async Task SyncAsync(string taskId, CancellationToken ct) + { + if (!_config.IsConfigured) return; + + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var row = await ctx.Tasks.AsNoTracking() + .Where(t => t.Id == taskId) + .Select(t => new { t.Status, t.TicketRef }) + .FirstOrDefaultAsync(ct); + + if (row?.TicketRef is null) return; + + var ticketId = TicketStatusMap.ParseBandelTicketId(row.TicketRef); + if (ticketId is null) return; + + var target = TicketStatusMap.ToTicketStatus(row.Status); + if (target is null) return; + + if (_lastWritten.TryGetValue(taskId, out var previous) && previous == target.Value) return; + + var client = _clients.Create(); + if (client is null) return; + + await client.SetStatusAsync(ticketId.Value, target.Value, ct); + _lastWritten[taskId] = target.Value; + + _logger.LogInformation( + "Ticket {ticket_id} auf Status {ticket_status} gesetzt (Task {task_id})", + ticketId.Value, target.Value, taskId); + } + catch (Exception ex) + { + // Serilog Warn landet über BroadcastLogSink im Footer-Log-Strip. + _logger.LogWarning(ex, + "Ticket-Status für Task {task_id} konnte nicht geschrieben werden: {reason}", + taskId, ex.Message); + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs index d9595326..afcb75d7 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/TaskStateServiceBuilder.cs @@ -1,9 +1,11 @@ using ClaudeDo.Data; +using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Tickets; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; @@ -31,6 +33,18 @@ public static class TaskStateServiceBuilder var waker = new CountingQueueWaker(); var runCancels = new RunCancellationRegistry(NullLogger.Instance); + // Unconfigured on purpose: no base URL, no PAT file at this (unused) path — IsConfigured + // is false, so TicketStatusSync.SyncAsync short-circuits before touching the DB or the + // network. Tests that care about ticket sync build TicketStatusSync themselves. + var ticketConfig = new TicketSystemConfig( + new WorkerConfig(), + new TicketPatStore(new DpapiTokenStore(Path.Combine(Path.GetTempPath(), $"cdo-unused-pat-{Guid.NewGuid():N}")))); + var ticketSync = new TicketStatusSync( + ticketConfig, + new TicketClientFactory(ticketConfig, new NoopHttpClientFactory(), NullLogger.Instance), + dbFactory, + NullLogger.Instance); + TaskStateService? state = null; var chain = new PlanningChainCoordinator(dbFactory, () => state!); state = new TaskStateService( @@ -41,7 +55,8 @@ public static class TaskStateServiceBuilder runCancels, mergeState ?? (() => NoActiveMergeState.Instance), baseDirtyChecker ?? new BaseDirtyChecker(new ClaudeDo.Data.Git.GitService(), NullLogger.Instance), - NullLogger.Instance); + NullLogger.Instance, + ticketSync); return new Built(state, chain, hub, () => waker.Count, waker, runCancels); } @@ -53,6 +68,14 @@ file sealed class NoActiveMergeState : IActiveMergeState public bool HasActiveMerge(string taskId) => false; } +// Never actually invoked: TicketSystemConfig.IsConfigured is false in every TaskStateServiceBuilder +// test, so TicketClientFactory.Create() returns null before this factory would be called. +file sealed class NoopHttpClientFactory : IHttpClientFactory +{ + public HttpClient CreateClient(string name) => throw new InvalidOperationException( + "Unexpected: ticket sync is not configured in tests built via TaskStateServiceBuilder."); +} + public sealed class CountingQueueWaker : IQueueWaker { private int _count; diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusMapTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusMapTests.cs new file mode 100644 index 00000000..762b18bd --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusMapTests.cs @@ -0,0 +1,46 @@ +using ClaudeDo.Worker.Tickets; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class TicketStatusMapTests +{ + [Theory] + [InlineData(TaskStatus.Running, 2)] + [InlineData(TaskStatus.WaitingForReview, 2)] + [InlineData(TaskStatus.Done, 3)] + public void Maps_the_three_states_we_report(TaskStatus status, int expected) + => Assert.Equal(expected, TicketStatusMap.ToTicketStatus(status)); + + [Theory] + [InlineData(TaskStatus.Idle)] + [InlineData(TaskStatus.Queued)] + [InlineData(TaskStatus.WaitingForChildren)] + [InlineData(TaskStatus.Failed)] + [InlineData(TaskStatus.Cancelled)] + public void Everything_else_writes_nothing(TaskStatus status) + => Assert.Null(TicketStatusMap.ToTicketStatus(status)); + + [Fact] + public void Never_writes_Offen() + { + foreach (var status in Enum.GetValues()) + Assert.NotEqual(1, TicketStatusMap.ToTicketStatus(status)); + } + + [Theory] + [InlineData("bandel:1234", 1234)] + [InlineData("BANDEL:7", 7)] + public void Parses_a_bandel_ref(string reference, int expected) + => Assert.Equal(expected, TicketStatusMap.ParseBandelTicketId(reference)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("1234")] + [InlineData("jira:ABC-1")] + [InlineData("bandel:")] + [InlineData("bandel:abc")] + public void Rejects_anything_else(string? reference) + => Assert.Null(TicketStatusMap.ParseBandelTicketId(reference)); +} diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusSyncTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusSyncTests.cs new file mode 100644 index 00000000..56b4b891 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketStatusSyncTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using System.Text.RegularExpressions; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Worker; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tickets; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Tickets; + +public sealed class TicketStatusSyncTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly TestDbContextFactory _factory; + private readonly string _listId = Guid.NewGuid().ToString(); + private readonly List _patDirs = new(); + private int _numberSeed; + + public TicketStatusSyncTests() + { + _factory = _db.CreateFactory(); + using var ctx = _factory.CreateDbContext(); + ctx.Lists.Add(new ListEntity + { + Id = _listId, + Name = "Test", + CreatedAt = DateTime.UtcNow, + DefaultCommitType = "chore", + }); + ctx.SaveChanges(); + } + + public void Dispose() + { + _db.Dispose(); + foreach (var dir in _patDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task Writes_InBearbeitung_when_the_task_starts_running() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Equal((12, 2), Assert.Single(calls)); + } + + [Fact] + public async Task Writes_Fertig_when_the_task_is_done() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Done, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Equal((12, 3), Assert.Single(calls)); + } + + [Fact] + public async Task Does_nothing_without_a_ticket_ref() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: null); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Empty(calls); + } + + [Fact] + public async Task Does_nothing_when_not_configured() + { + var (sync, calls) = Harness(configured: false); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Empty(calls); + } + + [Fact] + public async Task Does_nothing_for_an_unmapped_status() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Queued, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Empty(calls); + } + + [Fact] + public async Task Skips_the_redundant_patch_from_Running_to_WaitingForReview() + { + var (sync, calls) = Harness(configured: true); + var taskId = await SeedTaskAsync(TaskStatus.Running, ticketRef: "bandel:12"); + + await sync.SyncAsync(taskId, CancellationToken.None); + await SetStatusAsync(taskId, TaskStatus.WaitingForReview); + await sync.SyncAsync(taskId, CancellationToken.None); + + Assert.Single(calls); // beide mappen auf 2 — nur ein PATCH + } + + [Fact] + public async Task Api_failure_does_not_propagate() + { + var (sync, _) = Harness(configured: true, throwOnPatch: true); + var taskId = await SeedTaskAsync(TaskStatus.Done, ticketRef: "bandel:12"); + + // Darf NICHT werfen: ein Ticketsystem-Ausfall hält die Queue nicht an. + await sync.SyncAsync(taskId, CancellationToken.None); + } + + // ── Helfer ────────────────────────────────────────────────────────────── + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly StubHandler _handler; + public StubHttpClientFactory(StubHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler); + } + + private (TicketStatusSync Sync, List<(int TicketId, int StatusId)> Calls) Harness( + bool configured, bool throwOnPatch = false) + { + var calls = new List<(int TicketId, int StatusId)>(); + StubHandler handler = null!; + handler = new StubHandler(request => + { + if (throwOnPatch) + return (HttpStatusCode.InternalServerError, """{"success":false,"data":null,"message":"boom"}"""); + + var urlMatch = Regex.Match(request.RequestUri!.AbsolutePath, @"/api/Ticket/(\d+)/status"); + var ticketId = int.Parse(urlMatch.Groups[1].Value); + var bodyMatch = Regex.Match(handler.Bodies[^1], "\"statusId\":(\\d+)"); + var statusId = int.Parse(bodyMatch.Groups[1].Value); + calls.Add((ticketId, statusId)); + + return (HttpStatusCode.OK, """{"success":true,"data":{},"message":"ok"}"""); + }); + + var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketsync-" + Guid.NewGuid().ToString("N")); + _patDirs.Add(patDir); + var patStore = new TicketPatStore(new DpapiTokenStore(Path.Combine(patDir, "ticket.pat"))); + var worker = new WorkerConfig(); + if (configured) + { + worker.TicketApiBaseUrl = "http://api.local"; + patStore.Store.Save("tsp_x"); + } + + var config = new TicketSystemConfig(worker, patStore); + var clients = new TicketClientFactory(config, new StubHttpClientFactory(handler), NullLogger.Instance); + var sync = new TicketStatusSync(config, clients, _factory, NullLogger.Instance); + return (sync, calls); + } + + private async Task SeedTaskAsync(TaskStatus status, string? ticketRef) + { + var id = Guid.NewGuid().ToString(); + await using var ctx = _factory.CreateDbContext(); + ctx.Tasks.Add(new TaskEntity + { + Id = id, + ListId = _listId, + Title = "task", + Number = ++_numberSeed, + Status = status, + CreatedAt = DateTime.UtcNow, + TicketRef = ticketRef, + }); + await ctx.SaveChangesAsync(); + return id; + } + + private async Task SetStatusAsync(string taskId, TaskStatus status) + { + await using var ctx = _factory.CreateDbContext(); + await ctx.Tasks.Where(t => t.Id == taskId).ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status)); + } +}