namespace RpgRoller.Tests; public sealed class CampaignApiTests : ApiTestBase { public CampaignApiTests(WebApplicationFactory factory) : base(factory) { } [Fact] public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation() { using var factory = CreateFactory(6, 6, 6); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); await LoginAsync(gmClient, "gm", "Password123"); var campaign = await PostAsync(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e")); var gmCharacter = await PostAsync(gmClient, "/api/characters", new("Arin", campaign.Id)); Assert.Equal("Game Master", gmCharacter.OwnerDisplayName); var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null); Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode); var createdSkill = await PostAsync(gmClient, $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false)); Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); Assert.Equal(0, createdSkill.WildDice); Assert.False(createdSkill.AllowFumble); var updatedSkill = await PutAsync(gmClient, $"/api/skills/{createdSkill.Id}", new("Arcana Mastery", "2d12+3", 0, false)); Assert.Equal("Arcana Mastery", updatedSkill.Name); Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition); Assert.Equal(0, updatedSkill.WildDice); Assert.False(updatedSkill.AllowFumble); var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills", new CreateSkillRequest("Broken", "5D+4", 0, false)); Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode); var details = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}"); Assert.Equal(campaign.Id, details.Id); Assert.Single(details.Characters); Assert.Equal("Game Master", details.Characters[0].OwnerDisplayName); var currentCampaignCharacters = await GetAsync>(gmClient, "/api/characters"); Assert.Single(currentCampaignCharacters); Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName); var otherCampaign = await PostAsync(gmClient, "/api/campaigns", new("Beta Campaign", "d6")); var updatedCharacter = await PutAsync(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id)); Assert.Equal("Arin Updated", updatedCharacter.Name); Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); } [Fact] public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi() { using var factory = CreateFactory(6, 4, 5, 3); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var receiverClient = factory.CreateClient(new() { AllowAutoRedirect = false }); await RegisterAsync(gmClient, "gm2", "Password123", "GM"); await LoginAsync(gmClient, "gm2", "Password123"); await RegisterAsync(ownerClient, "owner2", "Password123", "Owner"); await LoginAsync(ownerClient, "owner2", "Password123"); await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver"); await LoginAsync(receiverClient, "receiver2", "Password123"); var campaign = await PostAsync(gmClient, "/api/campaigns", new("Grouped Campaign", "d6")); var character = await PostAsync(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id)); var createdGroup = await PostAsync(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true)); var renamedGroup = await PutAsync(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false)); Assert.Equal("Battle", renamedGroup.Name); Assert.Equal("3D+2", renamedGroup.DiceRollDefinition); Assert.Equal(2, renamedGroup.WildDice); Assert.False(renamedGroup.AllowFumble); var groupedSkill = await PostAsync(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id)); Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId); var ungroupedSkill = await PutAsync(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, null)); Assert.Null(ungroupedSkill.SkillGroupId); var groupedAgainSkill = await PutAsync(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id)); Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId); var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}"); Assert.Equal(HttpStatusCode.OK, deleteSkill.StatusCode); var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}"); Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode); var transferResult = await PutAsync(gmClient, $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2")); Assert.Equal("Grouped Hero", transferResult.Name); Assert.Equal("Receiver", transferResult.OwnerDisplayName); var gmCampaignView = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}"); var gmViewedCharacter = Assert.Single(gmCampaignView.Characters, c => c.Id == character.Id); Assert.Equal("Receiver", gmViewedCharacter.OwnerDisplayName); var ownerActivate = await ownerClient.PostAsync($"/api/characters/{character.Id}/activate", null); Assert.Equal(HttpStatusCode.BadRequest, ownerActivate.StatusCode); var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null); Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode); } [Fact] public async Task AdminUserManagementAndCampaignDeletion_WorkThroughApi() { using var factory = CreateFactory(6, 5, 4); using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false }); var admin = await RegisterAsync(adminClient, "admin3", "Password123", "Admin"); var gm = await RegisterAsync(gmClient, "gm3", "Password123", "GM"); var player = await RegisterAsync(playerClient, "player3", "Password123", "Player"); await LoginAsync(adminClient, "admin3", "Password123"); await LoginAsync(gmClient, "gm3", "Password123"); await LoginAsync(playerClient, "player3", "Password123"); var adminUsers = await GetAsync>(adminClient, "/api/admin/users"); var adminEntry = adminUsers.Single(user => user.Id == admin.Id); var playerEntry = adminUsers.Single(user => user.Id == player.Id); Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Empty(playerEntry.Roles); var promotedPlayer = await PutAsync(adminClient, $"/api/admin/users/{player.Id}/roles", new([ "admin" ])); Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); var campaign = await PostAsync(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); var character = await PostAsync(playerClient, "/api/characters", new("Disposable Hero", campaign.Id)); var skill = await PostAsync(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); _ = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}"); Assert.Equal(HttpStatusCode.OK, deleteCampaign.StatusCode); var getDeletedCampaign = await gmClient.GetAsync($"/api/campaigns/{campaign.Id}"); Assert.Equal(HttpStatusCode.BadRequest, getDeletedCampaign.StatusCode); var playerCharacters = await GetAsync>(playerClient, "/api/characters"); Assert.Single(playerCharacters); Assert.Null(playerCharacters[0].CampaignId); var deleteUser = await adminClient.DeleteAsync($"/api/admin/users/{player.Id}"); Assert.Equal(HttpStatusCode.OK, deleteUser.StatusCode); var usersAfterDelete = await GetAsync>(adminClient, "/api/admin/users"); 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); } [Fact] public async Task CharacterDelete_RequiresOwnerOrAdmin() { using var factory = CreateFactory(6, 5, 4); using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var otherClient = factory.CreateClient(new() { AllowAutoRedirect = false }); await RegisterAsync(adminClient, "admin-delete", "Password123", "Admin"); await LoginAsync(adminClient, "admin-delete", "Password123"); await RegisterAsync(gmClient, "gm-delete", "Password123", "GM"); await LoginAsync(gmClient, "gm-delete", "Password123"); await RegisterAsync(ownerClient, "owner-delete", "Password123", "Owner"); await LoginAsync(ownerClient, "owner-delete", "Password123"); await RegisterAsync(otherClient, "other-delete", "Password123", "Other"); await LoginAsync(otherClient, "other-delete", "Password123"); var campaign = await PostAsync(gmClient, "/api/campaigns", new("Deletion Campaign", "d6")); var ownerCharacter = await PostAsync(ownerClient, "/api/characters", new("Owner Character", campaign.Id)); var otherCharacter = await PostAsync(otherClient, "/api/characters", new("Other Character", campaign.Id)); var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode); var otherDeleteAttempt = await otherClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); Assert.Equal(HttpStatusCode.BadRequest, otherDeleteAttempt.StatusCode); var ownerDelete = await ownerClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); Assert.Equal(HttpStatusCode.OK, ownerDelete.StatusCode); var adminDelete = await adminClient.DeleteAsync($"/api/characters/{otherCharacter.Id}"); Assert.Equal(HttpStatusCode.OK, adminDelete.StatusCode); var campaignAfterDeletes = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}"); Assert.Empty(campaignAfterDeletes.Characters); } }