feat: add campaign templates and recurring schedules
Deploy Telegram Bot / build-and-push (push) Successful in 3m49s
Deploy Telegram Bot / deploy (push) Successful in 10s

This commit is contained in:
2026-04-28 10:01:18 +03:00
parent a1ec688ec8
commit 0218890a7a
17 changed files with 1075 additions and 14 deletions
@@ -33,6 +33,32 @@ public sealed class NewSessionCommandParserTests
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
@@ -460,13 +460,153 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_CreatesTemplate_WhenGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
" Weekly arc ",
" Kingmaker ",
" https://example.test/kingmaker ",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Equal(groupId, store.LastCreatedCampaignTemplateGroupId);
Assert.Equal("Weekly arc", store.LastCreatedCampaignTemplateRequest?.Name);
Assert.Equal("Kingmaker", store.LastCreatedCampaignTemplateRequest?.Title);
Assert.Equal("https://example.test/kingmaker", store.LastCreatedCampaignTemplateRequest?.JoinLink);
Assert.Equal(6, store.LastCreatedCampaignTemplateRequest?.SessionCount);
Assert.Equal(7, store.LastCreatedCampaignTemplateRequest?.IntervalDays);
Assert.Equal(5, store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
Assert.Equal(SessionNotificationMode.GroupOnly, store.LastCreatedCampaignTemplateRequest?.NotificationMode);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_AllowsNoSeatLimit()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
await service.CreateCampaignTemplateForGmAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
"Open table",
"West Marches",
"https://example.test/west",
8,
7,
null,
SessionNotificationMode.GroupAndDirect));
Assert.True(store.CreateCampaignTemplateCalled);
Assert.Null(store.LastCreatedCampaignTemplateRequest?.MaxPlayers);
}
[Fact]
public async Task CreateCampaignTemplateForGmAsync_Throws_WhenGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateCampaignTemplateForGmAsync(
groupId,
1001L,
new CreateCampaignTemplateRequest(
"Weekly arc",
"Kingmaker",
"https://example.test/kingmaker",
SessionCount: 6,
IntervalDays: 7,
MaxPlayers: 5,
SessionNotificationMode.GroupOnly));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateCampaignTemplateCalled);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_CreatesBatch_WhenTemplateGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var firstScheduledAt = DateTime.UtcNow.AddDays(3);
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);
Assert.True(store.CreateBatchFromTemplateCalled);
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
Assert.Equal(groupId, store.LastCreatedBatchTemplateGroupId);
Assert.Equal(firstScheduledAt, store.LastCreatedBatchFirstScheduledAt);
}
[Fact]
public async Task CreateBatchFromCampaignTemplateForGmAsync_Throws_WhenTemplateGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var templateId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
templates:
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateBatchFromTemplateCalled);
}
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null,
IEnumerable<FakeGroupManager>? managers = null) : ISessionStore
IEnumerable<FakeGroupManager>? managers = null,
IEnumerable<WebCampaignTemplate>? templates = null) : ISessionStore
{
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
private readonly Dictionary<Guid, WebCampaignTemplate> templatesById = templates?.ToDictionary(template => template.Id) ?? [];
private readonly List<FakeGroupManager> managers = managers?.ToList() ?? [];
public bool UpdateCalled { get; private set; }
@@ -475,6 +615,9 @@ public sealed class AuthorizedSessionServiceTests
public bool UpdateBatchNotificationModeCalled { get; private set; }
public bool RescheduleBatchCalled { get; private set; }
public bool CloneBatchCalled { get; private set; }
public bool CreateCampaignTemplateCalled { get; private set; }
public bool DeleteCampaignTemplateCalled { get; private set; }
public bool CreateBatchFromTemplateCalled { get; private set; }
public bool AddCoGmCalled { get; private set; }
public bool RemoveCoGmCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; }
@@ -499,6 +642,13 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastClonedBatchId { get; private set; }
public Guid? LastClonedBatchGroupId { get; private set; }
public BatchCloneInterval? LastCloneInterval { get; private set; }
public Guid? LastCreatedCampaignTemplateGroupId { get; private set; }
public CreateCampaignTemplateRequest? LastCreatedCampaignTemplateRequest { get; private set; }
public Guid? LastDeletedCampaignTemplateId { get; private set; }
public Guid? LastDeletedCampaignTemplateGroupId { get; private set; }
public Guid? LastCreatedBatchTemplateId { get; private set; }
public Guid? LastCreatedBatchTemplateGroupId { get; private set; }
public DateTime? LastCreatedBatchFirstScheduledAt { get; private set; }
public Guid? LastAddedCoGmGroupId { get; private set; }
public long? LastAddedCoGmTelegramId { get; private set; }
public string? LastAddedCoGmDisplayName { get; private set; }
@@ -642,6 +792,66 @@ public sealed class AuthorizedSessionServiceTests
1));
}
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) =>
Task.FromResult(templatesById.Values.Where(template => template.GroupId == groupId).ToList());
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
{
templatesById.TryGetValue(templateId, out var template);
return Task.FromResult(template);
}
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
{
CreateCampaignTemplateCalled = true;
LastCreatedCampaignTemplateGroupId = groupId;
LastCreatedCampaignTemplateRequest = request;
var template = new WebCampaignTemplate(
Guid.NewGuid(),
groupId,
request.Name,
request.Title,
request.JoinLink,
request.SessionCount,
request.IntervalDays,
request.MaxPlayers,
request.NotificationMode.ToDatabaseValue(),
DateTime.UtcNow,
DateTime.UtcNow);
templatesById[template.Id] = template;
return Task.FromResult(template);
}
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
{
DeleteCampaignTemplateCalled = true;
LastDeletedCampaignTemplateId = templateId;
LastDeletedCampaignTemplateGroupId = groupId;
templatesById.Remove(templateId);
return Task.CompletedTask;
}
public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
{
CreateBatchFromTemplateCalled = true;
LastCreatedBatchTemplateId = templateId;
LastCreatedBatchTemplateGroupId = groupId;
LastCreatedBatchFirstScheduledAt = firstScheduledAt;
var template = templatesById[templateId];
return Task.FromResult(new WebSessionBatch(
Guid.NewGuid(),
groupId,
template.Title,
template.JoinLink,
firstScheduledAt,
firstScheduledAt.AddDays(template.IntervalDays * (template.SessionCount - 1)),
template.SessionCount,
template.NotificationMode));
}
public Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
AddCoGmCalled = true;
@@ -37,6 +37,35 @@ public sealed class BatchSchedulePlannerTests
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Fact]
public void BuildRecurringSchedule_CreatesFixedIntervalScheduleFromFirstDate()
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var result = BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount: 3, intervalDays: 14);
Assert.Equal(
[
firstScheduledAt,
firstScheduledAt.AddDays(14),
firstScheduledAt.AddDays(28)
],
result);
}
[Theory]
[InlineData(0, 7)]
[InlineData(53, 7)]
[InlineData(3, 0)]
public void BuildRecurringSchedule_RejectsInvalidTemplateShape(int sessionCount, int intervalDays)
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var action = () => BatchSchedulePlanner.BuildRecurringSchedule(firstScheduledAt, sessionCount, intervalDays);
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Theory]
[InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)]
[InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]