Persist backend state with EF Core SQLite

This commit is contained in:
2026-02-24 22:48:28 +01:00
parent fb70c18e75
commit 3c6dc5c0a9
8 changed files with 738 additions and 461 deletions

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
@@ -10,7 +12,8 @@ public sealed class GameServiceTests
[Fact]
public void Register_ValidatesRequiredFieldsAndDuplicates()
{
var service = CreateService();
using var harness = CreateHarness();
var service = harness.Service;
var invalidUsername = service.Register(new RegisterRequest("", "Password123", "Display"));
var invalidDisplay = service.Register(new RegisterRequest("user", "Password123", ""));
@@ -28,7 +31,8 @@ public sealed class GameServiceTests
[Fact]
public void Login_ValidatesCredentialsAndSessionLookup()
{
var service = CreateService();
using var harness = CreateHarness();
var service = harness.Service;
service.Register(new RegisterRequest("user", "Password123", "Display"));
var invalidUser = service.Login(new LoginRequest("missing", "Password123"));
@@ -46,10 +50,25 @@ public sealed class GameServiceTests
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 RegisterRequest("user", "Password123", "Display"));
var login = service.Login(new LoginRequest("user", "Password123"));
Assert.True(login.Succeeded);
Assert.Equal(2, hasher.HashCalls);
}
[Fact]
public void CampaignAndCharacterOperations_CheckUnauthorizedAndNotFoundCases()
{
var service = CreateService();
using var harness = CreateHarness();
var service = harness.Service;
var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignRequest("Name", "d6"));
Assert.False(unauthorizedCampaign.Succeeded);
@@ -83,7 +102,8 @@ public sealed class GameServiceTests
[Fact]
public void CharacterSkillAndRollOperations_CheckAuthorizationAndValidationBranches()
{
var service = CreateService(3, 4, 5, 6);
using var harness = CreateHarness(3, 4, 5, 6);
var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other"));
@@ -152,7 +172,8 @@ public sealed class GameServiceTests
[Fact]
public void CurrentCampaignCharacters_ReturnsNoActiveCharacterWhenUnset()
{
var service = CreateService();
using var harness = CreateHarness();
var service = harness.Service;
service.Register(new RegisterRequest("user", "Password123", "User"));
var sessionToken = GetValue(service.Login(new LoginRequest("user", "Password123"))).SessionToken;
@@ -163,7 +184,8 @@ public sealed class GameServiceTests
[Fact]
public void GetCampaigns_ReturnsOwnedAndParticipatingCampaigns()
{
var service = CreateService();
using var harness = CreateHarness();
var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("player", "Password123", "Player"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
@@ -180,6 +202,105 @@ public sealed class GameServiceTests
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 LoginRequest("", ""));
Assert.False(invalidCredentials.Succeeded);
service.Logout("missing-session");
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", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id)));
Assert.False(service.GetMe(string.Empty).Succeeded);
Assert.False(service.CreateCampaign(gmSession, new CreateCampaignRequest("", "d6")).Succeeded);
Assert.False(service.GetCampaigns(string.Empty).Succeeded);
Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterRequest("", campaign.Id)).Succeeded);
Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterRequest("Name", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterRequest("Renamed", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterRequest("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 CreateSkillRequest("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillRequest("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")).Succeeded);
var ownerUser = harness.Db.Users.Single(u => u.UsernameNormalized == "OWNER");
ownerUser.ActiveCharacterId = Guid.NewGuid();
harness.Db.SaveChanges();
var staleMe = GetValue(service.GetMe(ownerSession));
Assert.Null(staleMe.ActiveCharacterId);
Assert.Null(staleMe.CurrentCampaignId);
Assert.True(service.ActivateCharacter(ownerSession, ownerCharacter.Id).Succeeded);
var activeMe = GetValue(service.GetMe(ownerSession));
Assert.Equal(ownerCharacter.Id, activeMe.ActiveCharacterId);
Assert.Equal(campaign.Id, activeMe.CurrentCampaignId);
var staleOwner = harness.Db.Users.Single(u => u.Id == ownerUser.Id);
staleOwner.ActiveCharacterId = Guid.NewGuid();
harness.Db.SaveChanges();
var staleCurrentCampaign = service.GetCurrentCampaignCharacters(ownerSession);
Assert.False(staleCurrentCampaign.Succeeded);
Assert.Null(harness.Db.Users.Single(u => u.Id == ownerUser.Id).ActiveCharacterId);
var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillRequest("Stealth", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("Stealth", "bad")).Succeeded);
Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillRequest("public")).Succeeded);
var mutableSkill = harness.Db.Skills.Single(s => s.Id == skill.Id);
mutableSkill.DiceRollDefinition = "bad";
harness.Db.SaveChanges();
Assert.False(service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("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 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", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id)));
var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterRequest("Other Character", campaign.Id)));
var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")));
_ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillRequest("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()
{
@@ -217,9 +338,23 @@ public sealed class GameServiceTests
}
}
private static GameService CreateService(params int[] rollValues)
private static ServiceHarness CreateHarness(params int[] rollValues)
{
return new GameService(new PasswordHasher<UserAccount>(), new FixedDiceRoller(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");
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
.UseSqlite($"Data Source={dbPath}")
.Options;
var db = new RpgRollerDbContext(options);
db.Database.EnsureCreated();
var service = new GameService(db, passwordHasher, new FixedDiceRoller(rollValues));
return new ServiceHarness(service, db);
}
private static T GetValue<T>(ServiceResult<T> result)
@@ -244,4 +379,41 @@ public sealed class GameServiceTests
return Math.Clamp(next, 1, sides);
}
}
private sealed class ServiceHarness : IDisposable
{
private readonly RpgRollerDbContext m_Db;
public ServiceHarness(GameService service, RpgRollerDbContext db)
{
Service = service;
m_Db = db;
}
public GameService Service { get; }
public RpgRollerDbContext Db => m_Db;
public void Dispose()
{
m_Db.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;
}
}
}

