Merge pull request #82: refactor: make session join leave platform-neutral
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 2.1.0
|
||||
VERSION: 2.1.1
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>2.1.0</Version>
|
||||
<Version>2.1.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
# Platform-Neutral Join Leave Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement Gitea issue #25 by making join/leave session interactions use platform-neutral command models while preserving Telegram callback behavior, seat limits, and waitlist semantics.
|
||||
|
||||
**Architecture:** Telegram callback routing remains in `UpdateRouter`, but it becomes an adapter that converts callback data into `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef` values. `JoinSessionHandler` and `LeaveSessionHandler` operate on those neutral values, persist players by `(platform, external_user_id)`, and update schedules through `IPlatformMessenger`.
|
||||
|
||||
**Tech Stack:** .NET 10, xUnit, Dapper, Npgsql, Gitea Actions.
|
||||
|
||||
---
|
||||
|
||||
## Issue Context
|
||||
|
||||
- Issue: `#25 refactor: obobshchit JoinSession i LeaveSession pod platform-neutral interactions`
|
||||
- Labels: `area:bot`, `area:platform`, `area:shared`, `platform:multi`, `type:refactor`
|
||||
- Version bump: patch, `2.1.0` -> `2.1.1`. The issue is labeled refactor, not breaking; do not use a major bump without explicit approval.
|
||||
- Existing untracked file: `CLAUDE.md`; do not stage or modify it.
|
||||
|
||||
## File Map
|
||||
|
||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
|
||||
- Reflection tests proving join/leave command records expose neutral properties and no Telegram-specific identity/message fields.
|
||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
|
||||
- Source-level regression tests for handler SQL and messenger boundaries.
|
||||
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
|
||||
- Add a migration test for nullable legacy `players.telegram_id`, required for non-Telegram player inserts.
|
||||
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
|
||||
- Drop `NOT NULL` from legacy Telegram-only player columns.
|
||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
||||
- Change `JoinSessionCommand` to neutral properties and query/upsert players by platform identity.
|
||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
||||
- Change `LeaveSessionCommand` to neutral properties and find participants by platform identity.
|
||||
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
|
||||
- Convert Telegram callback data into neutral command values using `TelegramPlatformIds`.
|
||||
- Modify: version files after implementation:
|
||||
- `Directory.Build.props`
|
||||
- `compose.yaml`
|
||||
- `.gitea/workflows/deploy.yml`
|
||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
## Task 1: RED - Command Model Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing command-shape tests**
|
||||
|
||||
```csharp
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed class PlatformNeutralSessionInteractionCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext()
|
||||
{
|
||||
AssertProperty<JoinSessionCommand>("SessionId", typeof(Guid));
|
||||
AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
|
||||
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
||||
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
||||
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
|
||||
{
|
||||
AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
|
||||
AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
|
||||
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
||||
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
||||
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
||||
}
|
||||
|
||||
private static void AssertProperty<T>(string name, Type expectedType)
|
||||
{
|
||||
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
|
||||
|
||||
Assert.Equal(expectedType, property.PropertyType);
|
||||
}
|
||||
|
||||
private static void AssertNoTelegramSpecificProperties<T>()
|
||||
{
|
||||
var names = typeof(T).GetProperties().Select(property => property.Name).ToArray();
|
||||
|
||||
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("ChatId", names);
|
||||
Assert.DoesNotContain("MessageId", names);
|
||||
Assert.DoesNotContain("TelegramUserId", names);
|
||||
Assert.DoesNotContain("TelegramUsername", names);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
|
||||
```
|
||||
|
||||
Expected: FAIL because `JoinSessionCommand` and `LeaveSessionCommand` still expose `TelegramUserId`, `ChatId`, and `MessageId`, and do not expose `User`, `Group`, or `ScheduleMessage`.
|
||||
|
||||
## Task 2: RED - SQL and Boundary Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs`
|
||||
- Modify: `tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing handler source tests**
|
||||
|
||||
```csharp
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed class PlatformNeutralSessionInteractionSqlTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
|
||||
{
|
||||
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
|
||||
|
||||
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
|
||||
{
|
||||
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
|
||||
|
||||
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
|
||||
{
|
||||
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
|
||||
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
|
||||
|
||||
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add failing migration assertion**
|
||||
|
||||
Append to `PlatformIdentityMigrationTests`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql");
|
||||
|
||||
Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionSqlTests|MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId"
|
||||
```
|
||||
|
||||
Expected: FAIL because handlers still use Telegram-specific properties and the V017 migration file does not exist.
|
||||
|
||||
## Task 3: GREEN - Add Migration
|
||||
|
||||
**Files:**
|
||||
- Create: `src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql`
|
||||
|
||||
- [ ] **Step 1: Create the migration**
|
||||
|
||||
```sql
|
||||
-- =============================================================
|
||||
-- V017: Allow platform-neutral players
|
||||
-- =============================================================
|
||||
-- Legacy Telegram identity columns remain for backward compatibility,
|
||||
-- but non-Telegram platform users do not have Telegram ids.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE players
|
||||
ALTER COLUMN telegram_id DROP NOT NULL;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify migration test turns green**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: GREEN - Refactor JoinSessionCommand and Handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs`
|
||||
|
||||
- [ ] **Step 1: Replace command record**
|
||||
|
||||
Replace the existing `JoinSessionCommand` declaration with:
|
||||
|
||||
```csharp
|
||||
public sealed record JoinSessionCommand(
|
||||
Guid SessionId,
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace player upsert**
|
||||
|
||||
Use platform identity parameters:
|
||||
|
||||
```csharp
|
||||
var platform = command.User.Platform.ToString();
|
||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
||||
: (long?)null;
|
||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
||||
? command.User.ExternalUsername
|
||||
: null;
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
||||
platform = EXCLUDED.platform,
|
||||
external_user_id = EXCLUDED.external_user_id,
|
||||
external_username = EXCLUDED.external_username
|
||||
RETURNING id;",
|
||||
new
|
||||
{
|
||||
LegacyTelegramId = legacyTelegramId,
|
||||
Name = command.User.DisplayName,
|
||||
LegacyTelegramUsername = legacyTelegramUsername,
|
||||
Platform = platform,
|
||||
command.User.ExternalUserId,
|
||||
command.User.ExternalUsername
|
||||
},
|
||||
transaction);
|
||||
```
|
||||
|
||||
Add `using System.Globalization;` at the top.
|
||||
|
||||
- [ ] **Step 3: Update participant display query**
|
||||
|
||||
Change the participant projection to prefer platform-neutral username:
|
||||
|
||||
```sql
|
||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update schedule message and interaction reply usage**
|
||||
|
||||
Use:
|
||||
|
||||
```csharp
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
```
|
||||
|
||||
and:
|
||||
|
||||
```csharp
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
```
|
||||
|
||||
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
|
||||
|
||||
- [ ] **Step 5: Verify command and SQL tests for join**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext|JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity"
|
||||
```
|
||||
|
||||
Expected: PASS for join-focused tests.
|
||||
|
||||
## Task 5: GREEN - Refactor LeaveSessionCommand and Handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs`
|
||||
|
||||
- [ ] **Step 1: Replace command record**
|
||||
|
||||
Replace the existing `LeaveSessionCommand` declaration with:
|
||||
|
||||
```csharp
|
||||
public sealed record LeaveSessionCommand(
|
||||
Guid SessionId,
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace participant lookup**
|
||||
|
||||
Use platform identity instead of Telegram id:
|
||||
|
||||
```csharp
|
||||
var platform = command.User.Platform.ToString();
|
||||
|
||||
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
|
||||
"""
|
||||
SELECT sp.id AS ParticipantRowId,
|
||||
p.display_name AS DisplayName,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
FOR UPDATE OF sp
|
||||
""",
|
||||
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
|
||||
transaction);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update participant display query**
|
||||
|
||||
Change the participant projection to:
|
||||
|
||||
```sql
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update schedule message and interaction reply usage**
|
||||
|
||||
Use:
|
||||
|
||||
```csharp
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
command.Group,
|
||||
view,
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
```
|
||||
|
||||
Replace all `command.CallbackQueryId` calls with `command.InteractionId`.
|
||||
|
||||
- [ ] **Step 5: Verify leave tests**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext|LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity|SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference"
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 6: GREEN - Convert Telegram Router to Neutral Commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs`
|
||||
|
||||
- [ ] **Step 1: Add local conversion values in `HandleCallbackQueryAsync`**
|
||||
|
||||
After parsing `action`, add:
|
||||
|
||||
```csharp
|
||||
var user = TelegramPlatformIds.User(
|
||||
query.From.Id,
|
||||
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||||
query.From.Username);
|
||||
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
|
||||
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update join command construction**
|
||||
|
||||
```csharp
|
||||
var command = new JoinSessionCommand(
|
||||
SessionId: joinSessionId,
|
||||
User: user,
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ScheduleMessage: scheduleMessage);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update leave command construction**
|
||||
|
||||
```csharp
|
||||
var command = new LeaveSessionCommand(
|
||||
SessionId: leaveSessionId,
|
||||
User: user,
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ScheduleMessage: scheduleMessage);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify compile**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter PlatformNeutralSessionInteractionCommandTests
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 7: REFACTOR - Clean Up and Full Test Pass
|
||||
|
||||
**Files:**
|
||||
- Modify only files already listed if cleanup is needed.
|
||||
|
||||
- [ ] **Step 1: Remove now-unused Telegram handler imports**
|
||||
|
||||
Check `JoinSessionHandler.cs` and `LeaveSessionHandler.cs` for unused:
|
||||
|
||||
```csharp
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
```
|
||||
|
||||
Remove it from handlers if no longer needed.
|
||||
|
||||
- [ ] **Step 2: Run focused tests**
|
||||
|
||||
```powershell
|
||||
dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
```powershell
|
||||
dotnet test .\GM-Relay.slnx
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Build solution**
|
||||
|
||||
```powershell
|
||||
dotnet build .\GM-Relay.slnx
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 8: Version Bump
|
||||
|
||||
**Files:**
|
||||
- Modify: `Directory.Build.props`
|
||||
- Modify: `compose.yaml`
|
||||
- Modify: `.gitea/workflows/deploy.yml`
|
||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
- [ ] **Step 1: Update version from `2.1.0` to `2.1.1`**
|
||||
|
||||
Expected exact replacements:
|
||||
|
||||
```xml
|
||||
<Version>2.1.1</Version>
|
||||
```
|
||||
|
||||
```yaml
|
||||
VERSION: 2.1.1
|
||||
```
|
||||
|
||||
```yaml
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
|
||||
```
|
||||
|
||||
```razor
|
||||
<div class="nav-version">v2.1.1</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify synchronized versions**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
rg "<Version>|image: git.codeanddice.ru/toutsu/gmrelay-|VERSION:|nav-version" Directory.Build.props compose.yaml .gitea\workflows\deploy.yml src\GmRelay.Web\Components\Layout\NavMenu.razor
|
||||
```
|
||||
|
||||
Expected: all project image/app/deploy UI versions show `2.1.1`.
|
||||
|
||||
## Task 9: PR, CI, Review, Merge, Deploy, Release
|
||||
|
||||
**Files:**
|
||||
- No additional source changes expected.
|
||||
|
||||
- [ ] **Step 1: Create branch after approval**
|
||||
|
||||
```powershell
|
||||
git checkout -b refactor/issue-25-platform-neutral-join-leave
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Stage only intended files**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/plans/2026-05-18-platform-neutral-join-leave.md tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionCommandTests.cs tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/PlatformNeutralSessionInteractionSqlTests.cs tests/GmRelay.Bot.Tests/Infrastructure/Database/PlatformIdentityMigrationTests.cs src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs src/GmRelay.Bot/Infrastructure/Telegram/UpdateRouter.cs Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```powershell
|
||||
git commit -m "refactor: make session join leave platform-neutral"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Push and create Gitea PR**
|
||||
|
||||
```powershell
|
||||
git push -u origin refactor/issue-25-platform-neutral-join-leave
|
||||
```
|
||||
|
||||
PR title:
|
||||
|
||||
```text
|
||||
refactor: make session join leave platform-neutral
|
||||
```
|
||||
|
||||
PR body:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
- Closes #25.
|
||||
- Converts join/leave session interaction commands from Telegram-specific fields to platform-neutral `PlatformUser`, `PlatformGroup`, and `PlatformMessageRef`.
|
||||
- Persists and looks up session participants by `(platform, external_user_id)`.
|
||||
- Keeps Telegram callback data and schedule update behavior intact.
|
||||
|
||||
## Test plan
|
||||
- `dotnet test .\tests\GmRelay.Bot.Tests\GmRelay.Bot.Tests.csproj --filter "PlatformNeutralSessionInteractionCommandTests|PlatformNeutralSessionInteractionSqlTests|PlatformIdentityMigrationTests"`
|
||||
- `dotnet test .\GM-Relay.slnx`
|
||||
- `dotnet build .\GM-Relay.slnx`
|
||||
|
||||
## Workflow
|
||||
- [ ] CI passes
|
||||
- [ ] Code review approved
|
||||
- [ ] Deployed
|
||||
- [ ] Release published
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Watch CI, request review, merge, deploy, release**
|
||||
|
||||
Use Gitea MCP for PR creation, CI polling, review, merge, deploy monitoring, and release `v2.1.1`. Close issue #25 after release and add a comment linking the PR and release.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: issue scope is covered by neutral command records, Telegram adapter conversion, platform identity SQL, messenger-based schedule updates, and tests.
|
||||
- Placeholder scan: no `TBD`, `TODO`, or "fill later" steps remain.
|
||||
- Type consistency: commands consistently use `PlatformUser User`, `string InteractionId`, `PlatformGroup Group`, and `PlatformMessageRef ScheduleMessage`.
|
||||
@@ -1,20 +1,18 @@
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record JoinSessionCommand(
|
||||
Guid SessionId,
|
||||
long TelegramUserId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
|
||||
@@ -33,17 +31,35 @@ public sealed class JoinSessionHandler(
|
||||
try
|
||||
{
|
||||
// 1. Убеждаемся, что игрок есть в базе
|
||||
var platform = command.User.Platform.ToString();
|
||||
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
||||
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
||||
: (long?)null;
|
||||
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
||||
? command.User.ExternalUsername
|
||||
: null;
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
||||
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username,
|
||||
platform = COALESCE(players.platform, 'Telegram'),
|
||||
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
|
||||
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username)
|
||||
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
||||
platform = EXCLUDED.platform,
|
||||
external_user_id = EXCLUDED.external_user_id,
|
||||
external_username = EXCLUDED.external_username
|
||||
RETURNING id;",
|
||||
new { TgId = command.TelegramUserId, Name = command.DisplayName, Username = command.TelegramUsername },
|
||||
new
|
||||
{
|
||||
LegacyTelegramId = legacyTelegramId,
|
||||
Name = command.User.DisplayName,
|
||||
LegacyTelegramUsername = legacyTelegramUsername,
|
||||
Platform = platform,
|
||||
command.User.ExternalUserId,
|
||||
command.User.ExternalUsername
|
||||
},
|
||||
transaction);
|
||||
|
||||
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
|
||||
@@ -58,7 +74,7 @@ public sealed class JoinSessionHandler(
|
||||
if (batchInfo is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +95,7 @@ public sealed class JoinSessionHandler(
|
||||
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Вы уже в листе ожидания!"
|
||||
: "Вы уже записаны!";
|
||||
await AnswerAsync(command.CallbackQueryId, alreadyText, ct);
|
||||
await AnswerAsync(command.InteractionId, alreadyText, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,7 +129,7 @@ public sealed class JoinSessionHandler(
|
||||
if (inserted == 0)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Вы уже записаны!", ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +144,7 @@ public sealed class JoinSessionHandler(
|
||||
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id as SessionId,
|
||||
p.display_name as DisplayName,
|
||||
p.telegram_username as TelegramUsername,
|
||||
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
|
||||
sp.registration_status as RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -144,15 +160,15 @@ public sealed class JoinSessionHandler(
|
||||
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
TelegramPlatformIds.Group(command.ChatId),
|
||||
command.Group,
|
||||
view,
|
||||
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
|
||||
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
||||
: "Вы успешно записаны!";
|
||||
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -165,10 +181,10 @@ public sealed class JoinSessionHandler(
|
||||
var errorText = transactionCommitted
|
||||
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при регистрации.";
|
||||
await AnswerAsync(command.CallbackQueryId, errorText, ct);
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,15 @@ using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record LeaveSessionCommand(
|
||||
Guid SessionId,
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
PlatformUser User,
|
||||
string InteractionId,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
|
||||
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||||
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||||
@@ -47,17 +46,19 @@ public sealed class LeaveSessionHandler(
|
||||
if (session is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Сессия уже отменена.", ct);
|
||||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var platform = command.User.Platform.ToString();
|
||||
|
||||
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
|
||||
"""
|
||||
SELECT sp.id AS ParticipantRowId,
|
||||
@@ -66,17 +67,18 @@ public sealed class LeaveSessionHandler(
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
FOR UPDATE OF sp
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId },
|
||||
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
|
||||
transaction);
|
||||
|
||||
if (participant is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Вы не записаны на эту сессию.", ct);
|
||||
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,7 +172,7 @@ public sealed class LeaveSessionHandler(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -187,9 +189,9 @@ public sealed class LeaveSessionHandler(
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
TelegramPlatformIds.Group(command.ChatId),
|
||||
command.Group,
|
||||
view,
|
||||
TelegramPlatformIds.Message(command.ChatId, threadId: null, command.MessageId)),
|
||||
command.ScheduleMessage),
|
||||
ct);
|
||||
|
||||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||||
@@ -198,7 +200,7 @@ public sealed class LeaveSessionHandler(
|
||||
? "Вы отписались от сессии."
|
||||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||||
|
||||
await AnswerAsync(command.CallbackQueryId, callbackText, ct);
|
||||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -211,10 +213,10 @@ public sealed class LeaveSessionHandler(
|
||||
var errorText = transactionCommitted
|
||||
? "Запись снята, но не удалось обновить сообщение расписания."
|
||||
: "Произошла ошибка при отмене записи.";
|
||||
await AnswerAsync(command.CallbackQueryId, errorText, ct);
|
||||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text), ct);
|
||||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||||
}
|
||||
|
||||
@@ -69,17 +69,21 @@ public sealed class UpdateRouter(
|
||||
|
||||
var parts = data.Split(':', 3);
|
||||
var action = parts[0];
|
||||
var user = TelegramPlatformIds.User(
|
||||
query.From.Id,
|
||||
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||||
query.From.Username);
|
||||
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
|
||||
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
|
||||
|
||||
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
|
||||
{
|
||||
var command = new JoinSessionCommand(
|
||||
SessionId: joinSessionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||||
TelegramUsername: query.From.Username,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageId: message.MessageId);
|
||||
User: user,
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ScheduleMessage: scheduleMessage);
|
||||
|
||||
await joinSessionHandler.HandleAsync(command, ct);
|
||||
return;
|
||||
@@ -89,10 +93,10 @@ public sealed class UpdateRouter(
|
||||
{
|
||||
var command = new LeaveSessionCommand(
|
||||
SessionId: leaveSessionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageId: message.MessageId);
|
||||
User: user,
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ScheduleMessage: scheduleMessage);
|
||||
|
||||
await leaveSessionHandler.HandleAsync(command, ct);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- =============================================================
|
||||
-- V017: Allow platform-neutral players
|
||||
-- =============================================================
|
||||
-- Legacy Telegram identity columns remain for backward compatibility,
|
||||
-- but non-Telegram platform users do not have Telegram ids.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE players
|
||||
ALTER COLUMN telegram_id DROP NOT NULL;
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v2.1.0</div>
|
||||
<div class="nav-version">v2.1.1</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed class PlatformNeutralSessionInteractionCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext()
|
||||
{
|
||||
AssertProperty<JoinSessionCommand>("SessionId", typeof(Guid));
|
||||
AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
|
||||
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
|
||||
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
|
||||
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
|
||||
{
|
||||
AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
|
||||
AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
|
||||
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
|
||||
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
|
||||
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
|
||||
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
|
||||
}
|
||||
|
||||
private static void AssertProperty<T>(string name, Type expectedType)
|
||||
{
|
||||
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
|
||||
|
||||
Assert.Equal(expectedType, property.PropertyType);
|
||||
}
|
||||
|
||||
private static void AssertNoTelegramSpecificProperties<T>()
|
||||
{
|
||||
var names = typeof(T).GetProperties().Select(property => property.Name).ToArray();
|
||||
|
||||
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("ChatId", names);
|
||||
Assert.DoesNotContain("MessageId", names);
|
||||
Assert.DoesNotContain("TelegramUserId", names);
|
||||
Assert.DoesNotContain("TelegramUsername", names);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed class PlatformNeutralSessionInteractionSqlTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
|
||||
{
|
||||
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
|
||||
|
||||
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
|
||||
{
|
||||
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
|
||||
|
||||
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
|
||||
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
|
||||
{
|
||||
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
|
||||
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
|
||||
|
||||
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal);
|
||||
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return await File.ReadAllTextAsync(candidate);
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,15 @@ public sealed class PlatformIdentityMigrationTests
|
||||
Assert.Contains("external_username", statsMigration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId()
|
||||
{
|
||||
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql");
|
||||
|
||||
Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal);
|
||||
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
Reference in New Issue
Block a user