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,
});
}