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; /// /// Sweep über alle Projekt-Boards. Projekt 10 "Lager": #100 Ticket mein/offen mit Tasks /// #101 mein/offen · #102 fremd · #103 mein/fertig · #104 mein, hat Kinder (übersprungen); /// #110 fremdes Ticket mit Task #111 mein/in Bearbeitung (zählt trotzdem). /// Projekt 20 "Web": #200 mein/offen · #201 mein/fertig. /// public sealed class TicketImportAllTests : IDisposable { 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 Candidates_are_open_assigned_tickets_and_tasks_across_all_projects() { var (import, _) = await HarnessAsync(); var c = await import.GetCandidatesAsync(CancellationToken.None); Assert.Equal([100, 101, 111, 200], c.Items.Select(i => i.Id).Order()); var task = c.Items.Single(i => i.Id == 111); Assert.Equal("task", task.Kind); Assert.Equal(110, task.ParentId); Assert.Equal("Foreign parent", task.ParentTitle); Assert.Equal("Lager", task.ProjectTitle); Assert.Equal("ticket", c.Items.Single(i => i.Id == 200).Kind); } [Fact] public async Task Candidates_skip_items_already_imported_in_any_list() { var (import, ctx) = await HarnessAsync(); await SeedTaskAsync(ctx, "l2", "bandel:200"); var c = await import.GetCandidatesAsync(CancellationToken.None); Assert.DoesNotContain(c.Items, i => i.Id == 200); } [Fact] public async Task Candidates_carry_every_list_with_its_project_hint_and_the_fallback() { var (import, ctx) = await HarnessAsync(); await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync("l2"); var c = await import.GetCandidatesAsync(CancellationToken.None); Assert.Equal(["l1", "l2"], c.Lists.Select(l => l.Id).Order()); Assert.Equal("Lager", c.Lists.Single(l => l.Id == "l1").TicketProject); Assert.Null(c.Lists.Single(l => l.Id == "l2").TicketProject); Assert.Equal("l2", c.FallbackList?.Id); } [Fact] public async Task ImportItem_into_a_list_by_name_creates_an_idle_task_with_parent_note() { var (import, ctx) = await HarnessAsync(); var r = await import.ImportItemAsync(101, "hub lagertechnik", CancellationToken.None); Assert.True(r.Created); Assert.False(r.UsedFallback); Assert.Equal("l1", r.ListId); Assert.Equal("Hub Lagertechnik", r.ListName); Assert.True(r.TaskNumber > 0); var task = await ctx.Tasks.SingleAsync(t => t.TicketRef == "bandel:101"); Assert.Equal(r.TaskId, task.Id); Assert.Equal("Task 101", task.Title); Assert.Equal(TaskStatus.Idle, task.Status); Assert.Equal("ticketsystem", task.CreatedBy); Assert.Contains("Body 101", task.Description); Assert.Contains("Ticket #101", task.Description); Assert.Contains("Teil von #100 Mine", task.Description); } [Fact] public async Task ImportItem_without_list_goes_to_the_fallback_list() { var (import, ctx) = await HarnessAsync(); await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync("l2"); var r = await import.ImportItemAsync(200, null, CancellationToken.None); Assert.True(r.Created); Assert.True(r.UsedFallback); Assert.Equal("l2", r.ListId); Assert.DoesNotContain("Teil von", (await ctx.Tasks.SingleAsync(t => t.TicketRef == "bandel:200")).Description); } [Fact] public async Task ImportItem_without_list_and_without_fallback_is_skipped() { var (import, ctx) = await HarnessAsync(); var r = await import.ImportItemAsync(200, null, CancellationToken.None); Assert.False(r.Created); Assert.Contains("fallback", r.SkippedReason, StringComparison.OrdinalIgnoreCase); Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:200")); } [Fact] public async Task ImportItem_twice_creates_one_task() { var (import, ctx) = await HarnessAsync(); await import.ImportItemAsync(200, "l1", CancellationToken.None); var second = await import.ImportItemAsync(200, "l2", CancellationToken.None); Assert.False(second.Created); Assert.Contains("already", second.SkippedReason); Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:200")); } [Fact] public async Task ImportItem_refuses_an_item_not_assigned_to_me() { var (import, ctx) = await HarnessAsync(); var r = await import.ImportItemAsync(102, "l1", CancellationToken.None); Assert.False(r.Created); Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:102")); } [Fact] public async Task ImportItem_with_an_unknown_list_names_the_known_lists() { var (import, _) = await HarnessAsync(); var r = await import.ImportItemAsync(200, "nope", CancellationToken.None); Assert.False(r.Created); Assert.Contains("Hub Lagertechnik", r.SkippedReason); } [Fact] public async Task GetCandidates_throws_when_no_board_is_readable() { var (import, _) = await HarnessAsync(respond: r => r.RequestUri!.AbsolutePath.Contains("/Board/project/") ? (HttpStatusCode.Forbidden, "") : Respond(r)); await Assert.ThrowsAsync(() => import.GetCandidatesAsync(CancellationToken.None)); } [Fact] public async Task GetCandidates_throws_when_not_configured() { var (import, _) = await HarnessAsync(configured: false); await Assert.ThrowsAsync(() => import.GetCandidatesAsync(CancellationToken.None)); } // ── Helfer ────────────────────────────────────────────────────────────── private sealed class StubHttpClientFactory(StubHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler); } private const string Me = """{"assigneeId":26,"assigneeName":"mika.kuns"}"""; private const string Other = """{"assigneeId":8,"assigneeName":"jemand.anders"}"""; private static string Envelope(string data) => $$"""{"success":true,"data":{{data}},"message":"ok"}"""; private static (HttpStatusCode, string) Respond(HttpRequestMessage request) { var path = request.RequestUri!.AbsolutePath; if (path.EndsWith("/pat/me", StringComparison.Ordinal)) return (HttpStatusCode.OK, Envelope("""{"userName":"mika.kuns","isPat":true,"scopes":[]}""")); if (path.EndsWith("/Navigation/sidebar", StringComparison.Ordinal)) return (HttpStatusCode.OK, Envelope(""" [{"departmentId":1,"departmentName":"Entwicklung","projects":[{"id":10,"title":"Lager"},{"id":20,"title":"Web"}]}] """)); if (path.EndsWith("/Board/project/10", StringComparison.Ordinal)) return (HttpStatusCode.OK, Envelope($$""" [ {"id":100,"title":"Mine","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Me}}], "tasks":[ {"id":101,"title":"Task 101","statusId":1,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]}, {"id":102,"title":"Task 102","statusId":1,"hasChildren":false,"ticketTaskAssignees":[{{Other}}]}, {"id":103,"title":"Task 103","statusId":3,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]}, {"id":104,"title":"Task 104","statusId":1,"hasChildren":true,"ticketTaskAssignees":[{{Me}}]} ]}, {"id":110,"title":"Foreign parent","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Other}}], "tasks":[{"id":111,"title":"Task 111","statusId":2,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]}]} ] """)); if (path.EndsWith("/Board/project/20", StringComparison.Ordinal)) return (HttpStatusCode.OK, Envelope($$""" [ {"id":200,"title":"Web thing","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Me}}]}, {"id":201,"title":"Web done","description":null,"statusId":3,"statusName":"Fertig","ticketAssignees":[{{Me}}]} ] """)); if (path.StartsWith("/api/ticket/", StringComparison.OrdinalIgnoreCase)) { var id = path[(path.LastIndexOf('/') + 1)..]; return (HttpStatusCode.OK, Envelope($$"""{"id":{{id}},"title":"T","description":"

Body {{id}}

","parentTicketID":null}""")); } return (HttpStatusCode.NotFound, ""); } private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync( bool configured = true, Func? respond = null) { var db = new DbFixture(); _dbs.Add(db); var factory = db.CreateFactory(); await using (var seed = factory.CreateDbContext()) { seed.Lists.Add(new ListEntity { Id = "l1", Name = "Hub Lagertechnik", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", SortOrder = 0 }); seed.Lists.Add(new ListEntity { Id = "l2", Name = "Inbox", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", SortOrder = 1 }); seed.ListConfigs.Add(new ListConfigEntity { ListId = "l1", TicketProjectId = 10 }); await seed.SaveChangesAsync(); } var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketall-" + 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 clients = new TicketClientFactory( new TicketSystemConfig(worker, patStore), new StubHttpClientFactory(new StubHandler(respond ?? Respond)), NullLogger.Instance); var import = new TicketImportService(clients, factory, NullLogger.Instance); return (import, factory.CreateDbContext()); } private static Task SeedTaskAsync(ClaudeDoDbContext ctx, string listId, string ticketRef) => new TaskRepository(ctx).AddAsync(new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Pre-existing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, TicketRef = ticketRef, }); }