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.
155 lines
6.7 KiB
C#
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());
|
|
}
|
|
}
|