using GmRelay.DiscordBot.Features.Sessions; namespace GmRelay.Bot.Tests.Discord; public sealed class DiscordNewSessionHandlerTests { private static string GetRepoRoot() { var dir = AppContext.BaseDirectory; while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props"))) { dir = Directory.GetParent(dir)?.FullName; } return dir ?? throw new InvalidOperationException("Could not find repo root"); } // --- Runtime tests for ParseTimeInput (static, no DB) --- [Fact] public void ParseTimeInput_ShouldTreatInputAsMoscowTime() { var result = DiscordNewSessionHandler.ParseTimeInput("2026-06-01 15:00"); Assert.True(result.IsSuccess); // 15:00 MSK = 12:00 UTC Assert.Equal(12, result.Value.Hour); Assert.Equal(0, result.Value.Minute); Assert.Equal(TimeSpan.Zero, result.Value.Offset); } [Fact] public void ParseTimeInput_ShouldParseDiscordDateFormat() { var expected = FutureDateAt1930(); var result = DiscordNewSessionHandler.ParseTimeInput( expected.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)); Assert.True(result.IsSuccess); Assert.Equal(expected.Year, result.Value.Year); Assert.Equal(expected.Month, result.Value.Month); Assert.Equal(expected.Day, result.Value.Day); // Input is treated as Moscow time; 19:30 MSK = 16:30 UTC Assert.Equal(16, result.Value.Hour); Assert.Equal(30, result.Value.Minute); } [Fact] public void ParseTimeInput_ShouldRejectPastDate() { var result = DiscordNewSessionHandler.ParseTimeInput("2020-01-01 00:00"); Assert.False(result.IsSuccess); } [Fact] public void ParseTimeInput_ShouldParseRussianDateFormat() { var expected = FutureDateAt1930(); var result = DiscordNewSessionHandler.ParseTimeInput( expected.ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture)); Assert.True(result.IsSuccess); Assert.Equal(expected.Year, result.Value.Year); Assert.Equal(expected.Month, result.Value.Month); Assert.Equal(expected.Day, result.Value.Day); } [Fact] public void ParseTimeInput_ShouldRejectInvalidFormat() { var result = DiscordNewSessionHandler.ParseTimeInput("not-a-date"); Assert.False(result.IsSuccess); Assert.NotNull(result.Error); } // --- Source-level structural tests --- [Fact] public void Handler_ShouldExist() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); Assert.True(File.Exists(handlerPath), "DiscordNewSessionHandler should exist."); } [Fact] public void Handler_ShouldUseDapperForDatabaseAccess() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.Contains("QueryAsync", source, StringComparison.Ordinal); Assert.Contains("ExecuteAsync", source, StringComparison.Ordinal); Assert.Contains("ExecuteScalarAsync", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldUseNpgsqlDataSource() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.Contains("NpgsqlDataSource", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldCheckPermissionsViaPermissionChecker() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.Contains("CanManageSchedule", source, StringComparison.Ordinal); Assert.Contains("UnauthorizedAccessException", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldBePlatformNeutral() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.DoesNotContain("telegram_chat_id", source, StringComparison.Ordinal); Assert.DoesNotContain("telegram_id", source, StringComparison.Ordinal); Assert.Contains("platform = 'Discord'", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldUseTransactions() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.Contains("BeginTransactionAsync", source, StringComparison.Ordinal); Assert.Contains("CommitAsync", source, StringComparison.Ordinal); Assert.Contains("RollbackAsync", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldNotRollbackCommittedTransactionAfterPostCommitFailure() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.Contains("transactionCommitted = false", source, StringComparison.Ordinal); Assert.Contains("transactionCommitted = true", source, StringComparison.Ordinal); Assert.Contains("if (!transactionCommitted)", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldRespectCancellationToken() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.Contains("CancellationToken", source, StringComparison.Ordinal); } [Fact] public void Command_ShouldRenderEmbedOnSuccess() { var repoRoot = GetRepoRoot(); var commandPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionCommand.cs"); var source = File.ReadAllText(commandPath); Assert.Contains("DiscordSessionBatchRenderer.Render", source, StringComparison.Ordinal); Assert.Contains("message.Embeds = embeds", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldLeaveScheduleMessageCreationToInteractionResponse() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.DoesNotContain("SendScheduleAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("PlatformScheduleMessage", source, StringComparison.Ordinal); } [Fact] public void Handler_ShouldStoreReadableDiscordGroupNameForWebCards() { var repoRoot = GetRepoRoot(); var handlerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Features", "Sessions", "DiscordNewSessionHandler.cs"); var source = File.ReadAllText(handlerPath); Assert.Contains("groupName", source, StringComparison.Ordinal); Assert.Contains("displayGroupName", source, StringComparison.Ordinal); Assert.Contains("VALUES (@GroupName, 'Discord'", source, StringComparison.Ordinal); } private static DateTimeOffset FutureDateAt1930() { var future = DateTimeOffset.UtcNow.AddDays(7); return new DateTimeOffset( future.Year, future.Month, future.Day, 19, 30, 0, TimeSpan.Zero); } }