using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Audit; /// /// #700: Audit and receipts are evidence — nothing is deleted. Old runs move from the working /// database into one archive file per month and are still found; each day is condensed per /// agent and unit first, and those values stay. /// public sealed class AuditArchiveTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "arc-" + Guid.NewGuid().ToString("N")[..8]); private readonly SqliteStorage _storage; private readonly SqliteAuditRepository _audit; private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 10, 2, 9, 0, 0, TimeSpan.Zero)); private readonly AuditArchiveSettings _settings = new() { HotDays = 30 }; private readonly AuditArchive _archive; public AuditArchiveTests() { _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); _audit = new SqliteAuditRepository(_storage); _archive = new AuditArchive(_storage, () => _settings, agent => agent == "desk" ? "sales" : null, NullLogger.Instance, _clock); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_directory); try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private async Task Run(string runId, string agent, DateTime at, string status = "Completed", decimal cost = 0.02m, params string[] callStatus) { foreach (var s in callStatus) await _audit.AppendAsync(new AuditEntry { RunId = runId, AgentId = agent, Tool = "FileRW", Arguments = "{\"action\":\"read\"}", Status = Enum.Parse(s), OccurredAt = at }, default); await _audit.RecordReceiptAsync(new RunReceipt { RunId = runId, AgentId = agent, Status = status, CostUsd = cost, CostIsKnown = true, PromptTokens = 1000, CompletionTokens = 100, OccurredAt = at }, default); } [Fact] public async Task Each_past_day_is_condensed_per_agent_with_its_unit_and_today_waits() { var yesterday = new DateTime(2026, 10, 1, 12, 0, 0, DateTimeKind.Utc); await Run("r1", "desk", yesterday, callStatus: ["Ok", "Error", "Staged"]); await Run("r2", "desk", yesterday.AddHours(1), "LoopLimitExceeded", 0.05m, "Ok"); await Run("r3", "desk", yesterday.AddHours(2), "Failed"); await Run("r4", "desk", yesterday.AddHours(3), "BudgetExceeded", 0m); await Run("r5", "other", yesterday.AddDays(-1), callStatus: ["Denied"]); await Run("r6", "desk", new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc)); await _archive.CondenseAsync(default); var days = await _archive.DaysAsync(new DateOnly(2026, 9, 1), new DateOnly(2026, 10, 2), null, null, default); days.Select(d => (d.Day, d.AgentId)).ShouldBe([("2026-09-30", "other"), ("2026-10-01", "desk")]); var desk = days.Single(d => d.AgentId == "desk"); (desk.UnitId, desk.Runs, desk.Failed, desk.LoopLimit).ShouldBe(("sales", 3, 1, 1), "a budget refusal is no run"); desk.CostUsd.ShouldBe(0.09m); (desk.ToolCalls, desk.ToolErrors, desk.Staged).ShouldBe((3, 1, 1)); days.Single(d => d.AgentId == "other").Denied.ShouldBe(1); (await _archive.DaysAsync(new DateOnly(2026, 9, 1), new DateOnly(2026, 10, 2), null, "sales", default)).Count.ShouldBe(1); } [Fact] public async Task Old_runs_move_to_their_month_archive_are_still_found_and_nothing_is_lost() { await Run("aug", "desk", new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Started", "Ok"]); await Run("jul", "desk", new DateTime(2026, 7, 31, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Ok"]); await Run("now", "desk", new DateTime(2026, 9, 28, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Ok"]); var moved = await _archive.ArchiveAsync(default); moved.ShouldBe(5, "three audit rows and two receipts"); _archive.Files().Select(f => f.Month).ShouldBe(["2026-08", "2026-07"]); (await _audit.CountAsync(default)).ShouldBe(1, "only the recent run stays in the working table"); (await _audit.ListForRunAsync("aug", default)).Count.ShouldBe(2); (await _audit.GetReceiptForRunAsync("jul", default))!.AgentId.ShouldBe("desk"); (await _audit.GetReceiptForRunAsync("now", default)).ShouldNotBeNull(); (await _archive.DaysAsync(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), null, null, default)) .Select(d => d.Day).ShouldBe(["2026-07-31", "2026-08-20"], "the day values stay after the move"); (await _archive.ArchiveAsync(default)).ShouldBe(0, "a second pass moves nothing twice"); } [Fact] public async Task A_column_added_later_is_added_to_an_existing_archive() { await Run("a1", "desk", new DateTime(2026, 8, 1, 10, 0, 0, DateTimeKind.Utc)); await _archive.ArchiveAsync(default); await _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = "ALTER TABLE RunReceipts ADD COLUMN LaterField TEXT NULL"; await cmd.ExecuteNonQueryAsync(); }, default); await Run("a2", "desk", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc)); (await _archive.ArchiveAsync(default)).ShouldBe(1); (await _audit.GetReceiptForRunAsync("a2", default)).ShouldNotBeNull(); } [Fact] public async Task Zero_days_keeps_everything_in_the_working_database() { _settings.HotDays = 0; await Run("old", "desk", new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc), callStatus: ["Ok"]); (await _archive.ArchiveAsync(default)).ShouldBe(0); _archive.Files().ShouldBeEmpty(); (await _audit.CountAsync(default)).ShouldBe(1); } }