feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s
PR Checks / test-and-build (pull_request) Successful in 12m32s
Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.
Bump version -> 3.5.0
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
@@ -61,8 +62,9 @@ public sealed class DiscordProjectStructureTests
|
||||
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
var version = GetProjectVersion(repoRoot);
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:3.4.0", compose);
|
||||
Assert.Contains($"gmrelay-discord-bot:{version}", 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,14 +77,15 @@ public sealed class DiscordProjectStructureTests
|
||||
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var version = GetProjectVersion(repoRoot);
|
||||
|
||||
Assert.Contains("<Version>3.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 3.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"<Version>{version}</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains($"VERSION: {version}", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains($"gmrelay-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"gmrelay-web:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains($"gmrelay-discord-bot:{version}", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v3.4.0",
|
||||
$"v{version}",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
@@ -121,4 +124,13 @@ public sealed class DiscordProjectStructureTests
|
||||
Assert.Contains("test:", discordBlock);
|
||||
Assert.Contains("localhost:8082/health", discordBlock);
|
||||
}
|
||||
|
||||
private static string GetProjectVersion(string repoRoot)
|
||||
{
|
||||
var props = XDocument.Load(Path.Combine(repoRoot, "Directory.Build.props"));
|
||||
return props.Root?
|
||||
.Element("PropertyGroup")?
|
||||
.Element("Version")?
|
||||
.Value ?? throw new InvalidOperationException("Version not found.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,6 +828,14 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Guid? LastUpdatedPublicGroupId { get; private set; }
|
||||
public string? LastUpdatedPublicSlug { get; private set; }
|
||||
public bool? LastUpdatedPublicScheduleEnabled { get; private set; }
|
||||
public MasterProfileSettings? MasterProfileSettings { get; set; } = new(Guid.NewGuid(), "Owner GM", null, false, null);
|
||||
public bool UpdateMasterProfileCalled { get; private set; }
|
||||
public string? LastMasterProfilePlatform { get; private set; }
|
||||
public string? LastMasterProfileExternalUserId { get; private set; }
|
||||
public string? LastMasterProfileSlug { get; private set; }
|
||||
public bool? LastMasterProfileIsPublic { get; private set; }
|
||||
public string? LastMasterProfileDisplayName { get; private set; }
|
||||
public string? LastMasterProfileBio { get; private set; }
|
||||
public Guid? LastPublicSessionId { get; private set; }
|
||||
public Guid? LastPublicSessionGroupId { get; private set; }
|
||||
public bool? LastSessionPublicValue { get; private set; }
|
||||
@@ -1195,6 +1203,25 @@ public sealed class AuthorizedSessionServiceTests
|
||||
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult(MasterProfileSettings);
|
||||
|
||||
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio)
|
||||
{
|
||||
UpdateMasterProfileCalled = true;
|
||||
LastMasterProfilePlatform = platform;
|
||||
LastMasterProfileExternalUserId = externalUserId;
|
||||
LastMasterProfileSlug = publicSlug;
|
||||
LastMasterProfileIsPublic = isPublic;
|
||||
LastMasterProfileDisplayName = displayName;
|
||||
LastMasterProfileBio = bio;
|
||||
MasterProfileSettings = new(Guid.NewGuid(), displayName, publicSlug, isPublic, bio);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) =>
|
||||
Task.FromResult<PublicMasterProfile?>(null);
|
||||
|
||||
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
|
||||
Task.FromResult<Guid?>(Guid.NewGuid());
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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<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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user