6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
Implements Issue #110: game masters can now publish sessions exclusively to a club's private showcase, gated behind a member application and approval flow. Adds a 4-state publication_mode (None/Catalog/ClubOnly/Both) replacing the binary is_public, plus a club_memberships table with Pending/Active/Rejected/Left lifecycle and partial unique index ensuring a single Active row per (group, player). Highlights - V030 migration: club_memberships, publication_mode, drop is_public, recreate partial indexes, portfolio_games gains publication_mode. - PublicationMode enum + extensions in GmRelay.Shared. - ISessionStore gains 12 membership/showcase methods; AuthorizedMembershipService owns the membership flow with GM-only approve/reject authorization. - PublicClub / PublicMasterProfile / PublicSession: member- aware queries (ClubOnly visible only to Active members). - New pages: MyClubMemberships (/profile/memberships) and ClubApplications (/group/{id}/applications). - GroupDetails and EditSession switch from a bool toggle to a 4-state publication_mode selector. - NavMenu adds Moji kluby, PublicLayout adds Kluby. Tests: 4 new test files (PublicationMode, ClubMemberships, AuthorizedMembershipService, ClubShowcaseSource) + updates to PublicClubPages, AuthorizedSessionService/Portfolio service FakeSessionStore, CampaignTemplatesNavigation. 493 tests pass. Bump version 3.6.0 -> 3.7.0 across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
869 lines
40 KiB
C#
869 lines
40 KiB
C#
using GmRelay.Shared.Domain;
|
|
using GmRelay.Web.Services;
|
|
using GmRelay.Web.Services.Portfolio;
|
|
using GmRelay.Web.Services.Portfolio.Covers;
|
|
using Microsoft.AspNetCore.Http;
|
|
using System.Security.Claims;
|
|
|
|
namespace GmRelay.Bot.Tests.Web;
|
|
|
|
public sealed class AuthorizedPortfolioServiceTests
|
|
{
|
|
private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, externalUserId),
|
|
new Claim("TelegramId", externalUserId),
|
|
new Claim("Platform", "Telegram")
|
|
};
|
|
if (name is not null)
|
|
claims.Add(new Claim(ClaimTypes.Name, name));
|
|
|
|
var identity = new ClaimsIdentity(claims, "Test");
|
|
var principal = new ClaimsPrincipal(identity);
|
|
var httpContext = new DefaultHttpContext { User = principal };
|
|
return new HttpContextAccessor { HttpContext = httpContext };
|
|
}
|
|
|
|
private static IHttpContextAccessor CreateAnonymousAccessor()
|
|
{
|
|
var httpContext = new DefaultHttpContext();
|
|
return new HttpContextAccessor { HttpContext = httpContext };
|
|
}
|
|
|
|
private static AuthorizedPortfolioService CreateService(
|
|
FakePortfolioStore? portfolioStore = null,
|
|
FakeSessionStore? sessionStore = null,
|
|
FakePortfolioCoverStorage? coverStorage = null,
|
|
IHttpContextAccessor? accessor = null,
|
|
bool isManager = true,
|
|
Guid? knownGroupId = null)
|
|
{
|
|
portfolioStore ??= new FakePortfolioStore();
|
|
sessionStore ??= new FakeSessionStore();
|
|
coverStorage ??= new FakePortfolioCoverStorage();
|
|
accessor ??= CreateAccessor("1001");
|
|
|
|
// Wire a known group + manager relationship for the test
|
|
if (knownGroupId is not null)
|
|
{
|
|
portfolioStore.GroupIds[Guid.NewGuid()] = knownGroupId.Value; // placeholder
|
|
sessionStore.ManagerFlags[(knownGroupId.Value, "Telegram", "1001")] = isManager;
|
|
}
|
|
|
|
return new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var sessionId = Guid.NewGuid();
|
|
var draftId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
CreateDraftResult = draftId,
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?>
|
|
{
|
|
[draftId] = groupId
|
|
}
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId);
|
|
|
|
Assert.Equal(draftId, created);
|
|
Assert.Equal(groupId, portfolioStore.LastCreateGroupId);
|
|
Assert.Equal(sessionId, portfolioStore.LastCreatePreselectedSessionId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = false
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(new FakePortfolioStore(), sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
|
() => service.CreateDraftForCurrentUserAsync(groupId, null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
|
CoverPriorKey = "old.png"
|
|
};
|
|
var coverStorage = new FakePortfolioCoverStorage
|
|
{
|
|
SaveKey = "new.png"
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
|
|
|
await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png");
|
|
|
|
Assert.Contains("old.png", coverStorage.DeletedKeys);
|
|
Assert.Contains("new.png", coverStorage.SavedKeys);
|
|
Assert.Equal(portfolioGameId, portfolioStore.LastSetCoverGameId);
|
|
Assert.Equal("new.png", portfolioStore.LastSetCoverKey);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
|
SetCoverThrows = new InvalidOperationException("boom")
|
|
};
|
|
var coverStorage = new FakePortfolioCoverStorage
|
|
{
|
|
SaveKey = "new.png"
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png"));
|
|
|
|
Assert.Contains("new.png", coverStorage.DeletedKeys);
|
|
Assert.DoesNotContain("old.png", coverStorage.DeletedKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnNullForAnotherClubManager()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = false
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);
|
|
|
|
Assert.Null(editor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnEditorForCoGm()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
|
EditorResult = new PortfolioGameEditor(
|
|
portfolioGameId,
|
|
groupId,
|
|
"Title",
|
|
"slug",
|
|
"Description",
|
|
"/portfolio-covers/x.png",
|
|
"D&D 5e",
|
|
"Online",
|
|
DateTime.UtcNow,
|
|
false,
|
|
[],
|
|
[],
|
|
[])
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);
|
|
|
|
Assert.NotNull(editor);
|
|
Assert.Equal("Title", editor!.Title);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = false
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
var update = new PortfolioGameUpdate("Updated", "updated-slug", "desc", null, "Online", [], []);
|
|
|
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
|
() => service.UpdateDraftForCurrentUserAsync(portfolioGameId, update));
|
|
Assert.False(portfolioStore.UpdateCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateDraftForCurrentUserAsync_ShouldNormalizeFieldsBeforeStoring()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
var update = new PortfolioGameUpdate(" Updated ", " my-slug ", " description ", null, " Online ", [], []);
|
|
|
|
await service.UpdateDraftForCurrentUserAsync(portfolioGameId, update);
|
|
|
|
Assert.True(portfolioStore.UpdateCalled);
|
|
Assert.Equal("Updated", portfolioStore.LastUpdateTitle);
|
|
Assert.Equal("my-slug", portfolioStore.LastUpdateSlug);
|
|
Assert.Equal("description", portfolioStore.LastUpdateDescription);
|
|
Assert.Equal("Online", portfolioStore.LastUpdateFormat);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ModerateReviewForCurrentUserAsync_ShouldResolveEffectivePlayerAndForwardIt()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var reviewId = Guid.NewGuid();
|
|
var effectivePlayerId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
},
|
|
EffectivePlayerId = effectivePlayerId
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await service.ModerateReviewForCurrentUserAsync(portfolioGameId, reviewId, "Approved");
|
|
|
|
Assert.True(portfolioStore.ModerateCalled);
|
|
Assert.Equal(reviewId, portfolioStore.LastModerateReviewId);
|
|
Assert.Equal(portfolioGameId, portfolioStore.LastModerateGameId);
|
|
Assert.Equal(groupId, portfolioStore.LastModerateGroupId);
|
|
Assert.Equal(effectivePlayerId, portfolioStore.LastModeratePlayerId);
|
|
Assert.Equal("Approved", portfolioStore.LastModerateStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ModerateReviewForCurrentUserAsync_ShouldRejectAnotherClubManager()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = false
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
|
() => service.ModerateReviewForCurrentUserAsync(portfolioGameId, Guid.NewGuid(), "Approved"));
|
|
Assert.False(portfolioStore.ModerateCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteForCurrentUserAsync_ShouldDeleteCoverAfterRowDeletion()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
|
DeleteCoverKey = "old.png"
|
|
};
|
|
var coverStorage = new FakePortfolioCoverStorage();
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
|
|
|
await service.DeleteForCurrentUserAsync(portfolioGameId);
|
|
|
|
Assert.True(portfolioStore.DeleteCalled);
|
|
Assert.Equal(portfolioGameId, portfolioStore.LastDeleteGameId);
|
|
Assert.Equal(groupId, portfolioStore.LastDeleteGroupId);
|
|
Assert.Contains("old.png", coverStorage.DeletedKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteForCurrentUserAsync_ShouldStillDeleteRowWhenNoCover()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
|
|
DeleteCoverKey = null
|
|
};
|
|
var coverStorage = new FakePortfolioCoverStorage();
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
|
|
|
|
await service.DeleteForCurrentUserAsync(portfolioGameId);
|
|
|
|
Assert.True(portfolioStore.DeleteCalled);
|
|
Assert.Empty(coverStorage.DeletedKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetPublicationForCurrentUserAsync_ShouldForwardIsPublicFlag()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await service.SetPublicationForCurrentUserAsync(portfolioGameId, isPublic: true);
|
|
|
|
Assert.True(portfolioStore.PublicationCalled);
|
|
Assert.Equal(portfolioGameId, portfolioStore.LastPublicationGameId);
|
|
Assert.Equal(groupId, portfolioStore.LastPublicationGroupId);
|
|
Assert.True(portfolioStore.LastPublicationIsPublic);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldReturnRequiresAuthForAnonymous()
|
|
{
|
|
var portfolioStore = new FakePortfolioStore();
|
|
var sessionStore = new FakeSessionStore();
|
|
var accessor = CreateAnonymousAccessor();
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");
|
|
|
|
Assert.Equal(PortfolioReviewSubmissionState.RequiresAuthentication, state);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldForwardPlatformAndUserId()
|
|
{
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
ReviewStateResult = PortfolioReviewSubmissionState.Eligible
|
|
};
|
|
var sessionStore = new FakeSessionStore();
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");
|
|
|
|
Assert.Equal(PortfolioReviewSubmissionState.Eligible, state);
|
|
Assert.Equal("some-slug", portfolioStore.LastReviewStateSlug);
|
|
Assert.Equal("Telegram", portfolioStore.LastReviewStatePlatform);
|
|
Assert.Equal("1001", portfolioStore.LastReviewStateExternalUserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitReviewForCurrentUserAsync_ShouldRejectAnonymous()
|
|
{
|
|
var portfolioStore = new FakePortfolioStore();
|
|
var sessionStore = new FakeSessionStore();
|
|
var accessor = CreateAnonymousAccessor();
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(
|
|
() => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", true));
|
|
Assert.False(portfolioStore.SubmitReviewCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitReviewForCurrentUserAsync_ShouldRejectMissingConsent()
|
|
{
|
|
var portfolioStore = new FakePortfolioStore();
|
|
var sessionStore = new FakeSessionStore();
|
|
var accessor = CreateAccessor("1001", "Alice");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", false));
|
|
Assert.False(portfolioStore.SubmitReviewCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitReviewForCurrentUserAsync_ShouldNormalizeBodyAndForwardIdentity()
|
|
{
|
|
var portfolioStore = new FakePortfolioStore();
|
|
var sessionStore = new FakeSessionStore();
|
|
var accessor = CreateAccessor("1001", "Alice");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await service.SubmitReviewForCurrentUserAsync(
|
|
" the-curse-of-strahd ",
|
|
" great adventure, would play again ",
|
|
true);
|
|
|
|
Assert.True(portfolioStore.SubmitReviewCalled);
|
|
Assert.Equal("the-curse-of-strahd", portfolioStore.LastSubmitSlug);
|
|
Assert.Equal("great adventure, would play again", portfolioStore.LastSubmitBody);
|
|
Assert.Equal("Alice", portfolioStore.LastSubmitDisplayName);
|
|
Assert.Equal("Telegram", portfolioStore.LastSubmitPlatform);
|
|
Assert.Equal("1001", portfolioStore.LastSubmitExternalUserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnEmptyForNonManager()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore();
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = false
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);
|
|
|
|
Assert.Empty(sessions);
|
|
Assert.Null(portfolioStore.LastEligibleGroupId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnSessionsForManager()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var sessionId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
EligibleSessions =
|
|
[
|
|
new PortfolioSessionOption(sessionId, "Old session", DateTime.UtcNow.AddDays(-7), false)
|
|
]
|
|
};
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = true
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);
|
|
|
|
Assert.Single(sessions);
|
|
Assert.Equal(sessionId, sessions[0].Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPortfolioGamesForCurrentUserAsync_ShouldReturnEmptyForNonManager()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore();
|
|
var sessionStore = new FakeSessionStore
|
|
{
|
|
ManagerFlags = new Dictionary<(Guid, string, string), bool>
|
|
{
|
|
[(groupId, "Telegram", "1001")] = false
|
|
}
|
|
};
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
var games = await service.GetPortfolioGamesForCurrentUserAsync(groupId);
|
|
|
|
Assert.Empty(games);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IDScopedMethod_ShouldThrowWhenPortfolioGameDoesNotExist()
|
|
{
|
|
var portfolioGameId = Guid.NewGuid();
|
|
var portfolioStore = new FakePortfolioStore
|
|
{
|
|
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = null }
|
|
};
|
|
var sessionStore = new FakeSessionStore();
|
|
var accessor = CreateAccessor("1001");
|
|
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.DeleteForCurrentUserAsync(portfolioGameId));
|
|
}
|
|
|
|
// --- Fakes ---
|
|
|
|
private sealed class FakePortfolioStore : IPortfolioStore
|
|
{
|
|
public Dictionary<Guid, Guid?> PortfolioGameGroupIds { get; set; } = new();
|
|
|
|
public Dictionary<Guid, Guid> GroupIds { get; set; } = new();
|
|
|
|
public Guid CreateDraftResult { get; set; } = Guid.NewGuid();
|
|
|
|
public Guid? LastCreateGroupId { get; private set; }
|
|
public Guid? LastCreatePreselectedSessionId { get; private set; }
|
|
public bool CreateCalled { get; private set; }
|
|
|
|
public PortfolioGameEditor? EditorResult { get; set; }
|
|
|
|
public string? CoverPriorKey { get; set; }
|
|
public Exception? SetCoverThrows { get; set; }
|
|
public Guid? LastSetCoverGameId { get; private set; }
|
|
public Guid? LastSetCoverGroupId { get; private set; }
|
|
public string? LastSetCoverKey { get; private set; }
|
|
|
|
public bool UpdateCalled { get; private set; }
|
|
public Guid? LastUpdateGameId { get; private set; }
|
|
public Guid? LastUpdateGroupId { get; private set; }
|
|
public string? LastUpdateTitle { get; private set; }
|
|
public string? LastUpdateSlug { get; private set; }
|
|
public string? LastUpdateDescription { get; private set; }
|
|
public string? LastUpdateFormat { get; private set; }
|
|
|
|
public bool DeleteCalled { get; private set; }
|
|
public Guid? LastDeleteGameId { get; private set; }
|
|
public Guid? LastDeleteGroupId { get; private set; }
|
|
public string? DeleteCoverKey { get; set; }
|
|
|
|
public bool PublicationCalled { get; private set; }
|
|
public Guid? LastPublicationGameId { get; private set; }
|
|
public Guid? LastPublicationGroupId { get; private set; }
|
|
public bool? LastPublicationIsPublic { get; private set; }
|
|
|
|
public bool ModerateCalled { get; private set; }
|
|
public Guid? LastModerateReviewId { get; private set; }
|
|
public Guid? LastModerateGameId { get; private set; }
|
|
public Guid? LastModerateGroupId { get; private set; }
|
|
public Guid? LastModeratePlayerId { get; private set; }
|
|
public string? LastModerateStatus { get; private set; }
|
|
|
|
public IReadOnlyList<PortfolioSessionOption> EligibleSessions { get; set; } = [];
|
|
public Guid? LastEligibleGroupId { get; private set; }
|
|
|
|
public IReadOnlyList<PortfolioGameSummary> GamesForGroup { get; set; } = [];
|
|
|
|
public PortfolioReviewSubmissionState ReviewStateResult { get; set; } = PortfolioReviewSubmissionState.Ineligible;
|
|
public string? LastReviewStateSlug { get; private set; }
|
|
public string? LastReviewStatePlatform { get; private set; }
|
|
public string? LastReviewStateExternalUserId { get; private set; }
|
|
|
|
public bool SubmitReviewCalled { get; private set; }
|
|
public string? LastSubmitSlug { get; private set; }
|
|
public string? LastSubmitBody { get; private set; }
|
|
public string? LastSubmitDisplayName { get; private set; }
|
|
public string? LastSubmitPlatform { get; private set; }
|
|
public string? LastSubmitExternalUserId { get; private set; }
|
|
|
|
public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug) =>
|
|
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
|
|
|
|
public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug) =>
|
|
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
|
|
|
|
public Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug) =>
|
|
Task.FromResult<PublicPortfolioGame?>(null);
|
|
|
|
public Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId)
|
|
{
|
|
LastEligibleGroupId = groupId;
|
|
return Task.FromResult(GamesForGroup);
|
|
}
|
|
|
|
public Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId)
|
|
{
|
|
PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId);
|
|
return Task.FromResult(groupId);
|
|
}
|
|
|
|
public Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId) =>
|
|
Task.FromResult(EditorResult);
|
|
|
|
public Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId)
|
|
{
|
|
LastEligibleGroupId = groupId;
|
|
return Task.FromResult(EligibleSessions);
|
|
}
|
|
|
|
public Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) =>
|
|
Task.FromResult<IReadOnlyList<PortfolioMasterOption>>([]);
|
|
|
|
public Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId)
|
|
{
|
|
CreateCalled = true;
|
|
LastCreateGroupId = groupId;
|
|
LastCreatePreselectedSessionId = preselectedSessionId;
|
|
return Task.FromResult(CreateDraftResult);
|
|
}
|
|
|
|
public Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update)
|
|
{
|
|
UpdateCalled = true;
|
|
LastUpdateGameId = portfolioGameId;
|
|
LastUpdateGroupId = groupId;
|
|
LastUpdateTitle = update.Title;
|
|
LastUpdateSlug = update.PublicSlug;
|
|
LastUpdateDescription = update.Description;
|
|
LastUpdateFormat = update.Format;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey)
|
|
{
|
|
LastSetCoverGameId = portfolioGameId;
|
|
LastSetCoverGroupId = groupId;
|
|
LastSetCoverKey = storageKey;
|
|
|
|
if (SetCoverThrows is not null)
|
|
{
|
|
throw SetCoverThrows;
|
|
}
|
|
|
|
return Task.FromResult(CoverPriorKey);
|
|
}
|
|
|
|
public Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId)
|
|
{
|
|
DeleteCalled = true;
|
|
LastDeleteGameId = portfolioGameId;
|
|
LastDeleteGroupId = groupId;
|
|
return Task.FromResult(DeleteCoverKey);
|
|
}
|
|
|
|
public Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic)
|
|
{
|
|
PublicationCalled = true;
|
|
LastPublicationGameId = portfolioGameId;
|
|
LastPublicationGroupId = groupId;
|
|
LastPublicationIsPublic = isPublic;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task ModeratePortfolioReviewAsync(
|
|
Guid reviewId,
|
|
Guid portfolioGameId,
|
|
Guid groupId,
|
|
Guid moderatorPlayerId,
|
|
string moderationStatus)
|
|
{
|
|
ModerateCalled = true;
|
|
LastModerateReviewId = reviewId;
|
|
LastModerateGameId = portfolioGameId;
|
|
LastModerateGroupId = groupId;
|
|
LastModeratePlayerId = moderatorPlayerId;
|
|
LastModerateStatus = moderationStatus;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId)
|
|
{
|
|
LastReviewStateSlug = slug;
|
|
LastReviewStatePlatform = platform;
|
|
LastReviewStateExternalUserId = externalUserId;
|
|
return Task.FromResult(ReviewStateResult);
|
|
}
|
|
|
|
public Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body)
|
|
{
|
|
SubmitReviewCalled = true;
|
|
LastSubmitSlug = slug;
|
|
LastSubmitPlatform = platform;
|
|
LastSubmitExternalUserId = externalUserId;
|
|
LastSubmitDisplayName = displayName;
|
|
LastSubmitBody = body;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSessionStore : ISessionStore
|
|
{
|
|
public Dictionary<(Guid GroupId, string Platform, string ExternalUserId), bool> ManagerFlags { get; set; } = new();
|
|
|
|
public Guid? EffectivePlayerId { get; set; } = Guid.NewGuid();
|
|
|
|
public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
|
{
|
|
if (ManagerFlags.TryGetValue((groupId, platform, externalUserId), out var flag))
|
|
{
|
|
return Task.FromResult(flag);
|
|
}
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
|
|
Task.FromResult(EffectivePlayerId);
|
|
|
|
// Unused interface members — throw so accidental use surfaces in test output
|
|
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
|
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException();
|
|
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
|
|
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
|
|
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
|
|
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException();
|
|
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException();
|
|
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
|
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException();
|
|
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException();
|
|
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => throw new NotImplementedException();
|
|
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
|
|
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
|
|
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => throw new NotImplementedException();
|
|
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException();
|
|
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
|
|
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task<WebSession?> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
|
|
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId) => throw new NotImplementedException();
|
|
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException();
|
|
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
|
|
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException();
|
|
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException();
|
|
public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) => throw new NotImplementedException();
|
|
public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException();
|
|
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException();
|
|
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException();
|
|
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException();
|
|
public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) => throw new NotImplementedException();
|
|
public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) => throw new NotImplementedException();
|
|
public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) => throw new NotImplementedException();
|
|
public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException();
|
|
public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException();
|
|
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) => throw new NotImplementedException();
|
|
public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) => throw new NotImplementedException();
|
|
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException();
|
|
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException();
|
|
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
|
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException();
|
|
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
|
|
public Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException();
|
|
public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException();
|
|
public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException();
|
|
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) => throw new NotImplementedException();
|
|
public Task<IReadOnlyList<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto>> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException();
|
|
public Task<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException();
|
|
public Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) => throw new NotImplementedException();
|
|
}
|
|
|
|
private sealed class FakePortfolioCoverStorage : IPortfolioCoverStorage
|
|
{
|
|
public string SaveKey { get; set; } = Guid.NewGuid().ToString("N") + ".png";
|
|
public List<string> SavedKeys { get; } = new();
|
|
public List<string> DeletedKeys { get; } = new();
|
|
|
|
public Task<PortfolioCoverUploadResult> SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default)
|
|
{
|
|
SavedKeys.Add(SaveKey);
|
|
return Task.FromResult(new PortfolioCoverUploadResult(SaveKey, contentType));
|
|
}
|
|
|
|
public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default)
|
|
{
|
|
DeletedKeys.Add(storageKey);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public string GetPublicPath(string storageKey) => "/portfolio-covers/" + storageKey;
|
|
}
|
|
}
|