Files
Playa/tests/Volcanic.AgenticCore.Telegram.Tests/TriggerStoreTests.cs

157 lines
7.6 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Telegram.Groups;
using Volcanic.AgenticCore.Telegram.Triggers;
namespace Volcanic.AgenticCore.Telegram.Tests;
/// <summary>The group registry, the trigger store and the snooze queue: persistence and bookkeeping.</summary>
public sealed class TriggerStoreTests : IDisposable
{
private const long Owner = 1001;
private const long OtherOwner = 2002;
private const long Group = -1001234567890;
private readonly string _root = Path.Combine(Path.GetTempPath(), "volcanic-triggers", Guid.NewGuid().ToString("N"));
public void Dispose()
{
try { Directory.Delete(_root, true); } catch (IOException) { }
}
private TriggerStore Store(int max = 20) => new(Path.Combine(_root, "workspaces"), max, NullLogger<TriggerStore>.Instance);
private static NewTrigger Deploys(long group = Group) => new("deploys", group, "Dev Team", "Is this about deploys?", "Mentions deploys", "Small talk", 0.7);
internal static GroupMessage Message(string text, int id = 1, string sender = "Alice (@alice)", long group = Group) =>
new(group, "Dev Team", null, true, id, new DateTimeOffset(2026, 10, 5, 12, 2, 0, TimeSpan.Zero), sender, text);
[Fact]
public async Task Triggers_get_sequential_ids_per_owner_and_survive_a_reload()
{
var store = Store();
Assert.Equal("t1", (await store.AddAsync(Owner, Deploys(), CancellationToken.None)).Id);
Assert.Equal("t2", (await store.AddAsync(Owner, Deploys(), CancellationToken.None)).Id);
Assert.Equal("t1", (await store.AddAsync(OtherOwner, Deploys(), CancellationToken.None)).Id);
await store.RemoveAsync(Owner, "t1", CancellationToken.None);
Assert.Equal("t3", (await store.AddAsync(Owner, Deploys(), CancellationToken.None)).Id);
var reloaded = Store();
Assert.Equal(["t2", "t3"], reloaded.List(Owner).Select(t => t.Id));
Assert.Equal(3, reloaded.Watching(Group).Count);
Assert.True(File.Exists(TriggerStore.PathIn(reloaded.WorkspaceOf(Owner))));
}
[Fact]
public async Task The_per_chat_limit_is_enforced()
{
var store = Store(max: 1);
await store.AddAsync(Owner, Deploys(), CancellationToken.None);
await Assert.ThrowsAsync<TriggerLimitException>(() => store.AddAsync(Owner, Deploys(), CancellationToken.None));
await store.AddAsync(OtherOwner, Deploys(), CancellationToken.None); // per chat, not global
}
[Fact]
public async Task Updates_keep_id_and_creation_time_and_snoozes_are_tracked()
{
var store = Store();
var t = await store.AddAsync(Owner, Deploys(), CancellationToken.None);
var until = DateTimeOffset.UtcNow.AddHours(1);
var updated = await store.UpdateAsync(Owner, "T1", x => x with { Id = "hijack", Threshold = 0.9, SnoozedUntil = until }, CancellationToken.None);
Assert.Equal(("t1", t.CreatedAt, 0.9), (updated!.Id, updated.CreatedAt, updated.Threshold));
Assert.Equal(until, store.NextSnoozeEnd());
Assert.Empty(store.SnoozeDue(until.AddSeconds(-1)));
Assert.Single(store.SnoozeDue(until));
Assert.Null(await store.UpdateAsync(Owner, "t9", x => x, CancellationToken.None));
}
[Fact]
public async Task Moving_a_group_moves_every_owners_triggers()
{
var store = Store();
await store.AddAsync(Owner, Deploys(-5), CancellationToken.None);
await store.AddAsync(OtherOwner, Deploys(-5), CancellationToken.None);
await store.MoveGroupAsync(-5, Group, CancellationToken.None);
Assert.Empty(store.Watching(-5));
Assert.Equal(2, Store().Watching(Group).Count);
}
[Fact]
public async Task A_corrupt_file_is_set_aside()
{
var store = Store();
var path = TriggerStore.PathIn(store.WorkspaceOf(Owner));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, "{ not json");
Assert.Empty(Store().List(Owner));
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(path)!, "triggers.json.corrupt-*"));
}
[Fact]
public async Task The_snooze_queue_caps_drops_the_oldest_and_takes_all_at_once()
{
var queue = new SnoozeQueue(maxPerTrigger: 2);
var ws = Path.Combine(_root, "ws");
Assert.Equal((1, false), await queue.AppendAsync(ws, "t1", Message("one", 1), CancellationToken.None));
Assert.Equal((2, false), await queue.AppendAsync(ws, "t1", Message("two", 2), CancellationToken.None));
Assert.Equal((2, true), await queue.AppendAsync(ws, "t1", Message("three", 3), CancellationToken.None));
Assert.Equal(2, queue.Count(ws, "t1"));
var taken = await queue.TakeAllAsync(ws, "t1", CancellationToken.None);
Assert.Equal(["two", "three"], taken.Select(m => m.Text));
Assert.Equal(Message("two", 2), taken[0]);
Assert.Equal(0, queue.Count(ws, "t1"));
Assert.Empty(await queue.TakeAllAsync(ws, "t1", CancellationToken.None));
await queue.DeleteAsync(Path.Combine(_root, "missing"), "t1", CancellationToken.None);
}
[Fact]
public async Task Groups_are_vouched_refreshed_paused_migrated_and_persisted()
{
var path = GroupRegistry.DefaultPath(Path.Combine(_root, "agent"));
var registry = new GroupRegistry(path, NullLogger<GroupRegistry>.Instance);
var chat = new Chat { Id = -5, Type = ChatType.Group, Title = "Dev" };
Assert.True(await registry.VouchAsync(chat, Owner));
Assert.False(await registry.VouchAsync(chat, Owner));
Assert.True(await registry.RefreshAsync(new Chat { Id = -5, Type = ChatType.Group, Title = "Dev Team" }));
Assert.False(await registry.RefreshAsync(new Chat { Id = -9, Type = ChatType.Group, Title = "Unknown" }));
Assert.True(await registry.SetInactiveAsync(-5));
Assert.False(registry.Find(-5)!.Active);
Assert.True(await registry.VouchAsync(new Chat { Id = -5, Type = ChatType.Group, Title = "Dev Team" }, Owner));
Assert.True(registry.Find(-5)!.Active);
Assert.True(await registry.MigrateAsync(-5, Group));
var reloaded = new GroupRegistry(path, NullLogger<GroupRegistry>.Instance);
var group = Assert.Single(reloaded.All());
Assert.Equal((Group, "Dev Team", true, Owner, true), (group.Id, group.Title, group.IsSupergroup, group.AddedBy, group.Active));
}
[Fact]
public void Message_links_and_senders()
{
Assert.Equal("https://t.me/c/1234567890/7", (Message("x") with { MessageId = 7 }).Link);
Assert.Equal("https://t.me/devteam/7", (Message("x") with { MessageId = 7, GroupUsername = "devteam" }).Link);
Assert.Null((Message("x") with { IsSupergroup = false, GroupId = -5 }).Link);
var message = new Message
{
Id = 3,
Chat = new Chat { Id = Group, Type = ChatType.Supergroup, Title = "Dev Team" },
From = new User { Id = 7, FirstName = "Alice", LastName = "Smith", Username = "alice" },
Caption = "deploy log attached",
Date = new DateTime(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc),
ReplyToMessage = new Message { Id = 2, Chat = new Chat { Id = Group }, From = new User { Id = 8, FirstName = "Bob" }, Text = "status?" },
};
var built = GroupMessage.From(message);
Assert.Equal(("Alice Smith (@alice)", "deploy log attached", "Bob", "status?"), (built.Sender, built.Text, built.ReplyTo!.Sender, built.ReplyTo.Text));
Assert.Equal("Dev Team", GroupMessage.SenderOf(new Message { Chat = new Chat { Id = Group }, SenderChat = new Chat { Id = Group, Title = "Dev Team" } }));
}
}