forked from admin/VolcanicAgenticCore
Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
132 lines
6.1 KiB
C#
132 lines
6.1 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Volcanic.AgenticCore.Extensions.Scheduler;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler;
|
|
|
|
public sealed class JobStoreTests : IDisposable
|
|
{
|
|
private static readonly DateTimeOffset Now = new(2026, 9, 26, 14, 3, 0, TimeSpan.Zero);
|
|
private readonly string _root = Directory.CreateTempSubdirectory("jobstore-").FullName;
|
|
private readonly FakeTimeProvider _time = new(Now);
|
|
|
|
private string StorePath => Path.Combine(_root, "scheduler", "jobs.json");
|
|
private JobStore Store() => new(StorePath, _time, NullLogger.Instance);
|
|
|
|
internal static ScheduledJob Prompt(string id, string target = "111") => new()
|
|
{
|
|
Id = id, Target = target, Name = "Call the dentist", Kind = JobKind.Prompt, Text = "Call the dentist",
|
|
Schedule = new JobSchedule(Now.AddDays(1), null), CreatedAt = Now,
|
|
};
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Missing_file_loads_empty() => Assert.Empty(await Store().LoadAsync(CancellationToken.None));
|
|
|
|
[Fact]
|
|
public async Task Round_trip_keeps_jobs_and_leaves_no_temp_file()
|
|
{
|
|
var watcher = new ScheduledJob
|
|
{
|
|
Id = "w8r4t", Target = "111", Name = "Price check", Kind = JobKind.Watcher, Command = "scripts/price.sh",
|
|
TimeoutSeconds = 60, Schedule = new JobSchedule(null, "*/10 * * * *"), CreatedAt = Now,
|
|
LastRunAt = Now, LastResult = new JobResult(false, 1, false, "boom", Now),
|
|
};
|
|
await Store().SaveAsync([Prompt("k7m2q"), watcher], CancellationToken.None);
|
|
|
|
Assert.Equal([Prompt("k7m2q"), watcher], await Store().LoadAsync(CancellationToken.None));
|
|
Assert.Equal(["jobs.json"], Directory.GetFiles(Path.GetDirectoryName(StorePath)!).Select(Path.GetFileName));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task File_format_is_versioned_camel_case()
|
|
{
|
|
await Store().SaveAsync([Prompt("k7m2q")], CancellationToken.None);
|
|
var json = await File.ReadAllTextAsync(StorePath);
|
|
Assert.Contains("\"version\": 1", json);
|
|
Assert.Contains("\"jobs\": [", json);
|
|
Assert.Contains("\"kind\": \"prompt\"", json);
|
|
Assert.NotEqual(0xEF, (await File.ReadAllBytesAsync(StorePath))[0]); // no BOM
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Corrupt_file_is_set_aside_and_loading_starts_empty()
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
|
|
await File.WriteAllTextAsync(StorePath, "{ not json");
|
|
|
|
Assert.Empty(await Store().LoadAsync(CancellationToken.None));
|
|
Assert.False(File.Exists(StorePath));
|
|
Assert.Equal("{ not json", await File.ReadAllTextAsync(StorePath + ".corrupt-20260926140300"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invalid_entries_are_dropped()
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
|
|
await File.WriteAllTextAsync(StorePath, """
|
|
{ "version": 1, "jobs": [
|
|
{ "id": "good1", "target": "111", "name": "ok", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
|
{ "id": "both1", "target": "111", "name": "both", "kind": "prompt", "text": "hi", "schedule": { "at": "2026-09-27T09:00:00+00:00", "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
|
{ "id": "nocmd", "target": "111", "name": "watch", "kind": "watcher", "schedule": { "cron": "* * * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
|
{ "id": "good1", "target": "111", "name": "duplicate id", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" }
|
|
] }
|
|
""");
|
|
|
|
var jobs = await Store().LoadAsync(CancellationToken.None);
|
|
|
|
Assert.Equal("ok", Assert.Single(jobs).Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_entry_that_does_not_deserialise_is_dropped_without_setting_the_file_aside()
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
|
|
await File.WriteAllTextAsync(StorePath, """
|
|
{ "version": 1, "jobs": [
|
|
{ "id": "good1", "target": "111", "name": "ok", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
|
{ "id": "kind1", "target": "111", "name": "bad kind", "kind": "alarm", "text": "hi", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
|
{ "id": "noat1", "target": "111", "name": "no createdAt", "kind": "prompt", "text": "hi", "schedule": { "cron": "0 9 * * *" } },
|
|
null,
|
|
42
|
|
] }
|
|
""");
|
|
|
|
var jobs = await Store().LoadAsync(CancellationToken.None);
|
|
|
|
Assert.Equal("good1", Assert.Single(jobs).Id);
|
|
Assert.True(File.Exists(StorePath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stored_reminder_is_loaded_as_a_prompt()
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
|
|
await File.WriteAllTextAsync(StorePath, """
|
|
{ "version": 1, "jobs": [
|
|
{ "id": "k7m2q", "target": "111", "name": "Dentist", "kind": "reminder", "text": "Call the dentist", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" },
|
|
{ "id": "empty", "target": "111", "name": "No text", "kind": "reminder", "schedule": { "cron": "0 9 * * *" }, "createdAt": "2026-09-26T14:03:00+00:00" }
|
|
] }
|
|
""");
|
|
|
|
var job = Assert.Single(await Store().LoadAsync(CancellationToken.None));
|
|
|
|
Assert.Equal(JobKind.Prompt, job.Kind);
|
|
Assert.Equal("Remind me: Call the dentist", job.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_saves_are_serialised()
|
|
{
|
|
var store = Store();
|
|
await Task.WhenAll(Enumerable.Range(0, 20).Select(i => Task.Run(() => store.SaveAsync([Prompt($"id{i:000}")], CancellationToken.None))));
|
|
|
|
Assert.Single(await Store().LoadAsync(CancellationToken.None));
|
|
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(StorePath)!, "*.tmp"));
|
|
}
|
|
}
|