feat: add web batch bulk operations
Deploy Telegram Bot / build-and-push (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 11s

This commit is contained in:
2026-04-27 09:31:51 +03:00
parent 5f3516e703
commit 621ef553e7
12 changed files with 931 additions and 7 deletions
@@ -162,6 +162,129 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(sessionId, store.LastPromotedSessionId);
}
[Fact]
public async Task UpdateBatchDetailsForGmAsync_Throws_WhenBatchBelongsToAnotherGm()
{
var batchId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b");
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchDetailsCalled);
}
[Fact]
public async Task UpdateBatchDetailsForGmAsync_UpdatesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
Assert.Equal(groupId, store.LastUpdatedBatchGroupId);
Assert.Equal("Updated", store.LastUpdatedBatchTitle);
Assert.Equal("https://example.test/b", store.LastUpdatedBatchJoinLink);
}
[Fact]
public async Task RescheduleBatchForGmAsync_RejectsNonPositiveInterval()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
Assert.False(store.RescheduleBatchCalled);
}
[Fact]
public async Task RescheduleBatchForGmAsync_ReschedulesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var firstScheduledAt = DateTime.UtcNow.AddDays(7);
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14);
Assert.True(store.RescheduleBatchCalled);
Assert.Equal(batchId, store.LastRescheduledBatchId);
Assert.Equal(groupId, store.LastRescheduledBatchGroupId);
Assert.Equal(firstScheduledAt, store.LastRescheduledFirstScheduledAt);
Assert.Equal(14, store.LastRescheduledIntervalDays);
}
[Fact]
public async Task CloneBatchForGmAsync_ClonesOwnedBatch()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var batchId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek);
Assert.True(store.CloneBatchCalled);
Assert.Equal(batchId, store.LastClonedBatchId);
Assert.Equal(groupId, store.LastClonedBatchGroupId);
Assert.Equal(BatchCloneInterval.NextWeek, store.LastCloneInterval);
}
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null) : ISessionStore
@@ -171,6 +294,9 @@ public sealed class AuthorizedSessionServiceTests
public bool UpdateCalled { get; private set; }
public bool PromoteCalled { get; private set; }
public bool UpdateBatchDetailsCalled { get; private set; }
public bool RescheduleBatchCalled { get; private set; }
public bool CloneBatchCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; }
public Guid? LastUpdatedGroupId { get; private set; }
public string? LastUpdatedTitle { get; private set; }
@@ -179,6 +305,17 @@ public sealed class AuthorizedSessionServiceTests
public int? LastUpdatedMaxPlayers { get; private set; }
public Guid? LastPromotedSessionId { get; private set; }
public Guid? LastPromotedGroupId { get; private set; }
public Guid? LastUpdatedBatchId { get; private set; }
public Guid? LastUpdatedBatchGroupId { get; private set; }
public string? LastUpdatedBatchTitle { get; private set; }
public string? LastUpdatedBatchJoinLink { get; private set; }
public Guid? LastRescheduledBatchId { get; private set; }
public Guid? LastRescheduledBatchGroupId { get; private set; }
public DateTime? LastRescheduledFirstScheduledAt { get; private set; }
public int? LastRescheduledIntervalDays { get; private set; }
public Guid? LastClonedBatchId { get; private set; }
public Guid? LastClonedBatchGroupId { get; private set; }
public BatchCloneInterval? LastCloneInterval { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
@@ -198,6 +335,29 @@ public sealed class AuthorizedSessionServiceTests
return Task.FromResult(session);
}
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
{
var batchSessions = sessionsById.Values
.Where(session => session.BatchId == batchId)
.OrderBy(session => session.ScheduledAt)
.ToList();
if (batchSessions.Count == 0)
{
return Task.FromResult<WebSessionBatch?>(null);
}
var firstSession = batchSessions[0];
return Task.FromResult<WebSessionBatch?>(new(
batchId,
firstSession.GroupId,
firstSession.Title,
firstSession.JoinLink,
firstSession.ScheduledAt,
batchSessions[^1].ScheduledAt,
batchSessions.Count));
}
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{
UpdateCalled = true;
@@ -217,5 +377,41 @@ public sealed class AuthorizedSessionServiceTests
LastPromotedGroupId = groupId;
return Task.CompletedTask;
}
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
{
UpdateBatchDetailsCalled = true;
LastUpdatedBatchId = batchId;
LastUpdatedBatchGroupId = groupId;
LastUpdatedBatchTitle = title;
LastUpdatedBatchJoinLink = joinLink;
return Task.CompletedTask;
}
public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
{
RescheduleBatchCalled = true;
LastRescheduledBatchId = batchId;
LastRescheduledBatchGroupId = groupId;
LastRescheduledFirstScheduledAt = firstScheduledAt;
LastRescheduledIntervalDays = intervalDays;
return Task.CompletedTask;
}
public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
{
CloneBatchCalled = true;
LastClonedBatchId = batchId;
LastClonedBatchGroupId = groupId;
LastCloneInterval = interval;
return Task.FromResult(new WebSessionBatch(
Guid.NewGuid(),
groupId,
"Session A",
"https://example.test/a",
DateTime.UtcNow.AddDays(7),
DateTime.UtcNow.AddDays(7),
1));
}
}
}
@@ -0,0 +1,51 @@
using GmRelay.Web.Services;
namespace GmRelay.Bot.Tests.Web;
public sealed class BatchSchedulePlannerTests
{
[Fact]
public void BuildFixedIntervalSchedule_OrdersSessionsAndAppliesInterval()
{
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var currentSchedule = new[]
{
new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 21, 16, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 5, 5, 16, 0, 0, DateTimeKind.Utc)
};
var result = BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 7);
Assert.Equal(
[
firstScheduledAt,
firstScheduledAt.AddDays(7),
firstScheduledAt.AddDays(14)
],
result);
}
[Fact]
public void BuildFixedIntervalSchedule_RejectsNonPositiveInterval()
{
var currentSchedule = new[] { new DateTime(2026, 4, 28, 16, 0, 0, DateTimeKind.Utc) };
var firstScheduledAt = new DateTime(2026, 5, 4, 16, 0, 0, DateTimeKind.Utc);
var action = () => BatchSchedulePlanner.BuildFixedIntervalSchedule(currentSchedule, firstScheduledAt, intervalDays: 0);
Assert.Throws<ArgumentOutOfRangeException>(action);
}
[Theory]
[InlineData(BatchCloneInterval.NextWeek, 2026, 5, 8)]
[InlineData(BatchCloneInterval.NextMonth, 2026, 6, 1)]
public void ShiftForClone_AppliesRequestedCalendarShift(BatchCloneInterval interval, int expectedYear, int expectedMonth, int expectedDay)
{
var scheduledAt = new DateTime(2026, 5, 1, 16, 30, 0, DateTimeKind.Utc);
var result = BatchSchedulePlanner.ShiftForClone(scheduledAt, interval);
Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, 16, 30, 0, DateTimeKind.Utc), result);
}
}