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_open_and_in_progress_tickets_assigned_to_the_token_owner() { var (import, ctx) = await HarnessAsync(); var result = await import.ImportAsync(ListId, CancellationToken.None); Assert.Equal(7, result.Examined); 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()); // Ohne die Ids kann der Hub kein TaskUpdated broadcasten und die offene Liste // zeigt die neuen Tasks erst nach Schließen/Öffnen. var createdIds = await ctx.Tasks .Where(t => t.CreatedBy == "ticketsystem") .Select(t => t.Id) .ToListAsync(); Assert.Equal(createdIds.Order(), result.CreatedTaskIds.Order()); 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); } // 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] 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(2, 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(2, first.Created); Assert.Equal(0, second.Created); Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1")); } [Fact] public async Task Ticket_already_imported_into_another_list_is_not_imported_again() { var (import, ctx) = await HarnessAsync(); ctx.Lists.Add(new ListEntity { Id = "l2", Name = "Other", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore" }); await ctx.SaveChangesAsync(); await SeedExistingTaskAsync(ctx, "l2", "bandel:1"); var result = await import.ImportAsync(ListId, CancellationToken.None); Assert.Equal(1, result.Created); // only #3 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 sieben Tickets, je einer pro Filterzweig: /// #1 offen/mein → importierbar · #2 offen/fremd → Assignee-Filter · /// #3 in Bearbeitung/mein → importierbar · #4 offen/mein, bereits importiert → Dedup /// (vorab als Task angelegt) · #5 offen, nur flaches assigneeName gesetzt → muss draußen /// bleiben · #6 fertig/mein → Status-Filter · #7 offen, ticketAssignees null → draußen. /// 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"} """); } // 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, $$""" {"success":true,"data":[ {"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null, "ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]}, {"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null, "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"} """); }); 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, }); } }