Reorganize tests by API and service concerns
This commit is contained in:
35
RpgRoller.Tests/Api/AuthApiTests.cs
Normal file
35
RpgRoller.Tests/Api/AuthApiTests.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class AuthApiTests : ApiTestBase
|
||||
{
|
||||
public AuthApiTests(WebApplicationFactory<Program> 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<MeResponse>(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);
|
||||
}
|
||||
}
|
||||
74
RpgRoller.Tests/Api/CampaignApiTests.cs
Normal file
74
RpgRoller.Tests/Api/CampaignApiTests.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class CampaignApiTests : ApiTestBase
|
||||
{
|
||||
public CampaignApiTests(WebApplicationFactory<Program> 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<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Alpha Campaign", "dnd5e"));
|
||||
|
||||
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
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<CreateSkillRequest, SkillSummary>(
|
||||
gmClient,
|
||||
$"/api/characters/{gmCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Arcana", "2d12+2"));
|
||||
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
||||
|
||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
|
||||
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<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
Assert.Equal(campaign.Id, details.Id);
|
||||
Assert.Single(details.Characters);
|
||||
Assert.Single(details.Skills);
|
||||
|
||||
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters/current-campaign");
|
||||
Assert.Single(currentCampaignCharacters);
|
||||
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
||||
|
||||
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Beta Campaign", "d6"));
|
||||
|
||||
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(
|
||||
gmClient,
|
||||
$"/api/characters/{gmCharacter.Id}",
|
||||
new UpdateCharacterRequest("Arin Updated", otherCampaign.Id));
|
||||
|
||||
Assert.Equal("Arin Updated", updatedCharacter.Name);
|
||||
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
|
||||
}
|
||||
}
|
||||
91
RpgRoller.Tests/Api/RollVisibilityApiTests.cs
Normal file
91
RpgRoller.Tests/Api/RollVisibilityApiTests.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class RollVisibilityApiTests : ApiTestBase
|
||||
{
|
||||
public RollVisibilityApiTests(WebApplicationFactory<Program> 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<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Main", "d6"));
|
||||
|
||||
await RegisterAsync(playerClient, "player", "Password123", "Player");
|
||||
await LoginAsync(playerClient, "player", "Password123");
|
||||
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
playerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Rogue", campaign.Id));
|
||||
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
playerClient,
|
||||
$"/api/characters/{playerCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Stealth", "2D+1"));
|
||||
|
||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||
await LoginAsync(observerClient, "observer", "Password123");
|
||||
await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
observerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Watcher", campaign.Id));
|
||||
|
||||
var privateRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("private"));
|
||||
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("public"));
|
||||
|
||||
Assert.Equal("private", privateRoll.Visibility);
|
||||
Assert.Equal("public", publicRoll.Visibility);
|
||||
|
||||
var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, gmLog.Count);
|
||||
|
||||
var playerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(playerClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, playerLog.Count);
|
||||
|
||||
var observerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(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);
|
||||
}
|
||||
}
|
||||
33
RpgRoller.Tests/Api/SystemApiTests.cs
Normal file
33
RpgRoller.Tests/Api/SystemApiTests.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class SystemApiTests : ApiTestBase
|
||||
{
|
||||
public SystemApiTests(WebApplicationFactory<Program> 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<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
|
||||
Assert.Equal(2, rulesets.Count);
|
||||
|
||||
await RegisterAsync(client, "sse", "Password123", "Sse User");
|
||||
await LoginAsync(client, "sse", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string?>
|
||||
{
|
||||
["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<TargetInvocationException>(() => method!.Invoke(null, [context]));
|
||||
Assert.IsType<InvalidOperationException>(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ArgumentOutOfRangeException>(() => 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<UserAccount>(), rollValues);
|
||||
}
|
||||
|
||||
private static ServiceHarness CreateHarness(IPasswordHasher<UserAccount> 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<UserAccount>(), rollValues);
|
||||
}
|
||||
|
||||
private static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||
.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<T>(ServiceResult<T> result)
|
||||
{
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.NotNull(result.Value);
|
||||
return result.Value!;
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
{
|
||||
private readonly Queue<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new Queue<int>(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<RpgRollerDbContext>, IDisposable
|
||||
{
|
||||
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
|
||||
|
||||
public SqliteDbContextFactory(string dbPath)
|
||||
{
|
||||
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options;
|
||||
}
|
||||
|
||||
public RpgRollerDbContext CreateDbContext()
|
||||
{
|
||||
return new RpgRollerDbContext(m_Options);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RehashingPasswordHasher : IPasswordHasher<UserAccount>
|
||||
{
|
||||
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.
|
||||
|
||||
7
RpgRoller.Tests/GlobalUsings.cs
Normal file
7
RpgRoller.Tests/GlobalUsings.cs
Normal file
@@ -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;
|
||||
30
RpgRoller.Tests/HostingCoverageTests.cs
Normal file
30
RpgRoller.Tests/HostingCoverageTests.cs
Normal file
@@ -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<string, string?>
|
||||
{
|
||||
["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));
|
||||
}
|
||||
}
|
||||
36
RpgRoller.Tests/Services/DiceRulesTests.cs
Normal file
36
RpgRoller.Tests/Services/DiceRulesTests.cs
Normal file
@@ -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<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
|
||||
}
|
||||
}
|
||||
17
RpgRoller.Tests/Services/RandomDiceRollerTests.cs
Normal file
17
RpgRoller.Tests/Services/RandomDiceRollerTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
RpgRoller.Tests/Services/ServiceAuthTests.cs
Normal file
59
RpgRoller.Tests/Services/ServiceAuthTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
100
RpgRoller.Tests/Services/ServiceCampaignTests.cs
Normal file
100
RpgRoller.Tests/Services/ServiceCampaignTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
94
RpgRoller.Tests/Services/ServicePersistenceTests.cs
Normal file
94
RpgRoller.Tests/Services/ServicePersistenceTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
74
RpgRoller.Tests/Services/ServiceSkillRollTests.cs
Normal file
74
RpgRoller.Tests/Services/ServiceSkillRollTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
95
RpgRoller.Tests/Support/ApiTestBase.cs
Normal file
95
RpgRoller.Tests/Support/ApiTestBase.cs
Normal file
@@ -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<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> m_BaseFactory;
|
||||
|
||||
protected ApiTestBase(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
m_BaseFactory = factory;
|
||||
}
|
||||
|
||||
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
|
||||
{
|
||||
return m_BaseFactory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IDiceRoller>();
|
||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
||||
|
||||
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
||||
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
|
||||
services.RemoveAll<RpgRollerDbContext>();
|
||||
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
||||
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||
}));
|
||||
}
|
||||
|
||||
protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
|
||||
{
|
||||
return await PostAsync<RegisterRequest, UserSummary>(
|
||||
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<TResponse> PostAsync<TRequest, TResponse>(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<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
protected static async Task<TResponse> PutAsync<TRequest, TResponse>(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<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
protected static async Task<TResponse> GetAsync<TResponse>(HttpClient client, string uri)
|
||||
{
|
||||
var response = await client.GetAsync(uri);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
{
|
||||
private readonly Queue<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new Queue<int>(values);
|
||||
}
|
||||
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
RpgRoller.Tests/Support/ServiceTestSupport.cs
Normal file
129
RpgRoller.Tests/Support/ServiceTestSupport.cs
Normal file
@@ -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<UserAccount>(), rollValues);
|
||||
}
|
||||
|
||||
internal static ServiceHarness CreateHarness(IPasswordHasher<UserAccount> 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<UserAccount>(), rollValues);
|
||||
}
|
||||
|
||||
internal static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||
.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<T>(ServiceResult<T> 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<UserAccount>
|
||||
{
|
||||
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<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new Queue<int>(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<RpgRollerDbContext>, IDisposable
|
||||
{
|
||||
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
|
||||
|
||||
public SqliteDbContextFactory(string dbPath)
|
||||
{
|
||||
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options;
|
||||
}
|
||||
|
||||
public RpgRollerDbContext CreateDbContext()
|
||||
{
|
||||
return new RpgRollerDbContext(m_Options);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
14
RpgRoller.Tests/Support/TestWebHostEnvironment.cs
Normal file
14
RpgRoller.Tests/Support/TestWebHostEnvironment.cs
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> m_BaseFactory;
|
||||
|
||||
public UnitTest1(WebApplicationFactory<Program> 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<MeResponse>(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<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Alpha Campaign", "dnd5e"));
|
||||
|
||||
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
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<CreateSkillRequest, SkillSummary>(
|
||||
gmClient,
|
||||
$"/api/characters/{gmCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Arcana", "2d12+2"));
|
||||
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
||||
|
||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
|
||||
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<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
Assert.Equal(campaign.Id, details.Id);
|
||||
Assert.Single(details.Characters);
|
||||
Assert.Single(details.Skills);
|
||||
|
||||
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters/current-campaign");
|
||||
Assert.Single(currentCampaignCharacters);
|
||||
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
||||
|
||||
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Beta Campaign", "d6"));
|
||||
|
||||
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(
|
||||
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<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Main", "d6"));
|
||||
|
||||
await RegisterAsync(playerClient, "player", "Password123", "Player");
|
||||
await LoginAsync(playerClient, "player", "Password123");
|
||||
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
playerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Rogue", campaign.Id));
|
||||
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
playerClient,
|
||||
$"/api/characters/{playerCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Stealth", "2D+1"));
|
||||
|
||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||
await LoginAsync(observerClient, "observer", "Password123");
|
||||
await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
observerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Watcher", campaign.Id));
|
||||
|
||||
var privateRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("private"));
|
||||
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("public"));
|
||||
|
||||
Assert.Equal("private", privateRoll.Visibility);
|
||||
Assert.Equal("public", publicRoll.Visibility);
|
||||
|
||||
var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, gmLog.Count);
|
||||
|
||||
var playerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(playerClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, playerLog.Count);
|
||||
|
||||
var observerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(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<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
|
||||
Assert.Equal(2, rulesets.Count);
|
||||
|
||||
await RegisterAsync(client, "sse", "Password123", "Sse User");
|
||||
await LoginAsync(client, "sse", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
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<Program> CreateFactory(params int[] rollValues)
|
||||
{
|
||||
return m_BaseFactory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IDiceRoller>();
|
||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
||||
|
||||
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
||||
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
|
||||
services.RemoveAll<RpgRollerDbContext>();
|
||||
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
||||
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
|
||||
{
|
||||
return await PostAsync<RegisterRequest, UserSummary>(
|
||||
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<TResponse> PostAsync<TRequest, TResponse>(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<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<TResponse> PutAsync<TRequest, TResponse>(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<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<TResponse> GetAsync<TResponse>(HttpClient client, string uri)
|
||||
{
|
||||
var response = await client.GetAsync(uri);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>();
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
{
|
||||
private readonly Queue<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new Queue<int>(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.
|
||||
|
||||
Reference in New Issue
Block a user