using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using Shouldly; namespace ClawdDotNet.Core.Tests.Tasks; /// /// A1 Taskboard: der Ausführungszustand. Getestet gegen eine echte SQLite-Datei, weil es /// gerade um Schema, Sperrverhalten und atomares Claiming geht — eine Attrappe würde /// genau das verstecken, worauf es ankommt. /// /// Die drei Invarianten aus dem Taskboard-Konzept stehen im Mittelpunkt: nie zwei Claims /// auf einen Termin, kein Dispatch bei offenem Blocker, doppelter Tick = ein Lauf. /// public sealed class TaskRepositoryTests : IDisposable { private readonly string _directory; private readonly SqliteStorage _storage; private readonly SqliteTaskRepository _repo; public TaskRepositoryTests() { _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _repo = new SqliteTaskRepository(_storage); } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } private Task Seed( string id, TaskItemStatus status = TaskItemStatus.Todo, string assignee = "@new", int priority = 3, string title = "Aufgabe", IReadOnlyList? blockedBy = null) => _repo.UpsertAsync(new TaskItem { Id = id, Title = title, Status = status, Assignee = assignee, Priority = priority, BlockedBy = blockedBy ?? [] }, default); private static string Occ(DateTime t) => t.ToUniversalTime().ToString("O"); private static readonly DateTime T0 = new(2026, 7, 31, 7, 0, 0, DateTimeKind.Utc); private static DateTime Now => DateTime.UtcNow; private static DateTime NoStaleClaims => Now.AddHours(-1); // Lease-Cutoff weit in der Vergangenheit // ═══════════════════════════════════════════════════════════ // Grundfunktionen: Import (Upsert), Lesen, Auflisten // ═══════════════════════════════════════════════════════════ [Fact] public async Task Eine_Aufgabe_wird_angelegt_und_wiedergefunden() { await Seed("t-1", title: "Recherche"); var found = await _repo.GetAsync("t-1", default); found.ShouldNotBeNull(); found!.Title.ShouldBe("Recherche"); found.Status.ShouldBe(TaskItemStatus.Todo); } [Fact] public async Task Der_Import_ist_idempotent_und_erzeugt_keine_Dublette() { await Seed("t-1", title: "Erste Fassung"); await Seed("t-1", title: "Zweite Fassung"); (await _repo.CountAsync(default)).ShouldBe(1); (await _repo.GetAsync("t-1", default))!.Title.ShouldBe("Zweite Fassung"); } [Fact] public async Task Ein_Re_Import_laesst_den_Ausfuehrungszustand_unangetastet() { // Der springende Punkt der Wahrheitsaufteilung: Die Datei ist Wahrheit über die // Definition, die DB über den Ausführungszustand. Ein erneuter Import darf einen // laufenden oder abgeschlossenen Zustand nicht zurücksetzen. await Seed("t-1"); (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Done, Now, default); // Datei wird erneut importiert (Titel geändert, Status im Frontmatter noch "todo"). await _repo.UpsertAsync(new TaskItem { Id = "t-1", Title = "geändert", Status = TaskItemStatus.Todo }, default); var after = await _repo.GetAsync("t-1", default); after!.Title.ShouldBe("geändert", "die Definition wird übernommen"); after.Status.ShouldBe(TaskItemStatus.Done, "der Ausführungszustand bleibt"); after.LastOccurrence.ShouldBe(Occ(T0), "der Marker bleibt erhalten"); } [Fact] public async Task Die_Liste_filtert_nach_Status_und_blendet_Archiviertes_aus() { await Seed("t-1", TaskItemStatus.Todo); await Seed("t-2", TaskItemStatus.Done); await Seed("t-3", TaskItemStatus.Archived); (await _repo.ListAsync(new TaskQuery { Status = TaskItemStatus.Todo }, default)).Count.ShouldBe(1); (await _repo.ListAsync(new TaskQuery(), default)).Count.ShouldBe(2, "Archiviertes ist standardmäßig ausgeblendet"); (await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default)).Count.ShouldBe(3); } [Fact] public async Task Die_Liste_ordnet_nach_Prioritaet() { await Seed("t-low", priority: 1, title: "nebensächlich"); await Seed("t-high", priority: 5, title: "dringend"); await Seed("t-mid", priority: 3, title: "mittel"); var list = await _repo.ListAsync(new TaskQuery(), default); list[0].Title.ShouldBe("dringend"); list[^1].Title.ShouldBe("nebensächlich"); } [Fact] public async Task Die_Liste_filtert_nach_Assignee_und_Freitext() { await Seed("t-1", assignee: "@crawler", title: "NVDA Zahlen"); await Seed("t-2", assignee: "@analyst", title: "TSLA Bericht"); (await _repo.ListAsync(new TaskQuery { Assignee = "@crawler" }, default)).Count.ShouldBe(1); (await _repo.ListAsync(new TaskQuery { Search = "nvda" }, default)).Count.ShouldBe(1); } // ═══════════════════════════════════════════════════════════ // Invariante 1 & 3: nie zwei Claims, doppelter Tick = ein Lauf // ═══════════════════════════════════════════════════════════ [Fact] public async Task Ein_Termin_laesst_sich_genau_einmal_beanspruchen() { await Seed("t-1"); (await _repo.TryClaimAsync("t-1", Occ(T0), "tok-a", Now, NoStaleClaims, default)).ShouldBeTrue(); (await _repo.TryClaimAsync("t-1", Occ(T0), "tok-b", Now, NoStaleClaims, default)) .ShouldBeFalse("der Termin ist bereits beansprucht"); } [Fact] public async Task Gleichzeitige_Claims_auf_denselben_Termin_ergeben_genau_einen_Gewinner() { // Die Kern-Invariante. Mehrfach wiederholt, weil ein Race sporadisch auftritt. for (var round = 0; round < 25; round++) { var id = $"t-race-{round}"; await Seed(id); var attempts = Enumerable.Range(0, 32) .Select(i => _repo.TryClaimAsync(id, Occ(T0), $"tok-{i}", Now, NoStaleClaims, default)); var results = await Task.WhenAll(attempts); results.Count(won => won).ShouldBe(1, $"Runde {round}: genau ein Lauf darf den Termin ziehen"); } } [Fact] public async Task Ein_zweiter_Tick_auf_denselben_Termin_loest_keinen_zweiten_Lauf_aus() { await Seed("t-1"); // Erster Tick beansprucht und schließt ab. (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default); // Zweiter Tick im selben Terminfenster: derselbe Occurrence-Key. (await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default)) .ShouldBeFalse("derselbe Termin darf nach Abschluss nicht erneut laufen"); } [Fact] public async Task Ein_gescheiterter_Lauf_wird_nicht_automatisch_wiederholt() { // Kein Retry-Sturm: Der Marker steht schon beim Claim, nicht erst beim Erfolg. await Seed("t-1"); (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); // Lauf scheitert → zurück auf todo, aber derselbe Termin bleibt verbraucht. await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default); (await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default)) .ShouldBeFalse("derselbe Termin wird nicht erneut versucht"); } [Fact] public async Task Der_naechste_planmaessige_Termin_laesst_sich_wieder_beanspruchen() { await Seed("t-1"); (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default); // Ein späterer Occurrence-Key liegt über dem Marker → wieder fällig. var later = Occ(T0.AddDays(1)); (await _repo.TryClaimAsync("t-1", later, "tok3", Now, NoStaleClaims, default)) .ShouldBeTrue("ein neuer Termin darf laufen"); } // ═══════════════════════════════════════════════════════════ // Invariante 2: kein Dispatch bei offenem Blocker // ═══════════════════════════════════════════════════════════ [Fact] public async Task Eine_blockierte_Aufgabe_laesst_sich_nicht_beanspruchen() { await Seed("t-1", TaskItemStatus.Blocked, blockedBy: ["t-0"]); (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)) .ShouldBeFalse("solange der Blocker offen ist, läuft nichts"); } [Fact] public async Task Wartende_Aufgaben_lassen_sich_ueber_ihren_Blocker_finden() { await Seed("t-a", blockedBy: ["t-blocker"]); await Seed("t-b", blockedBy: ["t-blocker", "t-anderer"]); await Seed("t-c", blockedBy: ["t-blocker10"]); var waiting = await _repo.ListBlockedByAsync("t-blocker", default); waiting.Select(t => t.Id).OrderBy(x => x).ShouldBe(["t-a", "t-b"]); waiting.ShouldNotContain(t => t.Id == "t-c", "t-blocker darf nicht t-blocker10 treffen"); } // ═══════════════════════════════════════════════════════════ // Claim-Abschluss, Lease und Reconciliation // ═══════════════════════════════════════════════════════════ [Fact] public async Task Ein_Lauf_ohne_gueltigen_Claim_kann_nichts_ueberschreiben() { await Seed("t-1"); (await _repo.TryClaimAsync("t-1", Occ(T0), "echt", Now, NoStaleClaims, default)).ShouldBeTrue(); (await _repo.CompleteClaimAsync("t-1", "falsch", TaskItemStatus.Done, Now, default)) .ShouldBeFalse("ein fremdes Token darf den Abschluss nicht setzen"); (await _repo.CompleteClaimAsync("t-1", "echt", TaskItemStatus.Done, Now, default)) .ShouldBeTrue(); (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done); } [Fact] public async Task Verwaiste_Claims_werden_beim_Start_zurueckgesetzt() { await Seed("t-stuck"); // Claim mit einem Zeitpunkt, der bereits weit zurückliegt (abgestürzter Lauf). var longAgo = Now.AddMinutes(-30); (await _repo.TryClaimAsync("t-stuck", Occ(T0), "tok", longAgo, longAgo.AddMinutes(-1), default)).ShouldBeTrue(); // Reconciliation: Claims älter als 5 Minuten gelten als verwaist. var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default); reset.ShouldBe(1); var after = await _repo.GetAsync("t-stuck", default); after!.Status.ShouldBe(TaskItemStatus.Todo); after.ClaimToken.ShouldBeNull(); } [Fact] public async Task Ein_frischer_Claim_wird_bei_der_Reconciliation_nicht_angetastet() { await Seed("t-live"); (await _repo.TryClaimAsync("t-live", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default); reset.ShouldBe(0, "ein laufender Lauf darf nicht abgeräumt werden"); (await _repo.GetAsync("t-live", default))!.Status.ShouldBe(TaskItemStatus.InProgress); } [Fact] public async Task Der_Status_laesst_sich_direkt_setzen() { await Seed("t-1"); (await _repo.SetStatusAsync("t-1", TaskItemStatus.Canceled, Now, default)).ShouldBeTrue(); (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Canceled); } // ═══════════════════════════════════════════════════════════ // Nebenläufigkeit und Persistenz // ═══════════════════════════════════════════════════════════ [Fact] public async Task Viele_Aufgaben_lassen_sich_gleichzeitig_importieren() { var imports = Enumerable.Range(0, 60).Select(i => Seed($"t-{i}")); await Task.WhenAll(imports); (await _repo.CountAsync(default)).ShouldBe(60); } [Fact] public async Task Aufgaben_ueberdauern_das_Schliessen_der_Datenbank() { await Seed("t-1", title: "muss einen Neustart überleben"); var reopened = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); (await reopened.GetAsync("t-1", default))!.Title.ShouldBe("muss einen Neustart überleben"); } }