feat(web): authorize portfolio management and reviews
This commit is contained in:
@@ -47,6 +47,7 @@ builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
|
||||
builder.Services.AddScoped<AuthorizedPortfolioService>();
|
||||
|
||||
// Add Bot Client
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Security.Claims;
|
||||
using GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio;
|
||||
|
||||
public sealed class AuthorizedPortfolioService(
|
||||
IPortfolioStore portfolioStore,
|
||||
ISessionStore sessionStore,
|
||||
IPortfolioCoverStorage coverStorage,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
private (string Platform, string ExternalUserId, string? Name)? GetCurrentIdentity()
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||
return null;
|
||||
|
||||
var name = user.FindFirst(ClaimTypes.Name)?.Value;
|
||||
return (platform, externalUserId, name);
|
||||
}
|
||||
|
||||
private async Task<(string Platform, string ExternalUserId)> RequireManagerAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, "<anonymous>");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
private async Task<(Guid GroupId, string Platform, string ExternalUserId)> RequireManagerForGameAsync(Guid portfolioGameId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(portfolioGameId, "<anonymous>");
|
||||
}
|
||||
|
||||
var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId);
|
||||
if (groupId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Portfolio game not found.");
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(portfolioGameId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
return (groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
// --- Protected reads ---
|
||||
|
||||
public async Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await portfolioStore.GetPortfolioGamesForGroupAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<PortfolioGameEditor?> GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId);
|
||||
if (groupId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await portfolioStore.GetPortfolioGameForManagementAsync(portfolioGameId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortfolioSessionOption>> GetCompletedSessionsForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await portfolioStore.GetEligibleCompletedSessionsAsync(groupId, null);
|
||||
}
|
||||
|
||||
// --- Protected writes ---
|
||||
|
||||
public async Task<Guid> CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId)
|
||||
{
|
||||
await RequireManagerAsync(groupId);
|
||||
return await portfolioStore.CreatePortfolioDraftAsync(groupId, preselectedSessionId);
|
||||
}
|
||||
|
||||
public async Task UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var normalized = NormalizeUpdate(update);
|
||||
await portfolioStore.UpdatePortfolioDraftAsync(portfolioGameId, groupId, normalized);
|
||||
}
|
||||
|
||||
public async Task ReplaceCoverForCurrentUserAsync(
|
||||
Guid portfolioGameId,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var saveResult = await coverStorage.SaveAsync(content, contentType, cancellationToken);
|
||||
var newKey = saveResult.StorageKey;
|
||||
|
||||
try
|
||||
{
|
||||
var oldKey = await portfolioStore.SetPortfolioCoverAsync(portfolioGameId, groupId, newKey);
|
||||
if (!string.IsNullOrWhiteSpace(oldKey))
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(oldKey, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(newKey, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteForCurrentUserAsync(Guid portfolioGameId)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var coverKey = await portfolioStore.DeletePortfolioGameAsync(portfolioGameId, groupId);
|
||||
if (!string.IsNullOrWhiteSpace(coverKey))
|
||||
{
|
||||
await coverStorage.DeleteIfExistsAsync(coverKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic)
|
||||
{
|
||||
var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
await portfolioStore.SetPortfolioPublicationAsync(portfolioGameId, groupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task ModerateReviewForCurrentUserAsync(
|
||||
Guid portfolioGameId,
|
||||
Guid reviewId,
|
||||
string moderationStatus)
|
||||
{
|
||||
var (groupId, platform, externalUserId) = await RequireManagerForGameAsync(portfolioGameId);
|
||||
|
||||
var moderatorPlayerId = await sessionStore.ResolveEffectivePlayerIdAsync(platform, externalUserId);
|
||||
if (moderatorPlayerId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authenticated player not found.");
|
||||
}
|
||||
|
||||
await portfolioStore.ModeratePortfolioReviewAsync(
|
||||
reviewId,
|
||||
portfolioGameId,
|
||||
groupId,
|
||||
moderatorPlayerId.Value,
|
||||
moderationStatus);
|
||||
}
|
||||
|
||||
// --- Review submission ---
|
||||
|
||||
public async Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateForCurrentUserAsync(string slug)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
return PortfolioReviewSubmissionState.RequiresAuthentication;
|
||||
}
|
||||
|
||||
return await portfolioStore.GetReviewSubmissionStateAsync(slug, identity.Value.Platform, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
public async Task SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent)
|
||||
{
|
||||
if (!publicationConsent)
|
||||
{
|
||||
throw new InvalidOperationException("Public review requires explicit consent.");
|
||||
}
|
||||
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(Guid.Empty, "<anonymous>");
|
||||
}
|
||||
|
||||
var normalizedSlug = PortfolioValidation.NormalizeSlug(slug);
|
||||
var normalizedBody = PortfolioValidation.NormalizeReviewBody(body);
|
||||
|
||||
var displayName = identity.Value.Name?.Trim() ?? identity.Value.ExternalUserId;
|
||||
if (displayName.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Display name is required.");
|
||||
}
|
||||
|
||||
await portfolioStore.SubmitPortfolioReviewAsync(
|
||||
normalizedSlug,
|
||||
identity.Value.Platform,
|
||||
identity.Value.ExternalUserId,
|
||||
displayName,
|
||||
normalizedBody);
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
private static PortfolioGameUpdate NormalizeUpdate(PortfolioGameUpdate update)
|
||||
{
|
||||
var title = PortfolioValidation.NormalizeTitle(update.Title);
|
||||
var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug);
|
||||
var description = PortfolioValidation.NormalizeDescription(update.Description);
|
||||
var format = PortfolioValidation.NormalizeFormat(update.Format);
|
||||
var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim();
|
||||
|
||||
return update with
|
||||
{
|
||||
Title = title,
|
||||
PublicSlug = slug,
|
||||
Description = description,
|
||||
System = system,
|
||||
Format = format
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
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 SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException();
|
||||
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException();
|
||||
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException();
|
||||
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) => 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) => 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user