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; } } }