using System.Net; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; 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 TicketImportServiceTests : IDisposable { private const string ListId = "l1"; private readonly List _dbs = new(); private readonly List _patDirs = new(); public void Dispose() { foreach (var db in _dbs) db.Dispose(); foreach (var dir in _patDirs) { try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } } } [Fact] public async Task Imports_only_open_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); var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync(); Assert.Equal("Fix X", created.Title); Assert.Equal(TaskStatus.Idle, created.Status); Assert.Equal("ticketsystem", created.CreatedBy); Assert.Contains("Ticket #1", created.Description); } [Fact] public async Task Assignee_match_is_case_insensitive() { // Board liefert "Mika.Kuns", /pat/me liefert "mika.kuns" var (import, _) = await HarnessAsync(boardAssignee: "Mika.Kuns", identityUser: "mika.kuns"); var result = await import.ImportAsync(ListId, CancellationToken.None); Assert.Equal(1, result.Created); } [Fact] public async Task Running_twice_creates_no_duplicates() { var (import, ctx) = await HarnessAsync(); var first = await import.ImportAsync(ListId, CancellationToken.None); var second = await import.ImportAsync(ListId, CancellationToken.None); Assert.Equal(1, first.Created); Assert.Equal(0, second.Created); Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1")); } [Fact] public async Task Throws_when_the_list_has_no_ticket_project() { var (import, _) = await HarnessAsync(ticketProjectId: null); await Assert.ThrowsAsync(() => import.ImportAsync(ListId, CancellationToken.None)); } [Fact] public async Task Throws_when_not_configured() { var (import, _) = await HarnessAsync(configured: false); await Assert.ThrowsAsync(() => import.ImportAsync(ListId, CancellationToken.None)); } [Fact] public async Task Imported_tasks_get_a_task_number() { var (import, ctx) = await HarnessAsync(); await import.ImportAsync(ListId, CancellationToken.None); var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync(); Assert.True(created.Number > 0); } // ── Helfer ────────────────────────────────────────────────────────────── private sealed class StubHttpClientFactory : IHttpClientFactory { private readonly StubHandler _handler; public StubHttpClientFactory(StubHandler handler) => _handler = handler; public HttpClient CreateClient(string name) => new(_handler); } /// /// 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). /// private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync( bool configured = true, int? ticketProjectId = 393, string boardAssignee = "mika.kuns", string identityUser = "mika.kuns") { var db = new DbFixture(); _dbs.Add(db); var factory = db.CreateFactory(); await using (var seed = factory.CreateDbContext()) { seed.Lists.Add(new ListEntity { Id = ListId, Name = "L", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", }); seed.ListConfigs.Add(new ListConfigEntity { ListId = ListId, TicketProjectId = ticketProjectId }); await seed.SaveChangesAsync(); } await using (var seed = factory.CreateDbContext()) { await SeedExistingTaskAsync(seed, ListId, "bandel:4"); } var handler = new StubHandler(request => { if (request.RequestUri!.AbsolutePath.EndsWith("/pat/me", StringComparison.Ordinal)) { return (HttpStatusCode.OK, $$""" {"success":true,"data":{"userName":"{{identityUser}}","isPat":true,"scopes":["pat:board:read"]},"message":"ok"} """); } 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}}"} ],"message":"ok"} """); }); var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketimport-" + 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 import = new TicketImportService(clients, factory, NullLogger.Instance); return (import, factory.CreateDbContext()); } private static async Task SeedExistingTaskAsync(ClaudeDoDbContext ctx, string listId, string ticketRef) { var repo = new TaskRepository(ctx); await repo.AddAsync(new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Pre-existing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, TicketRef = ticketRef, }); } }