feat(tickets): sweep all project boards for assigned tickets and tasks
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <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_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)
|
||||
{
|
||||
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<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,
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user