diff --git a/README.md b/README.md index 2589dee..a64a403 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ Fresh full-stack starter scaffold: - `RpgRoller.Tests/`: xUnit integration-heavy test project - `RpgRoller.sln`: solution used by local CI script +Test layout: + +- `RpgRoller.Tests/Api/`: API integration tests grouped by feature concern +- `RpgRoller.Tests/Services/`: service-level tests grouped by domain concern +- `RpgRoller.Tests/Support/`: shared test harnesses/builders/helpers + ## Code Organization Backend: diff --git a/RpgRoller.Tests/Api/AuthApiTests.cs b/RpgRoller.Tests/Api/AuthApiTests.cs new file mode 100644 index 0000000..3d199d9 --- /dev/null +++ b/RpgRoller.Tests/Api/AuthApiTests.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace RpgRoller.Tests; + +public sealed class AuthApiTests : ApiTestBase +{ + public AuthApiTests(WebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard() + { + using var factory = CreateFactory(4, 4, 4); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice"); + Assert.Equal("alice", registerResult.Username); + + var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2")); + Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); + + var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123")); + Assert.Equal(HttpStatusCode.OK, loginResult.StatusCode); + + var me = await GetAsync(client, "/api/me"); + Assert.Equal(registerResult.Id, me.User.Id); + Assert.Null(me.ActiveCharacterId); + Assert.Null(me.CurrentCampaignId); + + var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password")); + Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode); + } +} diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs new file mode 100644 index 0000000..4ac4a0e --- /dev/null +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +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 WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); + await LoginAsync(gmClient, "gm", "Password123"); + + var campaign = await PostAsync( + gmClient, + "/api/campaigns", + new CreateCampaignRequest("Alpha Campaign", "dnd5e")); + + var gmCharacter = await PostAsync( + gmClient, + "/api/characters", + new CreateCharacterRequest("Arin", campaign.Id)); + + 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 CreateSkillRequest("Arcana", "2d12+2")); + Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); + + var updatedSkill = await PutAsync( + gmClient, + $"/api/skills/{createdSkill.Id}", + new UpdateSkillRequest("Arcana Mastery", "2d12+3")); + Assert.Equal("Arcana Mastery", updatedSkill.Name); + Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition); + + var invalidSkill = await gmClient.PostAsJsonAsync( + $"/api/characters/{gmCharacter.Id}/skills", + new CreateSkillRequest("Broken", "5D+4")); + 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.Single(details.Skills); + + var currentCampaignCharacters = await GetAsync>(gmClient, "/api/characters/current-campaign"); + Assert.Single(currentCampaignCharacters); + Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); + + var otherCampaign = await PostAsync( + gmClient, + "/api/campaigns", + new CreateCampaignRequest("Beta Campaign", "d6")); + + var updatedCharacter = await PutAsync( + gmClient, + $"/api/characters/{gmCharacter.Id}", + new UpdateCharacterRequest("Arin Updated", otherCampaign.Id)); + + Assert.Equal("Arin Updated", updatedCharacter.Name); + Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); + } +} diff --git a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs new file mode 100644 index 0000000..dc5a9e8 --- /dev/null +++ b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace RpgRoller.Tests; + +public sealed class RollVisibilityApiTests : ApiTestBase +{ + public RollVisibilityApiTests(WebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task RollVisibilityAndAuthorization_AreEnforced() + { + using var factory = CreateFactory(4, 3, 5, 2, 6); + using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + using var playerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + using var observerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + using var outsiderClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + await RegisterAsync(gmClient, "gm", "Password123", "GM"); + await LoginAsync(gmClient, "gm", "Password123"); + var campaign = await PostAsync( + gmClient, + "/api/campaigns", + new CreateCampaignRequest("Main", "d6")); + + await RegisterAsync(playerClient, "player", "Password123", "Player"); + await LoginAsync(playerClient, "player", "Password123"); + var playerCharacter = await PostAsync( + playerClient, + "/api/characters", + new CreateCharacterRequest("Rogue", campaign.Id)); + + var skill = await PostAsync( + playerClient, + $"/api/characters/{playerCharacter.Id}/skills", + new CreateSkillRequest("Stealth", "2D+1")); + + await RegisterAsync(observerClient, "observer", "Password123", "Observer"); + await LoginAsync(observerClient, "observer", "Password123"); + await PostAsync( + observerClient, + "/api/characters", + new CreateCharacterRequest("Watcher", campaign.Id)); + + var privateRoll = await PostAsync( + playerClient, + $"/api/skills/{skill.Id}/roll", + new RollSkillRequest("private")); + var publicRoll = await PostAsync( + playerClient, + $"/api/skills/{skill.Id}/roll", + new RollSkillRequest("public")); + + Assert.Equal("private", privateRoll.Visibility); + Assert.Equal("public", publicRoll.Visibility); + + var gmLog = await GetAsync>(gmClient, $"/api/campaigns/{campaign.Id}/log"); + Assert.Equal(2, gmLog.Count); + + var playerLog = await GetAsync>(playerClient, $"/api/campaigns/{campaign.Id}/log"); + Assert.Equal(2, playerLog.Count); + + var observerLog = await GetAsync>(observerClient, $"/api/campaigns/{campaign.Id}/log"); + Assert.Single(observerLog); + Assert.Equal("public", observerLog[0].Visibility); + + await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider"); + await LoginAsync(outsiderClient, "outsider", "Password123"); + + var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}"); + Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode); + + var invalidVisibility = await playerClient.PostAsJsonAsync( + $"/api/skills/{skill.Id}/roll", + new RollSkillRequest("hidden")); + Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode); + + using var anonymousClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync( + "/api/campaigns", + new CreateCampaignRequest("Nope", "d6")); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode); + + var invalidSessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns"); + invalidSessionRequest.Headers.Add("Cookie", "rpgroller_session=invalid-token"); + var unauthorizedWithInvalidSession = await anonymousClient.SendAsync(invalidSessionRequest); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedWithInvalidSession.StatusCode); + } +} diff --git a/RpgRoller.Tests/Api/SystemApiTests.cs b/RpgRoller.Tests/Api/SystemApiTests.cs new file mode 100644 index 0000000..f0b6e48 --- /dev/null +++ b/RpgRoller.Tests/Api/SystemApiTests.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace RpgRoller.Tests; + +public sealed class SystemApiTests : ApiTestBase +{ + public SystemApiTests(WebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task RulesetAndSseEndpoints_ReturnExpectedResponses() + { + using var factory = CreateFactory(2, 2, 2); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var rulesets = await GetAsync>(client, "/api/rulesets"); + Assert.Equal(2, rulesets.Count); + + await RegisterAsync(client, "sse", "Password123", "Sse User"); + await LoginAsync(client, "sse", "Password123"); + + var campaign = await PostAsync( + client, + "/api/campaigns", + new CreateCampaignRequest("SSE", "d6")); + + var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode); + Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType); + } +} diff --git a/RpgRoller.Tests/BackendCoverageTests.cs b/RpgRoller.Tests/BackendCoverageTests.cs index 8fcd04f..3e63bf8 100644 --- a/RpgRoller.Tests/BackendCoverageTests.cs +++ b/RpgRoller.Tests/BackendCoverageTests.cs @@ -1,37 +1,10 @@ using System.Reflection; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using RpgRoller.Hosting; namespace RpgRoller.Tests; public sealed class BackendCoverageTests { - [Fact] - public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices() - { - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:RpgRoller"] = "Data Source=:memory:" - }) - .Build(); - - var environment = new TestWebHostEnvironment - { - ContentRootPath = Path.GetTempPath() - }; - - services.AddRpgRollerCore(configuration, environment); - - Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IGameService)); - Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller)); - } - [Fact] public void GetRequiredSessionToken_ThrowsWhenTokenWasNotStored() { @@ -47,14 +20,4 @@ public sealed class BackendCoverageTests var exception = Assert.Throws(() => method!.Invoke(null, [context])); Assert.IsType(exception.InnerException); } - - private sealed class TestWebHostEnvironment : IWebHostEnvironment - { - public string ApplicationName { get; set; } = "RpgRoller.Tests"; - public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); - public string WebRootPath { get; set; } = string.Empty; - public string EnvironmentName { get; set; } = "Development"; - public string ContentRootPath { get; set; } = string.Empty; - public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); - } } diff --git a/RpgRoller.Tests/GameServiceTests.cs b/RpgRoller.Tests/GameServiceTests.cs index 336885d..16ee960 100644 --- a/RpgRoller.Tests/GameServiceTests.cs +++ b/RpgRoller.Tests/GameServiceTests.cs @@ -1,482 +1,3 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using RpgRoller.Data; -using RpgRoller.Domain; -using RpgRoller.Services; - namespace RpgRoller.Tests; -public sealed class GameServiceTests -{ - [Fact] - public void Register_ValidatesRequiredFieldsAndDuplicates() - { - using var harness = CreateHarness(); - var service = harness.Service; - - var invalidUsername = service.Register(new RegisterCommand("", "Password123", "Display")); - var invalidDisplay = service.Register(new RegisterCommand("user", "Password123", "")); - var invalidPassword = service.Register(new RegisterCommand("user", "short", "Display")); - var valid = service.Register(new RegisterCommand("user", "Password123", "Display")); - var duplicate = service.Register(new RegisterCommand("user", "Password123", "Display 2")); - - Assert.False(invalidUsername.Succeeded); - Assert.False(invalidDisplay.Succeeded); - Assert.False(invalidPassword.Succeeded); - Assert.True(valid.Succeeded); - Assert.False(duplicate.Succeeded); - } - - [Fact] - public void Login_ValidatesCredentialsAndSessionLookup() - { - using var harness = CreateHarness(); - var service = harness.Service; - service.Register(new RegisterCommand("user", "Password123", "Display")); - - var invalidUser = service.Login(new LoginCommand("missing", "Password123")); - var invalidPassword = service.Login(new LoginCommand("user", "bad-password")); - var valid = service.Login(new LoginCommand("user", "Password123")); - - Assert.False(invalidUser.Succeeded); - Assert.False(invalidPassword.Succeeded); - Assert.True(valid.Succeeded); - - var sessionUser = service.GetUserBySession(valid.Value.SessionToken); - Assert.NotNull(sessionUser); - - service.Logout(valid.Value.SessionToken); - Assert.Null(service.GetUserBySession(valid.Value.SessionToken)); - } - - [Fact] - public void Login_RehashesPasswordWhenHasherRequestsIt() - { - var hasher = new RehashingPasswordHasher(); - using var harness = CreateHarness(hasher); - var service = harness.Service; - - service.Register(new RegisterCommand("user", "Password123", "Display")); - var login = service.Login(new LoginCommand("user", "Password123")); - - Assert.True(login.Succeeded); - Assert.Equal(2, hasher.HashCalls); - } - - [Fact] - public void CampaignAndCharacterOperations_CheckUnauthorizedAndNotFoundCases() - { - using var harness = CreateHarness(); - var service = harness.Service; - - var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignCommand("Name", "d6")); - Assert.False(unauthorizedCampaign.Succeeded); - - service.Register(new RegisterCommand("gm", "Password123", "GM")); - var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Name", "d6"))); - - var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignCommand("Name 2", "unknown")); - Assert.False(invalidRuleset.Succeeded); - - var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", Guid.NewGuid())); - Assert.False(noCampaignCharacter.Succeeded); - - var character = GetValue(service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", campaign.Id))); - - var missingCharacterActivate = service.ActivateCharacter(gmSession, Guid.NewGuid()); - Assert.False(missingCharacterActivate.Succeeded); - - var activateSuccess = service.ActivateCharacter(gmSession, character.Id); - Assert.True(activateSuccess.Succeeded); - - var currentCharacters = service.GetCurrentCampaignCharacters(gmSession); - Assert.True(currentCharacters.Succeeded); - Assert.Single(GetValue(currentCharacters)); - - var missingCampaignGet = service.GetCampaign(gmSession, Guid.NewGuid()); - Assert.False(missingCampaignGet.Succeeded); - } - - [Fact] - public void CharacterSkillAndRollOperations_CheckAuthorizationAndValidationBranches() - { - using var harness = CreateHarness(3, 4, 5, 6); - var service = harness.Service; - service.Register(new RegisterCommand("gm", "Password123", "GM")); - service.Register(new RegisterCommand("owner", "Password123", "Owner")); - service.Register(new RegisterCommand("other", "Password123", "Other")); - - var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; - var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; - var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; - - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "dnd5e"))); - var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Char", campaign.Id))); - - var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterCommand("Renamed", campaign.Id)); - Assert.False(noPermissionUpdate.Succeeded); - - var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("", campaign.Id)); - Assert.False(invalidCharacterName.Succeeded); - - var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("Renamed", Guid.NewGuid())); - Assert.False(missingTargetCampaign.Succeeded); - - var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("", "1d20")); - Assert.False(noSkillName.Succeeded); - - var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "5D+4")); - Assert.False(invalidExpression.Succeeded); - - var skill = GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "1d20+2"))); - - var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillCommand("X", "1d20")); - Assert.False(missingSkillUpdate.Succeeded); - - var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillCommand("X", "1d20")); - Assert.False(forbiddenSkillUpdate.Succeeded); - - var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillCommand("GM Edit", "2d6+1")); - Assert.True(gmSkillUpdate.Succeeded); - - var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillCommand("public")); - Assert.False(missingRoll.Succeeded); - - var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("hidden")); - Assert.False(invalidVisibility.Succeeded); - - var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillCommand("public")); - Assert.False(forbiddenRoll.Succeeded); - - var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("private")); - var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")); - Assert.True(privateRoll.Succeeded); - Assert.True(publicRoll.Succeeded); - - var ownerLog = service.GetCampaignLog(ownerSession, campaign.Id); - var gmLog = service.GetCampaignLog(gmSession, campaign.Id); - var outsiderLog = service.GetCampaignLog(otherSession, campaign.Id); - - Assert.Equal(2, GetValue(ownerLog).Count); - Assert.Equal(2, GetValue(gmLog).Count); - Assert.False(outsiderLog.Succeeded); - - var version = service.GetCampaignVersion(ownerSession, campaign.Id); - var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid()); - Assert.True(version.Succeeded); - Assert.False(missingVersion.Succeeded); - } - - [Fact] - public void CurrentCampaignCharacters_ReturnsNoActiveCharacterWhenUnset() - { - using var harness = CreateHarness(); - var service = harness.Service; - service.Register(new RegisterCommand("user", "Password123", "User")); - var sessionToken = GetValue(service.Login(new LoginCommand("user", "Password123"))).SessionToken; - - var result = service.GetCurrentCampaignCharacters(sessionToken); - Assert.False(result.Succeeded); - } - - [Fact] - public void GetCampaigns_ReturnsOwnedAndParticipatingCampaigns() - { - using var harness = CreateHarness(); - var service = harness.Service; - service.Register(new RegisterCommand("gm", "Password123", "GM")); - service.Register(new RegisterCommand("player", "Password123", "Player")); - var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; - var playerSession = GetValue(service.Login(new LoginCommand("player", "Password123"))).SessionToken; - - var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned", "d6"))); - _ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned 2", "d6"))); - _ = service.CreateCharacter(playerSession, new CreateCharacterCommand("Joiner", gmCampaign.Id)); - - var playerCampaigns = service.GetCampaigns(playerSession); - Assert.True(playerCampaigns.Succeeded); - var campaigns = GetValue(playerCampaigns); - Assert.Single(campaigns); - Assert.Equal(gmCampaign.Id, campaigns[0].Id); - } - - [Fact] - public void ServiceGuardAndPersistenceBranches_AreHandled() - { - using var harness = CreateHarness(2, 3, 4); - var service = harness.Service; - - var invalidCredentials = service.Login(new LoginCommand("", "")); - Assert.False(invalidCredentials.Succeeded); - - service.Logout("missing-session"); - - service.Register(new RegisterCommand("gm", "Password123", "GM")); - service.Register(new RegisterCommand("owner", "Password123", "Owner")); - service.Register(new RegisterCommand("other", "Password123", "Other")); - - var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; - var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; - var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; - - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6"))); - var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id))); - - Assert.False(service.GetMe(string.Empty).Succeeded); - Assert.False(service.CreateCampaign(gmSession, new CreateCampaignCommand("", "d6")).Succeeded); - Assert.False(service.GetCampaigns(string.Empty).Succeeded); - Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterCommand("", campaign.Id)).Succeeded); - Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterCommand("Name", campaign.Id)).Succeeded); - Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded); - Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded); - Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded); - Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); - Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded); - Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded); - Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillCommand("Stealth", "2D+1")).Succeeded); - Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded); - - using (var db = harness.CreateDbContext()) - { - var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER"); - ownerUser.ActiveCharacterId = Guid.NewGuid(); - db.SaveChanges(); - } - - using var staleMeHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); - var staleMeService = staleMeHarness.Service; - - var staleMe = GetValue(staleMeService.GetMe(ownerSession)); - Assert.Null(staleMe.ActiveCharacterId); - Assert.Null(staleMe.CurrentCampaignId); - - Assert.True(staleMeService.ActivateCharacter(ownerSession, ownerCharacter.Id).Succeeded); - var activeMe = GetValue(staleMeService.GetMe(ownerSession)); - Assert.Equal(ownerCharacter.Id, activeMe.ActiveCharacterId); - Assert.Equal(campaign.Id, activeMe.CurrentCampaignId); - - using (var db = harness.CreateDbContext()) - { - var staleOwner = db.Users.Single(u => u.UsernameNormalized == "OWNER"); - staleOwner.ActiveCharacterId = Guid.NewGuid(); - db.SaveChanges(); - } - - using var staleCurrentHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); - var staleCurrentService = staleCurrentHarness.Service; - - var staleCurrentCampaign = staleCurrentService.GetCurrentCampaignCharacters(ownerSession); - Assert.False(staleCurrentCampaign.Succeeded); - using (var db = harness.CreateDbContext()) - { - Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); - } - - var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1"))); - Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("", "2D+1")).Succeeded); - Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillCommand("Stealth", "2D+1")).Succeeded); - Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("Stealth", "bad")).Succeeded); - Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillCommand("public")).Succeeded); - - using (var db = harness.CreateDbContext()) - { - var mutableSkill = db.Skills.Single(s => s.Id == skill.Id); - mutableSkill.DiceRollDefinition = "bad"; - db.SaveChanges(); - } - - using var invalidExpressionHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); - Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")).Succeeded); - Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); - } - - [Fact] - public void GetCampaign_ForNonGm_ReturnsOnlyOwnedCharactersAndSkills() - { - using var harness = CreateHarness(); - var service = harness.Service; - - service.Register(new RegisterCommand("gm", "Password123", "GM")); - service.Register(new RegisterCommand("owner", "Password123", "Owner")); - service.Register(new RegisterCommand("other", "Password123", "Other")); - - var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; - var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; - var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; - - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6"))); - var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id))); - var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterCommand("Other Character", campaign.Id))); - - var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1"))); - _ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillCommand("Perception", "1D+2"))); - - var ownerView = GetValue(service.GetCampaign(ownerSession, campaign.Id)); - Assert.Single(ownerView.Characters); - Assert.Equal(ownerCharacter.Id, ownerView.Characters[0].Id); - Assert.Single(ownerView.Skills); - Assert.Equal(ownerSkill.Id, ownerView.Skills[0].Id); - } - - [Fact] - public void DiceRules_CoversParsingAndMappingBranches() - { - Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6")); - Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e")); - Assert.Null(DiceRules.TryParseRulesetId("unknown")); - - var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); - var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); - var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, ""); - var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); - var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); - var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); - var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); - var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1"); - - Assert.True(d6.Succeeded); - Assert.True(dnd.Succeeded); - Assert.False(emptyExpression.Succeeded); - Assert.False(badFormat.Succeeded); - Assert.False(tooManyDice.Succeeded); - Assert.False(tooManySides.Succeeded); - Assert.False(tooLargeModifier.Succeeded); - Assert.False(unknownRulesetExpression.Succeeded); - - Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6)); - Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); - Assert.Throws(() => DiceRules.ToRulesetId((RulesetKind)99)); - } - - [Fact] - public void RandomDiceRoller_ProducesValueWithinRange() - { - var roller = new RandomDiceRoller(); - for (var i = 0; i < 64; i += 1) - { - var value = roller.Roll(12); - Assert.InRange(value, 1, 12); - } - } - - private static ServiceHarness CreateHarness(params int[] rollValues) - { - return CreateHarness(new PasswordHasher(), rollValues); - } - - private static ServiceHarness CreateHarness(IPasswordHasher passwordHasher, params int[] rollValues) - { - var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-servicetests-{Guid.NewGuid():N}.db"); - return CreateHarnessFromPath(dbPath, passwordHasher, rollValues); - } - - private static ServiceHarness CreateHarnessFromPath(string dbPath, params int[] rollValues) - { - return CreateHarnessFromPath(dbPath, new PasswordHasher(), rollValues); - } - - private static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher passwordHasher, params int[] rollValues) - { - var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source={dbPath}") - .Options; - - using (var db = new RpgRollerDbContext(options)) - { - db.Database.EnsureCreated(); - } - - var factory = new SqliteDbContextFactory(dbPath); - var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues)); - return new ServiceHarness(service, factory, dbPath); - } - - private static T GetValue(ServiceResult result) - { - Assert.True(result.Succeeded); - Assert.NotNull(result.Value); - return result.Value!; - } - - private sealed class FixedDiceRoller : IDiceRoller - { - private readonly Queue m_Values; - - public FixedDiceRoller(IEnumerable values) - { - m_Values = new Queue(values); - } - - public int Roll(int sides) - { - var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1; - return Math.Clamp(next, 1, sides); - } - } - - private sealed class ServiceHarness : IDisposable - { - private readonly SqliteDbContextFactory m_Factory; - - public ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath) - { - Service = service; - m_Factory = factory; - DbPath = dbPath; - } - - public GameService Service { get; } - public string DbPath { get; } - - public void Dispose() - { - m_Factory.Dispose(); - } - - public RpgRollerDbContext CreateDbContext() - { - return m_Factory.CreateDbContext(); - } - } - - private sealed class SqliteDbContextFactory : IDbContextFactory, IDisposable - { - private readonly DbContextOptions m_Options; - - public SqliteDbContextFactory(string dbPath) - { - m_Options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source={dbPath}") - .Options; - } - - public RpgRollerDbContext CreateDbContext() - { - return new RpgRollerDbContext(m_Options); - } - - public void Dispose() - { - } - } - - private sealed class RehashingPasswordHasher : IPasswordHasher - { - public int HashCalls { get; private set; } - - public string HashPassword(UserAccount user, string password) - { - HashCalls += 1; - return $"hash:{HashCalls}:{password}"; - } - - public PasswordVerificationResult VerifyHashedPassword(UserAccount user, string hashedPassword, string providedPassword) - { - return providedPassword == "Password123" - ? PasswordVerificationResult.SuccessRehashNeeded - : PasswordVerificationResult.Failed; - } - } -} - +// Service-level tests were split by concern under RpgRoller.Tests/Services. diff --git a/RpgRoller.Tests/GlobalUsings.cs b/RpgRoller.Tests/GlobalUsings.cs new file mode 100644 index 0000000..3da012d --- /dev/null +++ b/RpgRoller.Tests/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using System.Net; +global using System.Net.Http; +global using System.Net.Http.Json; +global using Microsoft.AspNetCore.Mvc.Testing; +global using RpgRoller.Contracts; +global using RpgRoller.Domain; +global using RpgRoller.Services; diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs new file mode 100644 index 0000000..ad6a587 --- /dev/null +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using RpgRoller.Hosting; + +namespace RpgRoller.Tests; + +public sealed class HostingCoverageTests +{ + [Fact] + public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:RpgRoller"] = "Data Source=:memory:" + }) + .Build(); + + var environment = new TestWebHostEnvironment + { + ContentRootPath = Path.GetTempPath() + }; + + services.AddRpgRollerCore(configuration, environment); + + Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IGameService)); + Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller)); + } +} diff --git a/RpgRoller.Tests/Services/DiceRulesTests.cs b/RpgRoller.Tests/Services/DiceRulesTests.cs new file mode 100644 index 0000000..cc984c0 --- /dev/null +++ b/RpgRoller.Tests/Services/DiceRulesTests.cs @@ -0,0 +1,36 @@ +using RpgRoller.Services; + +namespace RpgRoller.Tests; + +public sealed class DiceRulesTests +{ + [Fact] + public void DiceRules_CoversParsingAndMappingBranches() + { + Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6")); + Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e")); + Assert.Null(DiceRules.TryParseRulesetId("unknown")); + + var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); + var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); + var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, ""); + var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); + var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); + var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); + var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); + var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1"); + + Assert.True(d6.Succeeded); + Assert.True(dnd.Succeeded); + Assert.False(emptyExpression.Succeeded); + Assert.False(badFormat.Succeeded); + Assert.False(tooManyDice.Succeeded); + Assert.False(tooManySides.Succeeded); + Assert.False(tooLargeModifier.Succeeded); + Assert.False(unknownRulesetExpression.Succeeded); + + Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6)); + Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); + Assert.Throws(() => DiceRules.ToRulesetId((RulesetKind)99)); + } +} diff --git a/RpgRoller.Tests/Services/RandomDiceRollerTests.cs b/RpgRoller.Tests/Services/RandomDiceRollerTests.cs new file mode 100644 index 0000000..ec4a192 --- /dev/null +++ b/RpgRoller.Tests/Services/RandomDiceRollerTests.cs @@ -0,0 +1,17 @@ +using RpgRoller.Services; + +namespace RpgRoller.Tests; + +public sealed class RandomDiceRollerTests +{ + [Fact] + public void RandomDiceRoller_ProducesValueWithinRange() + { + var roller = new RandomDiceRoller(); + for (var i = 0; i < 64; i += 1) + { + var value = roller.Roll(12); + Assert.InRange(value, 1, 12); + } + } +} diff --git a/RpgRoller.Tests/Services/ServiceAuthTests.cs b/RpgRoller.Tests/Services/ServiceAuthTests.cs new file mode 100644 index 0000000..39a9b7e --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceAuthTests.cs @@ -0,0 +1,59 @@ +namespace RpgRoller.Tests; + +public sealed class ServiceAuthTests +{ + [Fact] + public void Register_ValidatesRequiredFieldsAndDuplicates() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + var invalidUsername = service.Register(new RegisterCommand("", "Password123", "Display")); + var invalidDisplay = service.Register(new RegisterCommand("user", "Password123", "")); + var invalidPassword = service.Register(new RegisterCommand("user", "short", "Display")); + var valid = service.Register(new RegisterCommand("user", "Password123", "Display")); + var duplicate = service.Register(new RegisterCommand("user", "Password123", "Display 2")); + + Assert.False(invalidUsername.Succeeded); + Assert.False(invalidDisplay.Succeeded); + Assert.False(invalidPassword.Succeeded); + Assert.True(valid.Succeeded); + Assert.False(duplicate.Succeeded); + } + + [Fact] + public void Login_ValidatesCredentialsAndSessionLookup() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + service.Register(new RegisterCommand("user", "Password123", "Display")); + + var invalidUser = service.Login(new LoginCommand("missing", "Password123")); + var invalidPassword = service.Login(new LoginCommand("user", "bad-password")); + var valid = service.Login(new LoginCommand("user", "Password123")); + + Assert.False(invalidUser.Succeeded); + Assert.False(invalidPassword.Succeeded); + Assert.True(valid.Succeeded); + + var sessionUser = service.GetUserBySession(valid.Value.SessionToken); + Assert.NotNull(sessionUser); + + service.Logout(valid.Value.SessionToken); + Assert.Null(service.GetUserBySession(valid.Value.SessionToken)); + } + + [Fact] + public void Login_RehashesPasswordWhenHasherRequestsIt() + { + var hasher = new ServiceTestSupport.RehashingPasswordHasher(); + using var harness = ServiceTestSupport.CreateHarness(hasher); + var service = harness.Service; + + service.Register(new RegisterCommand("user", "Password123", "Display")); + var login = service.Login(new LoginCommand("user", "Password123")); + + Assert.True(login.Succeeded); + Assert.Equal(2, hasher.HashCalls); + } +} diff --git a/RpgRoller.Tests/Services/ServiceCampaignTests.cs b/RpgRoller.Tests/Services/ServiceCampaignTests.cs new file mode 100644 index 0000000..20096de --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceCampaignTests.cs @@ -0,0 +1,100 @@ +namespace RpgRoller.Tests; + +public sealed class ServiceCampaignTests +{ + [Fact] + public void CampaignAndCharacterOperations_CheckUnauthorizedAndNotFoundCases() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignCommand("Name", "d6")); + Assert.False(unauthorizedCampaign.Succeeded); + + service.Register(new RegisterCommand("gm", "Password123", "GM")); + var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Name", "d6"))); + + var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignCommand("Name 2", "unknown")); + Assert.False(invalidRuleset.Succeeded); + + var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", Guid.NewGuid())); + Assert.False(noCampaignCharacter.Succeeded); + + var character = ServiceTestSupport.GetValue(service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", campaign.Id))); + + var missingCharacterActivate = service.ActivateCharacter(gmSession, Guid.NewGuid()); + Assert.False(missingCharacterActivate.Succeeded); + + var activateSuccess = service.ActivateCharacter(gmSession, character.Id); + Assert.True(activateSuccess.Succeeded); + + var currentCharacters = service.GetCurrentCampaignCharacters(gmSession); + Assert.True(currentCharacters.Succeeded); + Assert.Single(ServiceTestSupport.GetValue(currentCharacters)); + + var missingCampaignGet = service.GetCampaign(gmSession, Guid.NewGuid()); + Assert.False(missingCampaignGet.Succeeded); + } + + [Fact] + public void CurrentCampaignCharacters_ReturnsNoActiveCharacterWhenUnset() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + service.Register(new RegisterCommand("user", "Password123", "User")); + var sessionToken = ServiceTestSupport.GetValue(service.Login(new LoginCommand("user", "Password123"))).SessionToken; + + var result = service.GetCurrentCampaignCharacters(sessionToken); + Assert.False(result.Succeeded); + } + + [Fact] + public void GetCampaigns_ReturnsOwnedAndParticipatingCampaigns() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("player", "Password123", "Player")); + var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var playerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("player", "Password123"))).SessionToken; + + var gmCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned", "d6"))); + _ = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned 2", "d6"))); + _ = service.CreateCharacter(playerSession, new CreateCharacterCommand("Joiner", gmCampaign.Id)); + + var playerCampaigns = service.GetCampaigns(playerSession); + Assert.True(playerCampaigns.Succeeded); + var campaigns = ServiceTestSupport.GetValue(playerCampaigns); + Assert.Single(campaigns); + Assert.Equal(gmCampaign.Id, campaigns[0].Id); + } + + [Fact] + public void GetCampaign_ForNonGm_ReturnsOnlyOwnedCharactersAndSkills() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("owner", "Password123", "Owner")); + service.Register(new RegisterCommand("other", "Password123", "Other")); + + var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; + var otherSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6"))); + var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id))); + var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, new CreateCharacterCommand("Other Character", campaign.Id))); + + var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1"))); + _ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillCommand("Perception", "1D+2"))); + + var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id)); + Assert.Single(ownerView.Characters); + Assert.Equal(ownerCharacter.Id, ownerView.Characters[0].Id); + Assert.Single(ownerView.Skills); + Assert.Equal(ownerSkill.Id, ownerView.Skills[0].Id); + } +} diff --git a/RpgRoller.Tests/Services/ServicePersistenceTests.cs b/RpgRoller.Tests/Services/ServicePersistenceTests.cs new file mode 100644 index 0000000..a223888 --- /dev/null +++ b/RpgRoller.Tests/Services/ServicePersistenceTests.cs @@ -0,0 +1,94 @@ +namespace RpgRoller.Tests; + +public sealed class ServicePersistenceTests +{ + [Fact] + public void ServiceGuardAndPersistenceBranches_AreHandled() + { + using var harness = ServiceTestSupport.CreateHarness(2, 3, 4); + var service = harness.Service; + + var invalidCredentials = service.Login(new LoginCommand("", "")); + Assert.False(invalidCredentials.Succeeded); + + service.Logout("missing-session"); + + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("owner", "Password123", "Owner")); + service.Register(new RegisterCommand("other", "Password123", "Other")); + + var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; + var otherSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6"))); + var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id))); + + Assert.False(service.GetMe(string.Empty).Succeeded); + Assert.False(service.CreateCampaign(gmSession, new CreateCampaignCommand("", "d6")).Succeeded); + Assert.False(service.GetCampaigns(string.Empty).Succeeded); + Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterCommand("", campaign.Id)).Succeeded); + Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterCommand("Name", campaign.Id)).Succeeded); + Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded); + Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded); + Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded); + Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); + Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded); + Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded); + Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillCommand("Stealth", "2D+1")).Succeeded); + Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded); + + using (var db = harness.CreateDbContext()) + { + var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER"); + ownerUser.ActiveCharacterId = Guid.NewGuid(); + db.SaveChanges(); + } + + using var staleMeHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4); + var staleMeService = staleMeHarness.Service; + + var staleMe = ServiceTestSupport.GetValue(staleMeService.GetMe(ownerSession)); + Assert.Null(staleMe.ActiveCharacterId); + Assert.Null(staleMe.CurrentCampaignId); + + Assert.True(staleMeService.ActivateCharacter(ownerSession, ownerCharacter.Id).Succeeded); + var activeMe = ServiceTestSupport.GetValue(staleMeService.GetMe(ownerSession)); + Assert.Equal(ownerCharacter.Id, activeMe.ActiveCharacterId); + Assert.Equal(campaign.Id, activeMe.CurrentCampaignId); + + using (var db = harness.CreateDbContext()) + { + var staleOwner = db.Users.Single(u => u.UsernameNormalized == "OWNER"); + staleOwner.ActiveCharacterId = Guid.NewGuid(); + db.SaveChanges(); + } + + using var staleCurrentHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4); + var staleCurrentService = staleCurrentHarness.Service; + + var staleCurrentCampaign = staleCurrentService.GetCurrentCampaignCharacters(ownerSession); + Assert.False(staleCurrentCampaign.Succeeded); + using (var db = harness.CreateDbContext()) + { + Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); + } + + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1"))); + Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("", "2D+1")).Succeeded); + Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillCommand("Stealth", "2D+1")).Succeeded); + Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("Stealth", "bad")).Succeeded); + Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillCommand("public")).Succeeded); + + using (var db = harness.CreateDbContext()) + { + var mutableSkill = db.Skills.Single(s => s.Id == skill.Id); + mutableSkill.DiceRollDefinition = "bad"; + db.SaveChanges(); + } + + using var invalidExpressionHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4); + Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")).Succeeded); + Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); + } +} diff --git a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs new file mode 100644 index 0000000..2e2f2f7 --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs @@ -0,0 +1,74 @@ +namespace RpgRoller.Tests; + +public sealed class ServiceSkillRollTests +{ + [Fact] + public void CharacterSkillAndRollOperations_CheckAuthorizationAndValidationBranches() + { + using var harness = ServiceTestSupport.CreateHarness(3, 4, 5, 6); + var service = harness.Service; + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("owner", "Password123", "Owner")); + service.Register(new RegisterCommand("other", "Password123", "Other")); + + var gmSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; + var otherSession = ServiceTestSupport.GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "dnd5e"))); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Char", campaign.Id))); + + var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterCommand("Renamed", campaign.Id)); + Assert.False(noPermissionUpdate.Succeeded); + + var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("", campaign.Id)); + Assert.False(invalidCharacterName.Succeeded); + + var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("Renamed", Guid.NewGuid())); + Assert.False(missingTargetCampaign.Succeeded); + + var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("", "1d20")); + Assert.False(noSkillName.Succeeded); + + var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "5D+4")); + Assert.False(invalidExpression.Succeeded); + + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "1d20+2"))); + + var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillCommand("X", "1d20")); + Assert.False(missingSkillUpdate.Succeeded); + + var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillCommand("X", "1d20")); + Assert.False(forbiddenSkillUpdate.Succeeded); + + var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillCommand("GM Edit", "2d6+1")); + Assert.True(gmSkillUpdate.Succeeded); + + var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillCommand("public")); + Assert.False(missingRoll.Succeeded); + + var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("hidden")); + Assert.False(invalidVisibility.Succeeded); + + var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillCommand("public")); + Assert.False(forbiddenRoll.Succeeded); + + var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("private")); + var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")); + Assert.True(privateRoll.Succeeded); + Assert.True(publicRoll.Succeeded); + + var ownerLog = service.GetCampaignLog(ownerSession, campaign.Id); + var gmLog = service.GetCampaignLog(gmSession, campaign.Id); + var outsiderLog = service.GetCampaignLog(otherSession, campaign.Id); + + Assert.Equal(2, ServiceTestSupport.GetValue(ownerLog).Count); + Assert.Equal(2, ServiceTestSupport.GetValue(gmLog).Count); + Assert.False(outsiderLog.Succeeded); + + var version = service.GetCampaignVersion(ownerSession, campaign.Id); + var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid()); + Assert.True(version.Succeeded); + Assert.False(missingVersion.Succeeded); + } +} diff --git a/RpgRoller.Tests/Support/ApiTestBase.cs b/RpgRoller.Tests/Support/ApiTestBase.cs new file mode 100644 index 0000000..9ec4293 --- /dev/null +++ b/RpgRoller.Tests/Support/ApiTestBase.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using RpgRoller.Contracts; +using RpgRoller.Data; +using RpgRoller.Services; + +namespace RpgRoller.Tests; + +public abstract class ApiTestBase : IClassFixture> +{ + private readonly WebApplicationFactory m_BaseFactory; + + protected ApiTestBase(WebApplicationFactory factory) + { + m_BaseFactory = factory; + } + + protected WebApplicationFactory CreateFactory(params int[] rollValues) + { + return m_BaseFactory.WithWebHostBuilder(builder => + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(new FixedDiceRoller(rollValues)); + + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + + var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); + services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); + })); + } + + protected static async Task RegisterAsync(HttpClient client, string username, string password, string displayName) + { + return await PostAsync( + client, + "/api/auth/register", + new RegisterRequest(username, password, displayName)); + } + + protected static async Task LoginAsync(HttpClient client, string username, string password) + { + var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(username, password)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + protected static async Task PostAsync(HttpClient client, string uri, TRequest payload) + { + var response = await client.PostAsJsonAsync(uri, payload); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result; + } + + protected static async Task PutAsync(HttpClient client, string uri, TRequest payload) + { + var response = await client.PutAsJsonAsync(uri, payload); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result; + } + + protected static async Task GetAsync(HttpClient client, string uri) + { + var response = await client.GetAsync(uri); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result; + } + + private sealed class FixedDiceRoller : IDiceRoller + { + private readonly Queue m_Values; + + public FixedDiceRoller(IEnumerable values) + { + m_Values = new Queue(values); + } + + public int Roll(int sides) + { + var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1; + return Math.Clamp(next, 1, sides); + } + } +} diff --git a/RpgRoller.Tests/Support/ServiceTestSupport.cs b/RpgRoller.Tests/Support/ServiceTestSupport.cs new file mode 100644 index 0000000..d7b0166 --- /dev/null +++ b/RpgRoller.Tests/Support/ServiceTestSupport.cs @@ -0,0 +1,129 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using RpgRoller.Data; +using RpgRoller.Domain; +using RpgRoller.Services; + +namespace RpgRoller.Tests; + +internal static class ServiceTestSupport +{ + internal static ServiceHarness CreateHarness(params int[] rollValues) + { + return CreateHarness(new PasswordHasher(), rollValues); + } + + internal static ServiceHarness CreateHarness(IPasswordHasher passwordHasher, params int[] rollValues) + { + var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-servicetests-{Guid.NewGuid():N}.db"); + return CreateHarnessFromPath(dbPath, passwordHasher, rollValues); + } + + internal static ServiceHarness CreateHarnessFromPath(string dbPath, params int[] rollValues) + { + return CreateHarnessFromPath(dbPath, new PasswordHasher(), rollValues); + } + + internal static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher passwordHasher, params int[] rollValues) + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}") + .Options; + + using (var db = new RpgRollerDbContext(options)) + { + db.Database.EnsureCreated(); + } + + var factory = new SqliteDbContextFactory(dbPath); + var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues)); + return new ServiceHarness(service, factory, dbPath); + } + + internal static T GetValue(ServiceResult result) + { + Assert.True(result.Succeeded); + Assert.NotNull(result.Value); + return result.Value!; + } + + internal sealed class ServiceHarness : IDisposable + { + private readonly SqliteDbContextFactory m_Factory; + + internal ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath) + { + Service = service; + m_Factory = factory; + DbPath = dbPath; + } + + public GameService Service { get; } + public string DbPath { get; } + + public void Dispose() + { + m_Factory.Dispose(); + } + + public RpgRollerDbContext CreateDbContext() + { + return m_Factory.CreateDbContext(); + } + } + + internal sealed class RehashingPasswordHasher : IPasswordHasher + { + public int HashCalls { get; private set; } + + public string HashPassword(UserAccount user, string password) + { + HashCalls += 1; + return $"hash:{HashCalls}:{password}"; + } + + public PasswordVerificationResult VerifyHashedPassword(UserAccount user, string hashedPassword, string providedPassword) + { + return providedPassword == "Password123" + ? PasswordVerificationResult.SuccessRehashNeeded + : PasswordVerificationResult.Failed; + } + } + + private sealed class FixedDiceRoller : IDiceRoller + { + private readonly Queue m_Values; + + public FixedDiceRoller(IEnumerable values) + { + m_Values = new Queue(values); + } + + public int Roll(int sides) + { + var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1; + return Math.Clamp(next, 1, sides); + } + } + + internal sealed class SqliteDbContextFactory : IDbContextFactory, IDisposable + { + private readonly DbContextOptions m_Options; + + public SqliteDbContextFactory(string dbPath) + { + m_Options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}") + .Options; + } + + public RpgRollerDbContext CreateDbContext() + { + return new RpgRollerDbContext(m_Options); + } + + public void Dispose() + { + } + } +} diff --git a/RpgRoller.Tests/Support/TestWebHostEnvironment.cs b/RpgRoller.Tests/Support/TestWebHostEnvironment.cs new file mode 100644 index 0000000..ee1e6a2 --- /dev/null +++ b/RpgRoller.Tests/Support/TestWebHostEnvironment.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; + +namespace RpgRoller.Tests; + +internal sealed class TestWebHostEnvironment : IWebHostEnvironment +{ + public string ApplicationName { get; set; } = "RpgRoller.Tests"; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = string.Empty; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = string.Empty; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); +} diff --git a/RpgRoller.Tests/UnitTest1.cs b/RpgRoller.Tests/UnitTest1.cs index 33a8bc0..093d131 100644 --- a/RpgRoller.Tests/UnitTest1.cs +++ b/RpgRoller.Tests/UnitTest1.cs @@ -1,284 +1,3 @@ -using System.Net; -using System.Net.Http.Json; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using RpgRoller.Contracts; -using RpgRoller.Data; -using RpgRoller.Services; - namespace RpgRoller.Tests; -public sealed class UnitTest1 : IClassFixture> -{ - private readonly WebApplicationFactory m_BaseFactory; - - public UnitTest1(WebApplicationFactory factory) - { - m_BaseFactory = factory; - } - - [Fact] - public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard() - { - using var factory = CreateFactory(4, 4, 4); - using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - - var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice"); - Assert.Equal("alice", registerResult.Username); - - var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2")); - Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); - - var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123")); - Assert.Equal(HttpStatusCode.OK, loginResult.StatusCode); - - var me = await GetAsync(client, "/api/me"); - Assert.Equal(registerResult.Id, me.User.Id); - Assert.Null(me.ActiveCharacterId); - Assert.Null(me.CurrentCampaignId); - - var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password")); - Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode); - } - - [Fact] - public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation() - { - using var factory = CreateFactory(6, 6, 6); - using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - - await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); - await LoginAsync(gmClient, "gm", "Password123"); - - var campaign = await PostAsync( - gmClient, - "/api/campaigns", - new CreateCampaignRequest("Alpha Campaign", "dnd5e")); - - var gmCharacter = await PostAsync( - gmClient, - "/api/characters", - new CreateCharacterRequest("Arin", campaign.Id)); - - 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 CreateSkillRequest("Arcana", "2d12+2")); - Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); - - var updatedSkill = await PutAsync( - gmClient, - $"/api/skills/{createdSkill.Id}", - new UpdateSkillRequest("Arcana Mastery", "2d12+3")); - Assert.Equal("Arcana Mastery", updatedSkill.Name); - Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition); - - var invalidSkill = await gmClient.PostAsJsonAsync( - $"/api/characters/{gmCharacter.Id}/skills", - new CreateSkillRequest("Broken", "5D+4")); - 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.Single(details.Skills); - - var currentCampaignCharacters = await GetAsync>(gmClient, "/api/characters/current-campaign"); - Assert.Single(currentCampaignCharacters); - Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); - - var otherCampaign = await PostAsync( - gmClient, - "/api/campaigns", - new CreateCampaignRequest("Beta Campaign", "d6")); - - var updatedCharacter = await PutAsync( - gmClient, - $"/api/characters/{gmCharacter.Id}", - new UpdateCharacterRequest("Arin Updated", otherCampaign.Id)); - - Assert.Equal("Arin Updated", updatedCharacter.Name); - Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); - } - - [Fact] - public async Task RollVisibilityAndAuthorization_AreEnforced() - { - using var factory = CreateFactory(4, 3, 5, 2, 6); - using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - using var playerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - using var observerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - using var outsiderClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - - await RegisterAsync(gmClient, "gm", "Password123", "GM"); - await LoginAsync(gmClient, "gm", "Password123"); - var campaign = await PostAsync( - gmClient, - "/api/campaigns", - new CreateCampaignRequest("Main", "d6")); - - await RegisterAsync(playerClient, "player", "Password123", "Player"); - await LoginAsync(playerClient, "player", "Password123"); - var playerCharacter = await PostAsync( - playerClient, - "/api/characters", - new CreateCharacterRequest("Rogue", campaign.Id)); - - var skill = await PostAsync( - playerClient, - $"/api/characters/{playerCharacter.Id}/skills", - new CreateSkillRequest("Stealth", "2D+1")); - - await RegisterAsync(observerClient, "observer", "Password123", "Observer"); - await LoginAsync(observerClient, "observer", "Password123"); - await PostAsync( - observerClient, - "/api/characters", - new CreateCharacterRequest("Watcher", campaign.Id)); - - var privateRoll = await PostAsync( - playerClient, - $"/api/skills/{skill.Id}/roll", - new RollSkillRequest("private")); - var publicRoll = await PostAsync( - playerClient, - $"/api/skills/{skill.Id}/roll", - new RollSkillRequest("public")); - - Assert.Equal("private", privateRoll.Visibility); - Assert.Equal("public", publicRoll.Visibility); - - var gmLog = await GetAsync>(gmClient, $"/api/campaigns/{campaign.Id}/log"); - Assert.Equal(2, gmLog.Count); - - var playerLog = await GetAsync>(playerClient, $"/api/campaigns/{campaign.Id}/log"); - Assert.Equal(2, playerLog.Count); - - var observerLog = await GetAsync>(observerClient, $"/api/campaigns/{campaign.Id}/log"); - Assert.Single(observerLog); - Assert.Equal("public", observerLog[0].Visibility); - - await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider"); - await LoginAsync(outsiderClient, "outsider", "Password123"); - - var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}"); - Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode); - - var invalidVisibility = await playerClient.PostAsJsonAsync( - $"/api/skills/{skill.Id}/roll", - new RollSkillRequest("hidden")); - Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode); - - using var anonymousClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync( - "/api/campaigns", - new CreateCampaignRequest("Nope", "d6")); - Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode); - - var invalidSessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns"); - invalidSessionRequest.Headers.Add("Cookie", "rpgroller_session=invalid-token"); - var unauthorizedWithInvalidSession = await anonymousClient.SendAsync(invalidSessionRequest); - Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedWithInvalidSession.StatusCode); - } - - [Fact] - public async Task RulesetAndSseEndpoints_ReturnExpectedResponses() - { - using var factory = CreateFactory(2, 2, 2); - using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - - var rulesets = await GetAsync>(client, "/api/rulesets"); - Assert.Equal(2, rulesets.Count); - - await RegisterAsync(client, "sse", "Password123", "Sse User"); - await LoginAsync(client, "sse", "Password123"); - - var campaign = await PostAsync( - client, - "/api/campaigns", - new CreateCampaignRequest("SSE", "d6")); - - var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead); - Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode); - Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType); - } - - private WebApplicationFactory CreateFactory(params int[] rollValues) - { - return m_BaseFactory.WithWebHostBuilder(builder => - builder.ConfigureServices(services => - { - services.RemoveAll(); - services.AddSingleton(new FixedDiceRoller(rollValues)); - - services.RemoveAll>(); - services.RemoveAll>(); - services.RemoveAll(); - - var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); - services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); - })); - } - - private static async Task RegisterAsync(HttpClient client, string username, string password, string displayName) - { - return await PostAsync( - client, - "/api/auth/register", - new RegisterRequest(username, password, displayName)); - } - - private static async Task LoginAsync(HttpClient client, string username, string password) - { - var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest(username, password)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - private static async Task PostAsync(HttpClient client, string uri, TRequest payload) - { - var response = await client.PostAsJsonAsync(uri, payload); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(result); - return result; - } - - private static async Task PutAsync(HttpClient client, string uri, TRequest payload) - { - var response = await client.PutAsJsonAsync(uri, payload); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(result); - return result; - } - - private static async Task GetAsync(HttpClient client, string uri) - { - var response = await client.GetAsync(uri); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(result); - return result; - } - - private sealed class FixedDiceRoller : IDiceRoller - { - private readonly Queue m_Values; - - public FixedDiceRoller(IEnumerable values) - { - m_Values = new Queue(values); - } - - public int Roll(int sides) - { - var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1; - return Math.Clamp(next, 1, sides); - } - } -} +// Integration API tests were split by concern under RpgRoller.Tests/Api. diff --git a/TECH.md b/TECH.md index 43028f3..91bbf5d 100644 --- a/TECH.md +++ b/TECH.md @@ -7,6 +7,7 @@ - Frontend source: `RpgRoller/frontend` (TypeScript) - Frontend module split: `RpgRoller/frontend/app/*` (dom/state/loaders/render/events/actions) - Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests) +- Test file split: concern-based API tests (`RpgRoller.Tests/Api/*`), service tests (`RpgRoller.Tests/Services/*`), and shared helpers (`RpgRoller.Tests/Support/*`) - Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService` - OpenAPI source: `openapi/RpgRoller.json` - Generated client source: `RpgRoller/frontend/generated/api-client.ts`