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