Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
311 lines
13 KiB
C#
311 lines
13 KiB
C#
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Core.Tasks;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Tasks;
|
|
|
|
/// <summary>
|
|
/// A1 taskboard: the execution state. Tested against a real SQLite file, because this is
|
|
/// precisely about schema, locking behavior and atomic claiming — a fake would
|
|
/// hide exactly what matters.
|
|
///
|
|
/// The three invariants from the taskboard concept are at the center: never two claims
|
|
/// on one occurrence, no dispatch with an open blocker, double tick = one run.
|
|
/// </summary>
|
|
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()
|
|
{
|
|
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
|
|
try { Directory.Delete(_directory, recursive: true); }
|
|
catch { /* Cleaning up is secondary */ }
|
|
}
|
|
|
|
private Task<TaskItem> Seed(
|
|
string id, TaskItemStatus status = TaskItemStatus.Todo,
|
|
string assignee = "@new", int priority = 3,
|
|
string title = "Aufgabe", IReadOnlyList<string>? 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 far in the past
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Basic functions: import (upsert), read, list
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task A_task_is_created_and_found_again()
|
|
{
|
|
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 The_import_is_idempotent_and_creates_no_duplicate()
|
|
{
|
|
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 A_re_import_leaves_the_execution_state_untouched()
|
|
{
|
|
// The crux of the split of truth: the file is the truth about the
|
|
// definition, the DB about the execution state. A renewed import must not reset a
|
|
// running or completed state.
|
|
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);
|
|
|
|
// The file is imported again (title changed, status in the frontmatter still "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 The_list_filters_by_status_and_hides_archived()
|
|
{
|
|
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 The_list_sorts_by_priority()
|
|
{
|
|
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 The_list_filters_by_assignee_and_free_text()
|
|
{
|
|
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);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Invariants 1 & 3: never two claims, double tick = one run
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task An_occurrence_can_be_claimed_exactly_once()
|
|
{
|
|
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 Concurrent_claims_on_the_same_occurrence_give_exactly_one_winner()
|
|
{
|
|
// The core invariant. Repeated several times, because a race occurs sporadically.
|
|
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 A_second_tick_on_the_same_occurrence_triggers_no_second_run()
|
|
{
|
|
await Seed("t-1");
|
|
|
|
// The first tick claims and completes.
|
|
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
|
|
await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default);
|
|
|
|
// Second tick in the same schedule window: the same 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 A_failed_run_is_not_retried_automatically()
|
|
{
|
|
// No retry storm: the marker is already set at claim time, not only on success.
|
|
await Seed("t-1");
|
|
(await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue();
|
|
|
|
// Run fails → back to todo, but the same occurrence stays used up.
|
|
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 The_next_scheduled_occurrence_can_be_claimed_again()
|
|
{
|
|
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);
|
|
|
|
// A later occurrence key lies above the marker → due again.
|
|
var later = Occ(T0.AddDays(1));
|
|
(await _repo.TryClaimAsync("t-1", later, "tok3", Now, NoStaleClaims, default))
|
|
.ShouldBeTrue("ein neuer Termin darf laufen");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Invariant 2: no dispatch with an open blocker
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task A_blocked_task_cannot_be_claimed()
|
|
{
|
|
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 Waiting_tasks_can_be_found_via_their_blocker()
|
|
{
|
|
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 completion, lease and reconciliation
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task A_run_without_a_valid_claim_cannot_overwrite_anything()
|
|
{
|
|
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 Orphaned_claims_are_reset_on_start()
|
|
{
|
|
await Seed("t-stuck");
|
|
// Claim with a point in time that lies far back already (crashed run).
|
|
var longAgo = Now.AddMinutes(-30);
|
|
(await _repo.TryClaimAsync("t-stuck", Occ(T0), "tok", longAgo, longAgo.AddMinutes(-1), default)).ShouldBeTrue();
|
|
|
|
// Reconciliation: claims older than 5 minutes count as orphaned.
|
|
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 A_fresh_claim_is_not_touched_by_reconciliation()
|
|
{
|
|
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 The_status_can_be_set_directly()
|
|
{
|
|
await Seed("t-1");
|
|
(await _repo.SetStatusAsync("t-1", TaskItemStatus.Canceled, Now, default)).ShouldBeTrue();
|
|
(await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// Concurrency and persistence
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
[Fact]
|
|
public async Task Many_tasks_can_be_imported_concurrently()
|
|
{
|
|
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 Tasks_outlast_closing_the_database()
|
|
{
|
|
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");
|
|
}
|
|
}
|