Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/SchedulerChatTests.cs
EugeneTes 8014a0e770 refactor(scheduler): drop the reminder job kind
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.
2026-09-29 09:30:40 +00:00

155 lines
6.7 KiB
C#

using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>The scheduler through the Telegram delivery and commands, on a fake clock with a fake script runner.</summary>
public sealed class SchedulerChatTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
internal static readonly DateTimeOffset Now = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero); // Sat 14:03 Berlin
private const long OtherUser = 2002;
internal sealed class Runner : IScriptRunner
{
public int Calls;
public ScriptOutcome Outcome { get; set; } = new ScriptOutcome.Silent();
public Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
return Task.FromResult(Outcome);
}
}
internal static async Task<(ChatHost Host, FakeTimeProvider Time)> StartedAsync(IScriptRunner? runner = null, Action<Microsoft.Extensions.DependencyInjection.IServiceCollection>? services = null)
{
var time = new FakeTimeProvider(Now);
var host = new ChatHost(core: o => o.DefaultTimeZone = Berlin, time: time, scheduler: new SchedulerOptions(), runner: runner);
host.Options.AllowedUserIds.Add(OtherUser);
await host.StartSchedulerAsync();
return (host, time);
}
internal static async Task AdvanceAsync(ChatHost host, FakeTimeProvider time, TimeSpan by)
{
time.Advance(by);
await host.Scheduler!.IdleAsync();
}
[Fact]
public async Task A_silent_watcher_with_a_fake_runner_creates_no_session_and_sends_nothing()
{
var runner = new Runner();
var (host, time) = await StartedAsync(runner);
await using var _ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Price", JobKind.Watcher, Cron: "* * * * *", Command: "scripts/price.sh"), CancellationToken.None);
for (var i = 0; i < 5; i++) await AdvanceAsync(host, time, TimeSpan.FromMinutes(1));
Assert.Equal(5, runner.Calls);
Assert.Null(host.Chats.Find(Updates.AllowedUser)); // no session created or woken
Assert.Empty(host.Bot.Requests);
Assert.Empty(host.Faux.Requests);
}
[Fact]
public async Task Delivery_WorkspaceFor_and_TimeZoneFor_never_create_a_session()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.Settings.SetTimeZoneAsync(Updates.AllowedUser, "Asia/Tokyo", CancellationToken.None);
Assert.Equal(host.WorkspaceOf(), host.Delivery!.WorkspaceFor(ChatHost.Target));
Assert.Equal("Asia/Tokyo", host.Delivery.TimeZoneFor(ChatHost.Target).Id);
Assert.Same(host.Settings.DefaultZone, host.Delivery.TimeZoneFor("not-a-chat")); // a hand-edited store never throws here
Assert.Null(host.Chats.Find(Updates.AllowedUser));
}
[Fact]
public async Task IsDeliverable_follows_the_allow_list()
{
var (host, _) = await StartedAsync();
await using var __ = host;
Assert.True(host.Delivery!.IsDeliverable(ChatHost.Target));
Assert.False(host.Delivery.IsDeliverable(Updates.Stranger.ToString(System.Globalization.CultureInfo.InvariantCulture)));
Assert.False(host.Delivery.IsDeliverable("not-a-chat"));
}
[Fact]
public async Task Jobs_lists_this_chats_jobs_in_its_zone()
{
var (host, _) = await StartedAsync();
await using var __ = host;
var once = await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Prompt, At: "2026-09-27T09:00", Text: "Call"), CancellationToken.None);
var cron = await host.Scheduler.CreateAsync(ChatHost.Target, new NewJob("Inbox summary", JobKind.Prompt, Cron: "0 8 * * 1-5", Text: "Summarise"), CancellationToken.None);
await host.SendAsync("/jobs");
Assert.Equal(
$"Scheduled jobs (Europe/Berlin, now 26 Sep 14:03)\n{once.Job.Id} 💬 Call the dentist · once 27 Sep 09:00\n{cron.Job.Id} 💬 Inbox summary · 0 8 * * 1-5 · next 28 Sep 08:00",
host.LastReply());
}
[Fact]
public async Task Jobs_without_jobs_says_so()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.SendAsync("/jobs");
Assert.Equal("No scheduled jobs.", host.LastReply());
}
[Fact]
public async Task Cancel_handles_own_unknown_and_other_chats_ids()
{
var (host, _) = await StartedAsync();
await using var __ = host;
var mine = (await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("Call the dentist", JobKind.Prompt, Cron: "@daily", Text: "x"), CancellationToken.None)).Job.Id;
var theirs = (await host.Scheduler.CreateAsync("2002", new NewJob("Theirs", JobKind.Prompt, Cron: "@daily", Text: "y"), CancellationToken.None)).Job.Id;
await host.SendAsync($"/cancel {theirs}");
Assert.Equal($"No job {theirs} in this chat.", host.LastReply());
await host.SendAsync("/cancel zzzzz");
Assert.Equal("No job zzzzz in this chat.", host.LastReply());
await host.SendAsync($"/cancel {mine.ToUpperInvariant()}");
Assert.Equal($"Cancelled {mine} (Call the dentist).", host.LastReply());
Assert.Empty(host.Scheduler.List(ChatHost.Target));
Assert.Single(host.Scheduler.List("2002"));
}
[Fact]
public async Task Cancel_without_an_id_shows_usage()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.SendAsync("/cancel");
Assert.Equal("Usage: /cancel <id> (see /jobs).", host.LastReply());
}
[Fact]
public async Task Status_shows_the_job_count()
{
var (host, _) = await StartedAsync();
await using var __ = host;
await host.Scheduler!.CreateAsync(ChatHost.Target, new NewJob("a", JobKind.Prompt, Cron: "@daily", Text: "x"), CancellationToken.None);
await host.SendAsync("/status");
Assert.Contains("Jobs: 1\n", host.LastReply());
}
[Fact]
public async Task Without_a_scheduler_jobs_says_scheduling_is_off()
{
await using var host = new ChatHost();
await host.SendAsync("/jobs");
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
await host.SendAsync("/cancel abcde");
Assert.Equal(SchedulerSetup.DisabledReply, host.LastReply());
await host.SendAsync("/status");
Assert.DoesNotContain("Jobs:", host.LastReply());
Assert.Contains("Time zone:", host.LastReply());
}
}