namespace GmRelay.Bot.Tests.Web; public sealed class MasterProfilesTests { [Fact] public async Task MigrationV028_ShouldAddMasterProfilesWithoutExternalIdentifiers() { var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql"); Assert.Contains("CREATE TABLE master_profiles", migration, StringComparison.Ordinal); Assert.Contains("player_id", migration, StringComparison.Ordinal); Assert.Contains("public_slug", migration, StringComparison.Ordinal); Assert.Contains("is_public", migration, StringComparison.Ordinal); Assert.Contains("display_name", migration, StringComparison.Ordinal); Assert.Contains("bio", migration, StringComparison.Ordinal); Assert.Contains("ux_master_profiles_public_slug", migration, StringComparison.Ordinal); Assert.DoesNotContain("external_user_id", migration, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("telegram_id", migration, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("discord", migration, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task SessionStore_ShouldExposeSanitizedMasterProfileContracts() { var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs"); Assert.Contains("MasterProfileSettings", sessionStore, StringComparison.Ordinal); Assert.Contains("PublicMasterProfile", sessionStore, StringComparison.Ordinal); Assert.Contains("PublicMasterClub", sessionStore, StringComparison.Ordinal); Assert.Contains("GetMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal); Assert.Contains("UpdateMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal); Assert.Contains("GetPublicMasterProfileBySlugAsync", sessionStore, StringComparison.Ordinal); var publicProfileSection = RecordSection(sessionStore, "PublicMasterProfile"); Assert.DoesNotContain("AvatarUrl", publicProfileSection, StringComparison.Ordinal); Assert.DoesNotContain("ExternalUserId", publicProfileSection, StringComparison.Ordinal); Assert.DoesNotContain("TelegramId", publicProfileSection, StringComparison.Ordinal); Assert.DoesNotContain("DiscordId", publicProfileSection, StringComparison.Ordinal); } [Fact] public async Task PublicMasterProfilePage_ShouldBePublicAndHideTechnicalIdentityData() { var publicProfilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor"); Assert.Contains("@page \"/gm/{Slug}\"", publicProfilePage, StringComparison.Ordinal); Assert.Contains("@layout PublicLayout", publicProfilePage, StringComparison.Ordinal); Assert.Contains("GetPublicMasterProfileBySlugAsync", publicProfilePage, StringComparison.Ordinal); Assert.DoesNotContain("@attribute [Authorize]", publicProfilePage, StringComparison.Ordinal); Assert.DoesNotContain("ExternalUserId", publicProfilePage, StringComparison.Ordinal); Assert.DoesNotContain("TelegramId", publicProfilePage, StringComparison.Ordinal); Assert.DoesNotContain("DiscordId", publicProfilePage, StringComparison.Ordinal); Assert.DoesNotContain("AvatarUrl", publicProfilePage, StringComparison.Ordinal); Assert.DoesNotContain("LinkedIdentity", publicProfilePage, StringComparison.Ordinal); } [Fact] public async Task ProfilePage_ShouldManageMasterProfilePublication() { var profilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Profile.razor"); var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs"); Assert.Contains("GetMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal); Assert.Contains("UpdateMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal); Assert.Contains("masterProfileModel", profilePage, StringComparison.Ordinal); Assert.Contains("PublicMasterProfileUrl", profilePage, StringComparison.Ordinal); Assert.Contains("NormalizeMasterProfileSlug", authorizedService, StringComparison.Ordinal); } [Fact] public async Task TelegramLoginEndpoints_ShouldUpsertPlayersForProfileManagement() { var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs"); Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal); Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal); Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal); Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal); } [Fact] public async Task PublicGamePages_ShouldLinkPublishedMasterProfilesWithoutPrivateIds() { var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor"); var showcasePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Showcase.razor"); var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); Assert.Contains("MasterProfileSlug", publicClubPage, StringComparison.Ordinal); Assert.Contains("MasterProfileSlug", publicSessionPage, StringComparison.Ordinal); Assert.Contains("MasterProfileSlug", showcasePage, StringComparison.Ordinal); Assert.Contains("master_profiles", sessionService, StringComparison.Ordinal); Assert.DoesNotContain("targetExternalUserId", PublicQuerySection(sessionService), StringComparison.Ordinal); Assert.DoesNotContain("target_platform", PublicQuerySection(sessionService), StringComparison.Ordinal); } [Fact] public async Task PublicMasterProfileQueries_ShouldIncludeCoGmManagedPublishedGames() { var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); var clubsQuery = MethodSection(sessionService, "GetPublicClubsForMasterAsync"); var sessionsQuery = MethodSection(sessionService, "GetPublicSessionsForMasterAsync"); Assert.Contains("JOIN group_managers gm", clubsQuery, StringComparison.Ordinal); Assert.Contains("JOIN group_managers gm", sessionsQuery, StringComparison.Ordinal); Assert.DoesNotContain("gm.role = @OwnerRole", clubsQuery, StringComparison.Ordinal); Assert.DoesNotContain("gm.role = @OwnerRole", sessionsQuery, StringComparison.Ordinal); } [Fact] public async Task PublicOwnerProfileLinks_ShouldOrderByExistingGroupManagerTimestamp() { var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V008__add_group_managers.sql"); var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs"); Assert.Contains("created_at", migration, StringComparison.Ordinal); Assert.DoesNotContain("added_at", migration, StringComparison.Ordinal); Assert.DoesNotContain("gm.added_at", PublicQuerySection(sessionService), StringComparison.Ordinal); Assert.Contains("gm.created_at", PublicQuerySection(sessionService), StringComparison.Ordinal); } private static string RecordSection(string source, string recordName) { var start = source.IndexOf($"record {recordName}", StringComparison.Ordinal); if (start < 0) return string.Empty; var end = source.IndexOf(");", start, StringComparison.Ordinal); return end < 0 ? source[start..] : source[start..(end + 2)]; } private static string PublicQuerySection(string source) { var start = source.IndexOf("GetPublicMasterProfileBySlugAsync", StringComparison.Ordinal); if (start < 0) return string.Empty; var end = source.IndexOf("// --- Identity linking", start, StringComparison.Ordinal); return end < 0 ? source[start..] : source[start..end]; } private static string MethodSection(string source, string methodName) { var start = -1; var searchFrom = 0; while (searchFrom < source.Length) { var candidate = source.IndexOf(methodName, searchFrom, StringComparison.Ordinal); if (candidate < 0) return string.Empty; var lineStart = source.LastIndexOf('\n', candidate); var headerStart = lineStart < 0 ? 0 : lineStart + 1; var header = source[headerStart..candidate]; if (header.Contains("private static async Task", StringComparison.Ordinal)) { start = candidate; break; } searchFrom = candidate + methodName.Length; } var nextMethod = source.IndexOf("\n private static async Task", start + methodName.Length, StringComparison.Ordinal); if (nextMethod < 0) { nextMethod = source.IndexOf("\n public async Task", start + methodName.Length, StringComparison.Ordinal); } return nextMethod < 0 ? source[start..] : source[start..nextMethod]; } private static string EndpointSection(string source, string route) { var start = source.IndexOf($"\"/{route}\"", StringComparison.Ordinal); if (start < 0) return string.Empty; var nextEndpoint = source.IndexOf("app.Map", start + route.Length, StringComparison.Ordinal); return nextEndpoint < 0 ? source[start..] : source[start..nextEndpoint]; } private static async Task 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}'."); } }