feat(web): add completed-game portfolio to GM showcase (issue #108) #118

Merged
Toutsu merged 31 commits from codex/feature-issue-108-portfolio into main 2026-06-02 18:28:49 +03:00
3 changed files with 1206 additions and 0 deletions
Showing only changes of commit f2c9f34ab4 - Show all commits
+2
View File
@@ -2,6 +2,7 @@ using GmRelay.Web;
using GmRelay.Web.Components;
using GmRelay.Web.Health;
using GmRelay.Web.Services;
using GmRelay.Web.Services.Portfolio;
using GmRelay.Web.Services.Portfolio.Covers;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
@@ -45,6 +46,7 @@ builder.Services.AddSingleton<DiscordOAuthStateStore>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class PortfolioServiceSourceTests
{
[Fact]
public async Task PortfolioService_ShouldExposePortfolioTablesAndPublicationGuards()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs");
Assert.Contains("portfolio_games", source, StringComparison.Ordinal);
Assert.Contains("portfolio_game_sessions", source, StringComparison.Ordinal);
Assert.Contains("portfolio_game_masters", source, StringComparison.Ordinal);
Assert.Contains("portfolio_game_reviews", source, StringComparison.Ordinal);
Assert.Contains("moderation_status = 'Approved'", source, StringComparison.Ordinal);
Assert.Contains("publication_consent_at IS NOT NULL", source, StringComparison.Ordinal);
Assert.Contains("s.scheduled_at < now()", source, StringComparison.Ordinal);
Assert.Contains("FOR UPDATE", source, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING", source, StringComparison.Ordinal);
}
[Fact]
public async Task PublicMasterPortfolioQuery_ShouldNotRequirePublicSchedule()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs");
var publicMasterQuery = PublicMasterQuerySection(source);
Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal);
Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPortfolioQuery_ShouldRequirePublicSchedule()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/Portfolio/PortfolioService.cs");
var publicClubQuery = PublicClubQuerySection(source);
Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal);
}
[Fact]
public async Task ShowcaseSessionQuery_ShouldKeepFourHourFutureWindow()
{
var source = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
var showcaseQuery = ShowcaseQuerySection(source);
Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal);
}
private static string PublicMasterQuerySection(string source)
{
var start = source.IndexOf("GetPublicPortfolioGamesForMasterAsync", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var end = source.IndexOf("GetPublicPortfolioGamesForClubAsync", start, StringComparison.Ordinal);
return end < 0 ? source[start..] : source[start..end];
}
private static string PublicClubQuerySection(string source)
{
var start = source.IndexOf("GetPublicPortfolioGamesForClubAsync", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var end = source.IndexOf("GetPublicPortfolioGameBySlugAsync", start, StringComparison.Ordinal);
return end < 0 ? source[start..] : source[start..end];
}
private static string ShowcaseQuerySection(string source)
{
var start = source.IndexOf("GetShowcaseSessionsAsync", StringComparison.Ordinal);
if (start < 0)
return string.Empty;
var end = source.IndexOf("GetShowcaseSessionAsync", start, StringComparison.Ordinal);
return end < 0 ? source[start..] : source[start..end];
}
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}'.");
}
}