Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs
T
mika kuns 1770b67713 fix(tickets): Import broadcastet TaskUpdated fuer neue Tasks
ImportTickets sendete nur ListUpdated. Die UI liest das als Aenderung der
Listen-Metadaten und patcht nur bestehende Zeilen, also tauchten die
importierten Tasks erst nach Schliessen und Neuoeffnen der Liste auf.
TicketImportResult liefert jetzt die angelegten Task-Ids, der Hub
broadcastet pro Id ein TaskUpdated - derselbe Pfad, den jeder andere
out-of-band Creator nutzt.
2026-09-10 15:39:58 +02:00

242 lines
10 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;
public sealed class TicketImportServiceTests : IDisposable
{
private const string ListId = "l1";
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 Imports_open_and_in_progress_tickets_assigned_to_the_token_owner()
{
var (import, ctx) = await HarnessAsync();
var result = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(7, result.Examined);
Assert.Equal(2, result.Created);
var refs = await ctx.Tasks
.Where(t => t.CreatedBy == "ticketsystem")
.Select(t => t.TicketRef)
.ToListAsync();
Assert.Equal(["bandel:1", "bandel:3"], refs.Order());
// Ohne die Ids kann der Hub kein TaskUpdated broadcasten und die offene Liste
// zeigt die neuen Tasks erst nach Schließen/Öffnen.
var createdIds = await ctx.Tasks
.Where(t => t.CreatedBy == "ticketsystem")
.Select(t => t.Id)
.ToListAsync();
Assert.Equal(createdIds.Order(), result.CreatedTaskIds.Order());
var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync();
Assert.Equal("Fix X", created.Title);
Assert.Equal(TaskStatus.Idle, created.Status);
Assert.Equal("ticketsystem", created.CreatedBy);
Assert.Contains("Ticket #1", created.Description);
}
// Der eigentliche Fehler der ersten Fassung: gefiltert wurde auf das flache assigneeName,
// das live auf jedem Ticket null ist. Ticket #5 hat es gesetzt, aber leere ticketAssignees —
// wer wieder aufs flache Feld zurückfällt, importiert es und lässt diesen Test scheitern.
[Fact]
public async Task Flat_assigneeName_is_not_an_assignment()
{
var (import, ctx) = await HarnessAsync();
await import.ImportAsync(ListId, CancellationToken.None);
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:5"));
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:7"));
}
[Fact]
public async Task Finished_tickets_are_never_imported()
{
var (import, ctx) = await HarnessAsync();
await import.ImportAsync(ListId, CancellationToken.None);
Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:6"));
}
[Fact]
public async Task Assignee_match_is_case_insensitive()
{
// Board liefert "Mika.Kuns", /pat/me liefert "mika.kuns"
var (import, _) = await HarnessAsync(boardAssignee: "Mika.Kuns", identityUser: "mika.kuns");
var result = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(2, result.Created);
}
[Fact]
public async Task Running_twice_creates_no_duplicates()
{
var (import, ctx) = await HarnessAsync();
var first = await import.ImportAsync(ListId, CancellationToken.None);
var second = await import.ImportAsync(ListId, CancellationToken.None);
Assert.Equal(2, first.Created);
Assert.Equal(0, second.Created);
Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1"));
}
[Fact]
public async Task Throws_when_the_list_has_no_ticket_project()
{
var (import, _) = await HarnessAsync(ticketProjectId: null);
await Assert.ThrowsAsync<TicketApiException>(() => import.ImportAsync(ListId, CancellationToken.None));
}
[Fact]
public async Task Throws_when_not_configured()
{
var (import, _) = await HarnessAsync(configured: false);
await Assert.ThrowsAsync<TicketApiException>(() => import.ImportAsync(ListId, CancellationToken.None));
}
[Fact]
public async Task Imported_tasks_get_a_task_number()
{
var (import, ctx) = await HarnessAsync();
await import.ImportAsync(ListId, CancellationToken.None);
var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync();
Assert.True(created.Number > 0);
}
// ── Helfer ──────────────────────────────────────────────────────────────
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly StubHandler _handler;
public StubHttpClientFactory(StubHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler);
}
/// <summary>
/// Board liefert immer sieben Tickets, je einer pro Filterzweig:
/// #1 offen/mein → importierbar · #2 offen/fremd → Assignee-Filter ·
/// #3 in Bearbeitung/mein → importierbar · #4 offen/mein, bereits importiert → Dedup
/// (vorab als Task angelegt) · #5 offen, nur flaches assigneeName gesetzt → muss draußen
/// bleiben · #6 fertig/mein → Status-Filter · #7 offen, ticketAssignees null → draußen.
/// </summary>
private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync(
bool configured = true,
int? ticketProjectId = 393,
string boardAssignee = "mika.kuns",
string identityUser = "mika.kuns")
{
var db = new DbFixture();
_dbs.Add(db);
var factory = db.CreateFactory();
await using (var seed = factory.CreateDbContext())
{
seed.Lists.Add(new ListEntity
{
Id = ListId,
Name = "L",
CreatedAt = DateTime.UtcNow,
DefaultCommitType = "chore",
});
seed.ListConfigs.Add(new ListConfigEntity { ListId = ListId, TicketProjectId = ticketProjectId });
await seed.SaveChangesAsync();
}
await using (var seed = factory.CreateDbContext())
{
await SeedExistingTaskAsync(seed, ListId, "bandel:4");
}
var handler = new StubHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/pat/me", StringComparison.Ordinal))
{
return (HttpStatusCode.OK, $$"""
{"success":true,"data":{"userName":"{{identityUser}}","isPat":true,"scopes":["pat:board:read"]},"message":"ok"}
""");
}
// Die flachen assigneeID/assigneeName sind hier absichtlich null — genau so liefert
// die echte API sie auf JEDEM Ticket. Die Zuweisung steckt nur in ticketAssignees.
return (HttpStatusCode.OK, $$"""
{"success":true,"data":[
{"id":1,"title":"Fix X","description":"d","statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":2,"title":"Other","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":8,"assigneeName":"jemand.anders"}]},
{"id":3,"title":"InProgress","description":null,"statusId":2,"statusName":"In Bearbeitung","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":8,"assigneeName":"jemand.anders"},{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":4,"title":"AlreadyImported","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":5,"title":"FlatAssigneeOnly","description":null,"statusId":1,"statusName":"Offen","assigneeID":26,"assigneeName":"{{boardAssignee}}",
"ticketAssignees":[]},
{"id":6,"title":"Done","description":null,"statusId":3,"statusName":"Fertig","assigneeID":null,"assigneeName":null,
"ticketAssignees":[{"assigneeId":26,"assigneeName":"{{boardAssignee}}"}]},
{"id":7,"title":"Unassigned","description":null,"statusId":1,"statusName":"Offen","assigneeID":null,"assigneeName":null,
"ticketAssignees":null}
],"message":"ok"}
""");
});
var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketimport-" + 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 config = new TicketSystemConfig(worker, patStore);
var clients = new TicketClientFactory(config, new StubHttpClientFactory(handler), NullLogger<BandelTicketClient>.Instance);
var import = new TicketImportService(clients, factory, NullLogger<TicketImportService>.Instance);
return (import, factory.CreateDbContext());
}
private static async Task SeedExistingTaskAsync(ClaudeDoDbContext ctx, string listId, string ticketRef)
{
var repo = new TaskRepository(ctx);
await repo.AddAsync(new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Pre-existing",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
TicketRef = ticketRef,
});
}
}