diff --git a/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs index 3e08b322..e407e1ae 100644 --- a/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs +++ b/src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs @@ -49,6 +49,10 @@ public sealed class BandelTicketClient => await SendAsync>( HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct); + /// Details eines Tickets oder Tasks (Beschreibung, Parent). + public Task GetTicketAsync(int ticketId, CancellationToken ct) + => SendAsync(HttpMethod.Get, $"/api/ticket/{ticketId}", null, ct); + /// /// ⚠️ Die Route verlangt statusName zusätzlich zu statusId, sonst 400 /// ("The StatusName field is required."). Der Wert wird nicht gegen die Id validiert — diff --git a/src/ClaudeDo.Worker/Tickets/TicketDtos.cs b/src/ClaudeDo.Worker/Tickets/TicketDtos.cs index b3a720a0..c01b0f33 100644 --- a/src/ClaudeDo.Worker/Tickets/TicketDtos.cs +++ b/src/ClaudeDo.Worker/Tickets/TicketDtos.cs @@ -29,12 +29,29 @@ public sealed record TicketBoardItem( [property: JsonPropertyName("statusName")] string? StatusName, [property: JsonPropertyName("assigneeID")] int? AssigneeId, [property: JsonPropertyName("assigneeName")] string? AssigneeName, - [property: JsonPropertyName("ticketAssignees")] IReadOnlyList? TicketAssignees = null); + [property: JsonPropertyName("ticketAssignees")] IReadOnlyList? TicketAssignees = null, + [property: JsonPropertyName("tasks")] IReadOnlyList? Tasks = null); public sealed record TicketAssignee( [property: JsonPropertyName("assigneeId")] int AssigneeId, [property: JsonPropertyName("assigneeName")] string? AssigneeName); +/// Ein Task unter einem Board-Item (tasks[]). ⚠️ Die Zuweisung steht in +/// ticketTaskAssignees, NICHT in ticketAssignees (live geprüft 2026-10-01). +public sealed record TicketBoardTask( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("statusId")] int StatusId, + [property: JsonPropertyName("hasChildren")] bool HasChildren, + [property: JsonPropertyName("ticketTaskAssignees")] IReadOnlyList? TicketTaskAssignees = null); + +/// GET /api/ticket/{id} — Ausschnitt; die Route liefert Tickets und Tasks gleichermaßen. +public sealed record TicketDetail( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("parentTicketID")] int? ParentTicketId); + /// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten. public sealed record SidebarDepartment( [property: JsonPropertyName("departmentId")] int DepartmentId, diff --git a/src/ClaudeDo.Worker/Tickets/TicketImportService.cs b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs index 02891ab3..7cc2e578 100644 --- a/src/ClaudeDo.Worker/Tickets/TicketImportService.cs +++ b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs @@ -10,6 +10,23 @@ namespace ClaudeDo.Worker.Tickets; public sealed record TicketImportResult(int Examined, int Created, IReadOnlyList CreatedTaskIds); +/// Ein offenes, dem Token-Inhaber zugewiesenes Ticket oder Task aus dem Board-Sweep. +public sealed record TicketCandidate( + int Id, string Kind, string Title, int StatusId, int ProjectId, string ProjectTitle, + int? ParentId, string? ParentTitle); + +/// Eine ClaudeDo-Liste als Zuordnungsziel; ist nur ein Hinweis. +public sealed record ImportTargetList(string Id, string Name, string? WorkingDir, string? TicketProject); + +/// Was der Zuordnungs-Lauf braucht: offene Items, alle Listen, die Fallback-Liste. +public sealed record TicketImportCandidates( + IReadOnlyList Items, IReadOnlyList Lists, ImportTargetList? FallbackList); + +/// Ergebnis eines Einzel-Imports; = false trägt immer einen Grund. +public sealed record TicketItemImportResult( + bool Created, string? TaskId, int? TaskNumber, string? ListId, string? ListName, + bool UsedFallback, string? SkippedReason); + /// /// Holt das Board des verknüpften Ticket-Projekts und legt für jedes offene, dem Token-Inhaber /// zugewiesene Ticket einen Idle-Task an. Bereits importierte Tickets werden übersprungen und @@ -38,8 +55,7 @@ public sealed class TicketImportService public async Task ImportAsync(string listId, CancellationToken ct) { - var client = _clients.Create() - ?? throw new TicketApiException("Ticketsystem ist nicht eingerichtet (Base-URL oder Token fehlt)."); + var client = RequireClient(); await using var ctx = await _dbFactory.CreateDbContextAsync(ct); @@ -53,21 +69,17 @@ public sealed class TicketImportService var mine = board .Where(t => ImportableStatuses.Contains(t.StatusId)) - .Where(t => IsAssignedTo(t, identity.UserName)) + .Where(t => IsAssignedTo(t.TicketAssignees, identity.UserName)) .ToList(); - var known = await ctx.Tasks.AsNoTracking() - .Where(t => t.ListId == listId && t.TicketRef != null) - .Select(t => t.TicketRef!) - .ToListAsync(ct); - var knownSet = known.ToHashSet(StringComparer.OrdinalIgnoreCase); + var knownSet = await KnownRefsAsync(ctx, ct); var repo = new TaskRepository(ctx); var created = new List(); foreach (var ticket in mine) { - var reference = $"bandel:{ticket.Id}"; + var reference = Ref(ticket.Id); if (!knownSet.Add(reference)) continue; var taskId = Guid.NewGuid().ToString(); @@ -75,8 +87,8 @@ public sealed class TicketImportService { Id = taskId, ListId = listId, - Title = WebUtility.HtmlDecode(ticket.Title).Trim(), - Description = BuildDescription(ticket), + Title = Clean(ticket.Title), + Description = BuildDescription(ticket.Description, ticket.Id, null, null), Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, TicketRef = reference, @@ -92,20 +104,166 @@ public sealed class TicketImportService return new TicketImportResult(board.Count, created.Count, created); } - /// - /// Zuweisung ausschließlich über ticketAssignees — das flache assigneeName ist - /// live auf jedem Ticket null (siehe ). Ein Ticket kann mehrere - /// Zuweisungen haben; eine davon muss der Token-Inhaber sein. - /// - private static bool IsAssignedTo(TicketBoardItem ticket, string userName) - => ticket.TicketAssignees?.Any( - a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase)) - ?? false; - - private static string BuildDescription(TicketBoardItem ticket) + /// Offene, zugewiesene Items aus allen Projekten, die noch in keiner Liste importiert sind, plus Zuordnungskontext. + public async Task GetCandidatesAsync(CancellationToken ct) { - var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : HtmlToMarkdown(ticket.Description); - return (body.Length > 0 ? body + "\n\n" : "") + $"Ticket #{ticket.Id}"; + var client = RequireClient(); + var (items, projects) = await SweepAsync(client, ct); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var known = await KnownRefsAsync(ctx, ct); + var fallbackId = (await new AppSettingsRepository(ctx).GetAsync(ct)).TicketFallbackListId; + + var projectTitles = projects.ToDictionary(p => p.Id, p => p.Title); + var lists = await ctx.Lists.AsNoTracking().Include(l => l.Config) + .OrderBy(l => l.SortOrder).ThenBy(l => l.Name) + .ToListAsync(ct); + var targets = lists.Select(l => new ImportTargetList( + l.Id, l.Name, l.WorkingDir, + l.Config?.TicketProjectId is { } pid ? projectTitles.GetValueOrDefault(pid, $"#{pid}") : null)) + .ToList(); + + return new TicketImportCandidates( + items.Where(i => !known.Contains(Ref(i.Id))).ToList(), + targets, + targets.FirstOrDefault(t => t.Id == fallbackId)); + } + + /// + /// Legt für ein offenes, zugewiesenes Item einen Idle-Task an. ist Id + /// oder Name; leer → Fallback-Liste aus den App-Settings. Fachliche Ablehnungen (schon + /// importiert, keine Fallback-Liste, unbekannte Liste, nicht zugewiesen) sind ein Ergebnis, + /// keine Exception. + /// + public async Task ImportItemAsync(int ticketId, string? list, CancellationToken ct) + { + var client = RequireClient(); + var reference = Ref(ticketId); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + if ((await KnownRefsAsync(ctx, ct)).Contains(reference)) + return Skipped($"Ticket #{ticketId} is already imported."); + + var usedFallback = string.IsNullOrWhiteSpace(list); + ListEntity? target; + if (usedFallback) + { + var fallbackId = (await new AppSettingsRepository(ctx).GetAsync(ct)).TicketFallbackListId; + target = fallbackId is null ? null : await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == fallbackId, ct); + if (target is null) return Skipped("No fallback list is set (Settings → Ticket system), so the item was skipped."); + } + else + { + var all = await ctx.Lists.AsNoTracking().ToListAsync(ct); + target = all.FirstOrDefault(l => l.Id == list) + ?? all.FirstOrDefault(l => string.Equals(l.Name, list!.Trim(), StringComparison.OrdinalIgnoreCase)); + if (target is null) + return Skipped($"No list matches '{list}'. Known lists: {string.Join(", ", all.Select(l => l.Name))}."); + } + + // ponytail: voller Sweep pro Import (~1,2 s) statt Cache — prüft gleich mit, dass das Item offen und zugewiesen ist; cachen, falls Importe spürbar langsam werden + var (items, _) = await SweepAsync(client, ct); + var item = items.FirstOrDefault(i => i.Id == ticketId); + if (item is null) return Skipped($"Ticket #{ticketId} is not an open item assigned to you."); + + var detail = await client.GetTicketAsync(ticketId, ct); + + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = target.Id, + Title = item.Title, + Description = BuildDescription(detail.Description, item.Id, item.ParentId, item.ParentTitle), + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + TicketRef = reference, + CreatedBy = "ticketsystem", + }; + await new TaskRepository(ctx).AddAsync(task, ct); + + _logger.LogInformation( + "Ticket {ticket_id} als Task {task_id} in Liste {list_id} importiert (Fallback: {used_fallback})", + ticketId, task.Id, target.Id, usedFallback); + + return new TicketItemImportResult(true, task.Id, task.Number, target.Id, target.Name, usedFallback, null); + + static TicketItemImportResult Skipped(string reason) => new(false, null, null, null, null, false, reason); + } + + /// + /// Sweep über alle sichtbaren Projekt-Boards: offene Tickets UND Tasks des Token-Inhabers. + /// Ein Task zählt unabhängig davon, wem sein Parent-Ticket gehört. + /// + private async Task<(IReadOnlyList Items, IReadOnlyList Projects)> SweepAsync( + BandelTicketClient client, CancellationToken ct) + { + var identity = await client.GetIdentityAsync(ct); + var projects = await client.GetProjectsAsync(ct); + var items = new List(); + + foreach (var project in projects) + { + IReadOnlyList board; + try + { + board = await client.GetProjectBoardAsync(project.Id, ct); + } + catch (TicketApiException ex) + { + // Ein unlesbares Board darf den Sweep über die übrigen Projekte nicht abbrechen. + _logger.LogWarning("Board von Projekt {project_id} übersprungen: {reason}", project.Id, ex.Message); + continue; + } + + foreach (var ticket in board) + { + var title = Clean(ticket.Title); + if (ImportableStatuses.Contains(ticket.StatusId) && IsAssignedTo(ticket.TicketAssignees, identity.UserName)) + items.Add(new TicketCandidate(ticket.Id, "ticket", title, ticket.StatusId, project.Id, project.Title, null, null)); + + foreach (var task in ticket.Tasks ?? []) + { + // ponytail: Subtasks (Ebene 3) nicht rekursiv über GET /api/item/{id}/children — live gibt es keine; nachrüsten, sobald sie vorkommen + if (task.HasChildren) continue; + if (!ImportableStatuses.Contains(task.StatusId)) continue; + if (!IsAssignedTo(task.TicketTaskAssignees, identity.UserName)) continue; + items.Add(new TicketCandidate( + task.Id, "task", Clean(task.Title), task.StatusId, project.Id, project.Title, ticket.Id, title)); + } + } + } + + return (items.DistinctBy(i => i.Id).ToList(), projects); + } + + private BandelTicketClient RequireClient() => _clients.Create() + ?? throw new TicketApiException("Ticketsystem ist nicht eingerichtet (Base-URL oder Token fehlt)."); + + /// Alle bereits importierten TicketRefs — listenübergreifend, sonst entstehen Duplikate. + private static async Task> KnownRefsAsync(ClaudeDoDbContext ctx, CancellationToken ct) + => (await ctx.Tasks.AsNoTracking() + .Where(t => t.TicketRef != null) + .Select(t => t.TicketRef!) + .ToListAsync(ct)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + private static string Ref(int ticketId) => $"bandel:{ticketId}"; + + private static string Clean(string title) => WebUtility.HtmlDecode(title).Trim(); + + /// + /// Zuweisung ausschließlich über die Assignee-Liste — das flache assigneeName ist live + /// auf jedem Ticket null (siehe ). Eine Zuweisung an den + /// Token-Inhaber reicht. + /// + private static bool IsAssignedTo(IReadOnlyList? assignees, string userName) + => assignees?.Any(a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase)) ?? false; + + private static string BuildDescription(string? html, int id, int? parentId, string? parentTitle) + { + var body = string.IsNullOrWhiteSpace(html) ? "" : HtmlToMarkdown(html); + var footer = $"Ticket #{id}" + (parentId is { } p ? $"\n\nTeil von #{p} {parentTitle}" : ""); + return (body.Length > 0 ? body + "\n\n" : "") + footer; } private const RegexOptions Rx = RegexOptions.IgnoreCase | RegexOptions.Singleline; diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportAllTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportAllTests.cs new file mode 100644 index 00000000..378f2ee6 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportAllTests.cs @@ -0,0 +1,255 @@ +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_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) + { + 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)), + 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, + }); +} diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs index 6a61547a..e107680e 100644 --- a/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs @@ -106,6 +106,20 @@ public sealed class TicketImportServiceTests : IDisposable 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() {