The external MCP server has its own DI container; TicketMcpTools could not resolve TicketImportService there, so both tools failed at runtime. Also surface a skipped import in the prime outcome and treat a sweep where every project board fails as an error instead of an empty backlog.
266 lines
11 KiB
C#
266 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class TicketImportAllTests : IDisposable
|
|
{
|
|
private readonly List<DbFixture> _dbs = new();
|
|
private readonly List<string> _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<TicketApiException>(() => import.GetCandidatesAsync(CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCandidates_throws_when_not_configured()
|
|
{
|
|
var (import, _) = await HarnessAsync(configured: false);
|
|
|
|
await Assert.ThrowsAsync<TicketApiException>(() => 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":"<p>Body {{id}}</p>","parentTicketID":null}"""));
|
|
}
|
|
return (HttpStatusCode.NotFound, "");
|
|
}
|
|
|
|
private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync(
|
|
bool configured = true, Func<HttpRequestMessage, (HttpStatusCode, string)>? 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<BandelTicketClient>.Instance);
|
|
var import = new TicketImportService(clients, factory, NullLogger<TicketImportService>.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,
|
|
});
|
|
}
|