diff --git a/README.md b/README.md index 9ea1ddd..cc85131 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Gameplay capabilities now include: - Role-aware authorization with admin role support (including admin user/role management) - Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries) - Campaign management owner labels use account display names (no GUID fallback rendering) +- Character edit flow supports unlinking from campaigns (owner/GM/admin) and assigning to any existing campaign via expanded campaign options ## Prerequisites diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index 23222db..82c2ba0 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -159,4 +159,33 @@ public sealed class CampaignApiTests : ApiTestBase Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id); Assert.Contains(usersAfterDelete, user => user.Id == gm.Id); } + + [Fact] + public async Task CampaignOptionsEndpoint_ReturnsCampaignsBeyondVisibleCampaignList() + { + using var factory = CreateFactory(6, 5, 4); + using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + using var otherGmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(gmClient, "gm-options-1", "Password123", "GM One"); + await LoginAsync(gmClient, "gm-options-1", "Password123"); + + await RegisterAsync(otherGmClient, "gm-options-2", "Password123", "GM Two"); + await LoginAsync(otherGmClient, "gm-options-2", "Password123"); + + await RegisterAsync(playerClient, "player-options", "Password123", "Player"); + await LoginAsync(playerClient, "player-options", "Password123"); + + var firstCampaign = await PostAsync(gmClient, "/api/campaigns", new("Alpha Visible", "d6")); + var secondCampaign = await PostAsync(otherGmClient, "/api/campaigns", new("Beta Available", "d6")); + + var playerVisibleCampaigns = await GetAsync>(playerClient, "/api/campaigns"); + Assert.Empty(playerVisibleCampaigns); + + var playerCampaignOptions = await GetAsync>(playerClient, "/api/campaigns/options"); + Assert.Equal(2, playerCampaignOptions.Count); + Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id); + Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id); + } } diff --git a/RpgRoller.Tests/Services/ServiceCampaignTests.cs b/RpgRoller.Tests/Services/ServiceCampaignTests.cs index f0108b6..c13ab96 100644 --- a/RpgRoller.Tests/Services/ServiceCampaignTests.cs +++ b/RpgRoller.Tests/Services/ServiceCampaignTests.cs @@ -71,6 +71,30 @@ public sealed class ServiceCampaignTests Assert.Equal(gmCampaign.Id, campaigns[0].Id); } + [Fact] + public void GetCharacterCampaignOptions_ReturnsAllCampaignsForCharacterAssignment() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + service.Register("gm1", "Password123", "GM One"); + service.Register("gm2", "Password123", "GM Two"); + service.Register("player", "Password123", "Player"); + var gmSession = ServiceTestSupport.GetValue(service.Login("gm1", "Password123")).SessionToken; + var gmTwoSession = ServiceTestSupport.GetValue(service.Login("gm2", "Password123")).SessionToken; + var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken; + + var firstCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Alpha", "d6")); + var secondCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmTwoSession, "Beta", "d6")); + + var visibleCampaigns = ServiceTestSupport.GetValue(service.GetCampaigns(playerSession)); + Assert.Empty(visibleCampaigns); + + var options = ServiceTestSupport.GetValue(service.GetCharacterCampaignOptions(playerSession)); + Assert.Equal(2, options.Count); + Assert.Contains(options, option => option.Id == firstCampaign.Id); + Assert.Contains(options, option => option.Id == secondCampaign.Id); + } + [Fact] public void GetCampaign_ForNonGmParticipant_ReturnsCampaignCharactersAndSkills() { diff --git a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs index 1fb8839..cba59cc 100644 --- a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs @@ -94,4 +94,46 @@ public sealed class ServiceSkillGroupAndOwnershipTests Assert.False(service.ActivateCharacter(ownerSession, character.Id).Succeeded); Assert.True(service.ActivateCharacter(receiverSession, character.Id).Succeeded); } + + [Fact] + public void CharacterUnlink_AllowsOwnerGmAndAdmin() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register("gm", "Password123", "GM"); + service.Register("owner", "Password123", "Owner"); + service.Register("outsider", "Password123", "Outsider"); + service.Register("admin2", "Password123", "Admin Two"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken; + var outsiderSession = ServiceTestSupport.GetValue(service.Login("outsider", "Password123")).SessionToken; + var adminTwoSession = ServiceTestSupport.GetValue(service.Login("admin2", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Unlink Me", campaign.Id)); + + var outsiderUnlink = service.UpdateCharacter(outsiderSession, character.Id, "Unlink Me", null); + Assert.False(outsiderUnlink.Succeeded); + + var ownerUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(ownerSession, character.Id, "Owner Unlink", null)); + Assert.Null(ownerUnlink.CampaignId); + + var relinkByOwner = ServiceTestSupport.GetValue(service.UpdateCharacter(ownerSession, character.Id, "Relink", campaign.Id)); + Assert.Equal(campaign.Id, relinkByOwner.CampaignId); + + var gmUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Gm Unlink", null)); + Assert.Null(gmUnlink.CampaignId); + + var relinkByGm = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Relink Again", campaign.Id)); + Assert.Equal(campaign.Id, relinkByGm.CampaignId); + + var adminTwo = service.GetUserBySession(adminTwoSession); + Assert.NotNull(adminTwo); + _ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo!.Id, [ "admin" ])); + + var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null)); + Assert.Null(adminUnlink.CampaignId); + } } diff --git a/RpgRoller/Api/CampaignEndpoints.cs b/RpgRoller/Api/CampaignEndpoints.cs index cb19e54..abc8baf 100644 --- a/RpgRoller/Api/CampaignEndpoints.cs +++ b/RpgRoller/Api/CampaignEndpoints.cs @@ -19,6 +19,12 @@ internal static class CampaignEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapGet("/campaigns/options", (HttpContext context, IGameService game) => + { + var result = game.GetCharacterCampaignOptions(context.GetRequiredSessionToken()); + return ApiResultMapper.ToApiResult(result); + }); + group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) => { var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId); diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor index c8239ad..23f26d9 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor @@ -16,8 +16,8 @@ }