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
@@ -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)]