View File

@@ -1,9 +1,11 @@
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;
@@ -201,6 +203,12 @@ public sealed class UnitTest1 : IClassFixture<WebApplicationFactory<Program>>
{
services.RemoveAll<IDiceRoller>();
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
services.RemoveAll<RpgRollerDbContext>();
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
services.AddDbContext<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
}));
}

View File

@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using RpgRoller.Domain;
namespace RpgRoller.Data;
public sealed class RpgRollerDbContext : DbContext
{
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options)
: base(options)
{
}
public DbSet<UserAccount> Users => Set<UserAccount>();
public DbSet<UserSession> Sessions => Set<UserSession>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Username).IsRequired().HasMaxLength(64);
entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64);
entity.Property(x => x.PasswordHash).IsRequired();
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
});
modelBuilder.Entity<UserSession>(entity =>
{
entity.HasKey(x => x.Token);
entity.Property(x => x.Token).HasMaxLength(64);
entity.Property(x => x.CreatedAtUtc).IsRequired();
entity.HasIndex(x => x.UserId);
});
modelBuilder.Entity<Campaign>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
entity.Property(x => x.Ruleset).HasConversion<string>().IsRequired();
entity.Property(x => x.Version).IsRequired();
entity.HasIndex(x => x.GmUserId);
});
modelBuilder.Entity<Character>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
entity.HasIndex(x => x.OwnerUserId);
entity.HasIndex(x => x.CampaignId);
});
modelBuilder.Entity<Skill>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
entity.HasIndex(x => x.CharacterId);
});
modelBuilder.Entity<RollLogEntry>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Visibility).HasConversion<string>().IsRequired();
entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256);
entity.Property(x => x.TimestampUtc).IsRequired();
entity.HasIndex(x => x.CampaignId);
entity.HasIndex(x => x.RollerUserId);
entity.HasIndex(x => x.SkillId);
entity.HasIndex(x => x.CharacterId);
});
}
}

View File

@@ -16,8 +16,10 @@ public sealed class UserAccount
{
public required Guid Id { get; init; }
public required string Username { get; init; }
public required string UsernameNormalized { get; init; }
public required string PasswordHash { get; set; }
public required string DisplayName { get; set; }
public Guid? ActiveCharacterId { get; set; }
}
public sealed class UserSession

View File

@@ -1,18 +1,31 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
const string SessionCookieName = "rpgroller_session";
var builder = WebApplication.CreateBuilder(args);
var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath);
builder.Services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
builder.Services.AddDbContext<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
builder.Services.AddSingleton<IGameService, GameService>();
builder.Services.AddScoped<IGameService, GameService>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<RpgRollerDbContext>();
db.Database.EnsureCreated();
}
app.UseDefaultFiles();
app.UseStaticFiles();
@@ -287,4 +300,23 @@ static BadRequest<ApiError> ToBadRequest(ServiceError error)
return TypedResults.BadRequest(new ApiError(error.Message));
}
static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath)
{
var builder = new SqliteConnectionStringBuilder(connectionString);
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
{
return;
}
var fullPath = Path.IsPathRooted(builder.DataSource)
? builder.DataSource
: Path.Combine(contentRootPath, builder.DataSource);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
public partial class Program;

View File

@@ -6,4 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
</ItemGroup>
</Project>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
{
"ConnectionStrings": {
"RpgRoller": "Data Source=App_Data/rpgroller.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",