feat(worker): Ticket-Status-Rueckmeldung ueber TaskStateService
This commit is contained in:
@@ -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<string> _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<BandelTicketClient>.Instance);
|
||||
var sync = new TicketStatusSync(config, clients, _factory, NullLogger<TicketStatusSync>.Instance);
|
||||
return (sync, calls);
|
||||
}
|
||||
|
||||
private async Task<string> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user