Implement core backend domain and API workflows

This commit is contained in:
2026-02-24 22:13:34 +01:00
parent cd87d7378d
commit e54b9d2ce8
13 changed files with 1769 additions and 63 deletions

View File

@@ -0,0 +1,247 @@
using Microsoft.AspNetCore.Identity;
using RpgRoller.Contracts;
using RpgRoller.Domain;
using RpgRoller.Services;
namespace RpgRoller.Tests;
public sealed class GameServiceTests
{
[Fact]
public void Register_ValidatesRequiredFieldsAndDuplicates()
{
var service = CreateService();
var invalidUsername = service.Register(new RegisterRequest("", "Password123", "Display"));
var invalidDisplay = service.Register(new RegisterRequest("user", "Password123", ""));
var invalidPassword = service.Register(new RegisterRequest("user", "short", "Display"));
var valid = service.Register(new RegisterRequest("user", "Password123", "Display"));
var duplicate = service.Register(new RegisterRequest("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()
{
var service = CreateService();
service.Register(new RegisterRequest("user", "Password123", "Display"));
var invalidUser = service.Login(new LoginRequest("missing", "Password123"));
var invalidPassword = service.Login(new LoginRequest("user", "bad-password"));
var valid = service.Login(new LoginRequest("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 CampaignAndCharacterOperations_CheckUnauthorizedAndNotFoundCases()
{
var service = CreateService();
var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignRequest("Name", "d6"));
Assert.False(unauthorizedCampaign.Succeeded);
service.Register(new RegisterRequest("gm", "Password123", "GM"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Name", "d6")));
var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignRequest("Name 2", "unknown"));
Assert.False(invalidRuleset.Succeeded);
var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterRequest("Hero", Guid.NewGuid()));
Assert.False(noCampaignCharacter.Succeeded);
var character = GetValue(service.CreateCharacter(gmSession, new CreateCharacterRequest("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()
{
var service = CreateService(3, 4, 5, 6);
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "dnd5e")));
var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Char", campaign.Id)));
var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterRequest("Renamed", campaign.Id));
Assert.False(noPermissionUpdate.Succeeded);
var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("", campaign.Id));
Assert.False(invalidCharacterName.Succeeded);
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("Renamed", Guid.NewGuid()));
Assert.False(missingTargetCampaign.Succeeded);
var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("", "1d20"));
Assert.False(noSkillName.Succeeded);
var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "5D+4"));
Assert.False(invalidExpression.Succeeded);
var skill = GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "1d20+2")));
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillRequest("X", "1d20"));
Assert.False(missingSkillUpdate.Succeeded);
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillRequest("X", "1d20"));
Assert.False(forbiddenSkillUpdate.Succeeded);
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillRequest("GM Edit", "2d6+1"));
Assert.True(gmSkillUpdate.Succeeded);
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillRequest("public"));
Assert.False(missingRoll.Succeeded);
var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("hidden"));
Assert.False(invalidVisibility.Succeeded);
var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillRequest("public"));
Assert.False(forbiddenRoll.Succeeded);
var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("private"));
var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("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()
{
var service = CreateService();
service.Register(new RegisterRequest("user", "Password123", "User"));
var sessionToken = GetValue(service.Login(new LoginRequest("user", "Password123"))).SessionToken;
var result = service.GetCurrentCampaignCharacters(sessionToken);
Assert.False(result.Succeeded);
}
[Fact]
public void GetCampaigns_ReturnsOwnedAndParticipatingCampaigns()
{
var service = CreateService();
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("player", "Password123", "Player"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var playerSession = GetValue(service.Login(new LoginRequest("player", "Password123"))).SessionToken;
var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned", "d6")));
_ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned 2", "d6")));
_ = service.CreateCharacter(playerSession, new CreateCharacterRequest("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 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 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");
Assert.True(d6.Succeeded);
Assert.True(dnd.Succeeded);
Assert.False(badFormat.Succeeded);
Assert.False(tooManyDice.Succeeded);
Assert.False(tooManySides.Succeeded);
Assert.False(tooLargeModifier.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 GameService CreateService(params int[] rollValues)
{
return new GameService(new PasswordHasher<UserAccount>(), new FixedDiceRoller(rollValues));
}
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);
}
}
}

View File

@@ -10,80 +10,254 @@ namespace RpgRoller.Tests;
public sealed class UnitTest1 : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient m_Client;
private readonly WebApplicationFactory<Program> m_BaseFactory;
public UnitTest1(WebApplicationFactory<Program> factory)
{
m_Client = factory.WithWebHostBuilder(builder =>
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 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);
}
[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(7));
})).CreateClient();
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
}));
}
[Fact]
public async Task GetHealth_ReturnsOkPayload()
private static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
{
var response = await m_Client.GetAsync("/api/health");
var payload = await response.Content.ReadFromJsonAsync<HealthResponse>();
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);
Assert.NotNull(payload);
Assert.Equal("ok", payload.Status);
}
[Theory]
[InlineData(1, "Dice must have at least 2 sides.")]
[InlineData(1001, "Dice must have at most 1000 sides.")]
public async Task Roll_WithInvalidSides_ReturnsBadRequest(int sides, string expectedError)
private static async Task<TResponse> PostAsync<TRequest, TResponse>(HttpClient client, string uri, TRequest payload)
{
var response = await m_Client.GetAsync($"/api/roll/{sides}");
var payload = await response.Content.ReadFromJsonAsync<ApiError>();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.NotNull(payload);
Assert.Equal(expectedError, payload.Error);
}
[Theory]
[InlineData(2)]
[InlineData(1000)]
public async Task Roll_WithValidSides_ReturnsExpectedResult(int sides)
{
var response = await m_Client.GetAsync($"/api/roll/{sides}");
var payload = await response.Content.ReadFromJsonAsync<RollResponse>();
var response = await client.PostAsJsonAsync(uri, payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(payload);
Assert.Equal(sides, payload.Sides);
Assert.Equal(Math.Min(7, sides), payload.Value);
var result = await response.Content.ReadFromJsonAsync<TResponse>();
Assert.NotNull(result);
return result;
}
[Fact]
public void RandomDiceRoller_ProducesValueWithinRange()
private static async Task<TResponse> PutAsync<TRequest, TResponse>(HttpClient client, string uri, TRequest payload)
{
var roller = new RandomDiceRoller();
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;
}
for (var i = 0; i < 200; i += 1)
{
var value = roller.Roll(6);
Assert.InRange(value, 1, 6);
}
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 int m_Result;
private readonly Queue<int> m_Values;
public FixedDiceRoller(int result)
public FixedDiceRoller(IEnumerable<int> values)
{
m_Result = result;
m_Values = new Queue<int>(values);
}
public int Roll(int sides)
{
return Math.Min(m_Result, sides);
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
}
}

View File

@@ -5,6 +5,7 @@
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura</Format>
<Include>[RpgRoller]RpgRoller.Services.*</Include>
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</Configuration>
</DataCollector>