feat(web): finalize Discord OAuth and platform-agnostic auth
PR Checks / test-and-build (pull_request) Successful in 5m47s

- Bump version to 2.8.0 across all versioned files
- Fix AuthorizedSessionServiceTests for platform-agnostic identity
- Update Razor Pages to use *ForCurrentUserAsync APIs
- Add backward-compatible constructors to WebGameGroup/WebGroupManager
- Make DiscordOAuthOptions properties non-required for config binding

Bump version → 2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:47:54 +03:00
parent 5fa7e26f72
commit 50f5307aac
15 changed files with 235 additions and 129 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 2.7.2
VERSION: 2.8.0
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>2.7.2</Version>
<Version>2.8.0</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+1 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.7.2`.
**Текущая версия:** `v2.8.0`.
---
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0
restart: always
depends_on:
db:
@@ -84,7 +84,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2
image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0
restart: always
depends_on:
db:
@@ -185,7 +185,7 @@
private Guid selectedGroupId;
private Guid? deletingTemplateId;
private bool isCreatingTemplate;
private long telegramId;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CampaignTemplateEditModel templateModel = new();
@@ -195,13 +195,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
groups = await SessionService.GetGroupsForCurrentUserAsync();
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
if (selectedGroupId != Guid.Empty)
@@ -228,7 +228,7 @@
campaignTemplates = null;
campaignTemplateModels = [];
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
var templates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(selectedGroupId);
if (templates is null)
{
Navigation.NavigateTo("/access-denied");
@@ -260,9 +260,8 @@
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
await SessionService.CreateCampaignTemplateForCurrentUserAsync(
selectedGroupId,
telegramId,
new CreateCampaignTemplateRequest(
templateModel.Name,
templateModel.Title,
@@ -298,7 +297,7 @@
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
await SessionService.DeleteCampaignTemplateForCurrentUserAsync(template.Id);
successMessage = "Шаблон кампании удалён.";
await LoadTemplates();
}
@@ -87,13 +87,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
@@ -114,7 +114,7 @@
try
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
@@ -122,7 +122,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
@@ -40,8 +40,8 @@
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
@@ -403,9 +403,9 @@
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private long? removingCoGmId;
private string? removingCoGmId;
private bool isAddingCoGm;
private long telegramId;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
@@ -417,7 +417,7 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
@@ -428,21 +428,21 @@
private async Task LoadSessions()
{
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
@@ -470,8 +470,8 @@
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
"Telegram",
coGmModel.TelegramId.Value.ToString(),
coGmModel.DisplayName,
coGmModel.TelegramUsername);
@@ -493,15 +493,15 @@
}
}
private async Task RemoveCoGm(long coGmTelegramId)
private async Task RemoveCoGm(string coGmExternalUserId)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
removingCoGmId = coGmExternalUserId;
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
@@ -527,7 +527,7 @@
try
{
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -559,7 +559,7 @@
loadingParticipantsSessionId = sessionId;
try
{
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId);
participantsCache[sessionId] = participants ?? [];
}
catch (Exception ex)
@@ -582,7 +582,7 @@
try
{
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId);
participantsCache.Remove(sessionId);
successMessage = "Игрок исключён.";
await LoadSessions();
@@ -658,10 +658,9 @@
try
{
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForGmAsync(
await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForCurrentUserAsync(
batch.BatchId,
telegramId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены.";
await LoadSessions();
@@ -696,7 +695,7 @@
try
{
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays);
await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays);
successMessage = "Расписание пачки обновлено.";
await LoadSessions();
}
@@ -726,7 +725,7 @@
? BatchCloneInterval.NextMonth
: BatchCloneInterval.NextWeek;
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval);
var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval);
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
await LoadSessions();
}
@@ -759,7 +758,7 @@
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
@@ -836,7 +835,7 @@
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
@@ -5,7 +5,7 @@
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -85,9 +85,9 @@
<td>
<div class="player-info">
<span class="player-name">@s.DisplayName</span>
@if (!string.IsNullOrEmpty(s.TelegramUsername))
@if (!string.IsNullOrEmpty(s.ExternalUsername))
{
<span class="player-username">@@@s.TelegramUsername</span>
<span class="player-username">@@@s.ExternalUsername</span>
}
</div>
</td>
@@ -171,21 +171,20 @@
Navigation.NavigateTo("/login");
return;
}
var telegramIdClaim = user.FindFirst("telegram_id")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!long.TryParse(telegramIdClaim, out var telegramId))
if (!user.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/login");
return;
}
try
{
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId))
var groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new();
stats = await SessionService.GetGroupAttendanceStatsForCurrentUserAsync(GroupId) ?? new();
UpdateSortedStats();
}
catch (Exception ex)
+3 -3
View File
@@ -43,7 +43,7 @@
<div class="glass-card group-card">
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p>
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole)
</span>
@@ -93,13 +93,13 @@
var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры";
if (!user.TryGetTelegramId(out var telegramId))
if (!user.TryGetPlatformIdentity(out _, out _))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
groups = await SessionService.GetGroupsForCurrentUserAsync();
}
private static string FormatRole(string role) =>
@@ -56,7 +56,7 @@
{
<tr>
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
<td>@entry.ActorName (@entry.ActorTelegramId)</td>
<td>@entry.ActorName (@entry.ActorExternalUserId)</td>
<td>
<span class="status-badge @(GetBadgeClass(entry.ChangeType))">
@GetChangeTypeLabel(entry.ChangeType)
@@ -82,13 +82,13 @@
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
@@ -97,7 +97,7 @@
sessionTitle = session.Title;
groupId = session.GroupId;
entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId);
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
}
private string GetChangeTypeLabel(string changeType) => changeType switch
+3 -3
View File
@@ -2,9 +2,9 @@ namespace GmRelay.Web;
public sealed class DiscordOAuthOptions
{
public required string ClientId { get; set; }
public required string ClientSecret { get; set; }
public required string RedirectUri { get; set; }
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string RedirectUri { get; set; } = string.Empty;
public string[] Scopes { get; set; } = ["identify", "guilds"];
public void Validate()
+1
View File
@@ -1,3 +1,4 @@
using GmRelay.Web;
using GmRelay.Web.Components;
using GmRelay.Web.Health;
using GmRelay.Web.Services;
+15 -2
View File
@@ -13,7 +13,16 @@ public sealed record WebGameGroup(
string? ExternalGroupId,
string Name,
string? Platform,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
string ManagerRole = GroupManagerRoleExtensions.OwnerValue)
{
public long GmTelegramId { get; init; }
public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId)
: this(id, telegramChatId, null, name, null)
{
GmTelegramId = gmTelegramId;
}
}
public sealed record WebGroupManager(
long TelegramId,
@@ -22,7 +31,11 @@ public sealed record WebGroupManager(
string? TelegramUsername,
string? ExternalUsername,
string Role,
DateTime AddedAt);
DateTime AddedAt)
{
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
: this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { }
}
public sealed record WebGroupManagement(
WebGameGroup Group,
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.7.2", compose);
Assert.Contains("gmrelay-discord-bot:2.8.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
{
var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.7.2</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.7.2", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("<Version>2.8.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains(
"v2.7.2",
"v2.8.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
}
@@ -1,10 +1,29 @@
using GmRelay.Web.Services;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedSessionServiceTests
{
private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, externalUserId),
new Claim("TelegramId", externalUserId),
new Claim("Platform", "Telegram")
};
if (name is not null)
claims.Add(new Claim(ClaimTypes.Name, name));
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
return new HttpContextAccessor { HttpContext = httpContext };
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
{
@@ -19,9 +38,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.NotNull(sessions);
Assert.Single(sessions);
@@ -47,9 +67,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.NotNull(sessions);
Assert.Single(sessions);
@@ -65,9 +86,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.Null(sessions);
}
@@ -87,9 +109,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var session = await service.GetSessionForGmAsync(sessionId, gmId);
var session = await service.GetSessionForCurrentUserAsync(sessionId);
Assert.NotNull(session);
Assert.Equal(sessionId, session.Id);
@@ -109,9 +132,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var session = await service.GetSessionForGmAsync(sessionId, 1001L);
var session = await service.GetSessionForCurrentUserAsync(sessionId);
Assert.Null(session);
}
@@ -130,9 +154,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
var action = () => service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateCalled);
@@ -154,9 +179,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", scheduledAt, "https://example.test/b", 5);
Assert.True(store.UpdateCalled);
Assert.Equal(groupId, store.LastUpdatedGroupId);
@@ -183,9 +209,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", originalTime, "https://example.test/a", 4);
Assert.Single(store.LogEntries);
Assert.Equal("Title", store.LogEntries[0].ChangeType);
@@ -209,10 +236,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var newTime = originalTime.AddDays(1);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", newTime, "https://example.test/b", 5);
Assert.Equal(4, store.LogEntries.Count);
Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
@@ -237,9 +265,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Session A", originalTime, "https://example.test/a", 4);
Assert.Empty(store.LogEntries);
}
@@ -259,9 +288,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId);
var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
Assert.NotNull(history);
Assert.Empty(history);
@@ -281,9 +311,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L);
var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
Assert.Null(history);
}
@@ -303,9 +334,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId);
await service.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
Assert.True(store.PromoteCalled);
Assert.Equal(groupId, store.LastPromotedGroupId);
@@ -326,9 +358,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b");
var action = () => service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchDetailsCalled);
@@ -349,9 +382,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b");
await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
@@ -380,9 +414,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");
await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
@@ -400,9 +435,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", ownerId)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(ownerId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");
await service.AddCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString(), "Assistant GM", "assistant");
Assert.True(store.AddCoGmCalled);
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
@@ -427,9 +463,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);
var action = () => service.AddCoGmForOwnerAsync(groupId, "Telegram", newCoGmId.ToString(), "Second Assistant", null);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.AddCoGmCalled);
@@ -450,9 +487,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(ownerId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);
await service.RemoveCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString());
Assert.True(store.RemoveCoGmCalled);
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
@@ -473,9 +511,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);
var action = () => service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchNotificationModeCalled);
@@ -496,9 +535,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);
await service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
Assert.True(store.UpdateBatchNotificationModeCalled);
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
@@ -521,9 +561,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
var action = () => service.RescheduleBatchForCurrentUserAsync(batchId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
Assert.False(store.RescheduleBatchCalled);
@@ -545,9 +586,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14);
await service.RescheduleBatchForCurrentUserAsync(batchId, firstScheduledAt, intervalDays: 14);
Assert.True(store.RescheduleBatchCalled);
Assert.Equal(batchId, store.LastRescheduledBatchId);
@@ -571,9 +613,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek);
await service.CloneBatchForCurrentUserAsync(batchId, BatchCloneInterval.NextWeek);
Assert.True(store.CloneBatchCalled);
Assert.Equal(batchId, store.LastClonedBatchId);
@@ -591,11 +634,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateCampaignTemplateForGmAsync(
await service.CreateCampaignTemplateForCurrentUserAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
" Weekly arc ",
" Kingmaker ",
@@ -626,11 +669,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateCampaignTemplateForGmAsync(
await service.CreateCampaignTemplateForCurrentUserAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
"Open table",
"West Marches",
@@ -653,11 +696,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.CreateCampaignTemplateForGmAsync(
var action = () => service.CreateCampaignTemplateForCurrentUserAsync(
groupId,
1001L,
new CreateCampaignTemplateRequest(
"Weekly arc",
"Kingmaker",
@@ -687,9 +730,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);
await service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, firstScheduledAt);
Assert.True(store.CreateBatchFromTemplateCalled);
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
@@ -711,9 +755,10 @@ public sealed class AuthorizedSessionServiceTests
[
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 accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));
var action = () => service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, DateTime.UtcNow.AddDays(3));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateBatchFromTemplateCalled);
@@ -1019,7 +1064,7 @@ public sealed class AuthorizedSessionServiceTests
public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId.ToString(), actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry);
LastLogSessionId = sessionId;
LastLogActorTelegramId = actorTelegramId;
@@ -1033,12 +1078,62 @@ public sealed class AuthorizedSessionServiceTests
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) =>
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, externalUserId)).ToList());
public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) =>
Task.FromResult(IsManager(groupId, externalUserId));
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) =>
Task.FromResult(IsOwner(groupId, externalUserId));
public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername)
{
AddCoGmCalled = true;
LastAddedCoGmGroupId = groupId;
LastAddedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
LastAddedCoGmDisplayName = displayName;
LastAddedCoGmUsername = externalUsername;
return Task.CompletedTask;
}
public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{
RemoveCoGmCalled = true;
LastRemovedCoGmGroupId = groupId;
LastRemovedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
return Task.CompletedTask;
}
public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
{
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorExternalUserId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry);
LastLogSessionId = sessionId;
LastLogActorTelegramId = long.TryParse(actorExternalUserId, out var id) ? (long?)id : null;
LastLogActorName = actorName;
LastLogChangeType = changeType;
LastLogOldValue = oldValue;
LastLogNewValue = newValue;
return Task.CompletedTask;
}
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
private bool IsOwner(Guid groupId, long telegramId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
private bool IsManager(Guid groupId, string externalUserId) =>
IsOwner(groupId, externalUserId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId.ToString() == externalUserId);
private bool IsOwner(Guid groupId, string externalUserId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId.ToString() == externalUserId;
}
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);