using Microsoft.AspNetCore.Builder; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using RpgRoller.Data; using RpgRoller.Hosting; namespace RpgRoller.Tests; public sealed class HostingCoverageTests { [Fact] public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices() { var services = new ServiceCollection(); var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ConnectionStrings:RpgRoller"] = "Data Source=:memory:" }).Build(); var environment = new TestWebHostEnvironment { ContentRootPath = Path.GetTempPath() }; services.AddRpgRollerCore(configuration, environment); Assert.Contains(services, d => d.ServiceType == typeof(IGameService)); Assert.Contains(services, d => d.ServiceType == typeof(IDiceRoller)); } [Fact] public void AddRpgRollerCore_WithFileConnectionString_RegistersResolvedSqliteDatabaseFile() { var services = new ServiceCollection(); var contentRoot = Path.Combine(Path.GetTempPath(), $"rpgroller-hosting-{Guid.NewGuid():N}"); var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ConnectionStrings:RpgRoller"] = "Data Source=App_Data/rpgroller.db" }).Build(); var environment = new TestWebHostEnvironment { ContentRootPath = contentRoot }; services.AddRpgRollerCore(configuration, environment); using var provider = services.BuildServiceProvider(); var databaseFile = provider.GetRequiredService(); Assert.Equal(Path.Combine(contentRoot, "App_Data", "rpgroller.db"), databaseFile.Path); Assert.True(Directory.Exists(Path.Combine(contentRoot, "App_Data"))); } [Fact] public void SqliteSchemaUpgrader_MigratesLegacySchema() { var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-legacy-upgrade-{Guid.NewGuid():N}.db"); var connectionString = $"Data Source={dbPath}"; using (var connection = new SqliteConnection(connectionString)) { connection.Open(); using var command = connection.CreateCommand(); command.CommandText = """ CREATE TABLE "Users" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY, "Username" TEXT NOT NULL, "UsernameNormalized" TEXT NOT NULL, "PasswordHash" TEXT NOT NULL, "DisplayName" TEXT NOT NULL, "ActiveCharacterId" TEXT NULL ); CREATE TABLE "Sessions" ( "Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY, "UserId" TEXT NOT NULL, "CreatedAtUtc" TEXT NOT NULL ); CREATE TABLE "Campaigns" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY, "GmUserId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Ruleset" TEXT NOT NULL, "Version" INTEGER NOT NULL ); CREATE TABLE "Characters" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY, "OwnerUserId" TEXT NOT NULL, "CampaignId" TEXT NOT NULL, "Name" TEXT NOT NULL ); CREATE TABLE "Skills" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY, "CharacterId" TEXT NOT NULL, "Name" TEXT NOT NULL, "DiceRollDefinition" TEXT NOT NULL ); CREATE TABLE "RollLogEntries" ( "Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY, "CampaignId" TEXT NOT NULL, "CharacterId" TEXT NOT NULL, "SkillId" TEXT NOT NULL, "RollerUserId" TEXT NOT NULL, "Visibility" TEXT NOT NULL, "Result" INTEGER NOT NULL, "Breakdown" TEXT NOT NULL, "TimestampUtc" TEXT NOT NULL ); INSERT INTO "Users" ("Id", "Username", "UsernameNormalized", "PasswordHash", "DisplayName", "ActiveCharacterId") VALUES ('00000000-0000-0000-0000-000000000001', 'legacy-admin', 'LEGACY-ADMIN', 'hash', 'Legacy Admin', NULL); """; _ = command.ExecuteNonQuery(); } var options = new DbContextOptionsBuilder().UseSqlite(connectionString).Options; using (var db = new RpgRollerDbContext(options)) { SqliteSchemaUpgrader.ApplyPendingChanges(db); } using var verifyConnection = new SqliteConnection(connectionString); verifyConnection.Open(); using var tableInfoCommand = verifyConnection.CreateCommand(); tableInfoCommand.CommandText = "PRAGMA table_info('Skills');"; using var tableInfoReader = tableInfoCommand.ExecuteReader(); var columns = new HashSet(StringComparer.OrdinalIgnoreCase); while (tableInfoReader.Read()) columns.Add(tableInfoReader.GetString(1)); Assert.Contains("WildDice", columns); Assert.Contains("AllowFumble", columns); Assert.Contains("FumbleRange", columns); Assert.Contains("RolemasterAutoRetry", columns); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader(); var skillGroupColumns = new HashSet(StringComparer.OrdinalIgnoreCase); while (skillGroupsTableInfoReader.Read()) skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1)); Assert.Contains("FumbleRange", skillGroupColumns); using var rollTableInfoCommand = verifyConnection.CreateCommand(); rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');"; using var rollTableInfoReader = rollTableInfoCommand.ExecuteReader(); var rollColumns = new HashSet(StringComparer.OrdinalIgnoreCase); while (rollTableInfoReader.Read()) rollColumns.Add(rollTableInfoReader.GetString(1)); Assert.Contains("Dice", rollColumns); using var usersTableInfoCommand = verifyConnection.CreateCommand(); usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');"; using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader(); var usersColumns = new HashSet(StringComparer.OrdinalIgnoreCase); while (usersTableInfoReader.Read()) usersColumns.Add(usersTableInfoReader.GetString(1)); Assert.Contains("Roles", usersColumns); using var usersRoleCommand = verifyConnection.CreateCommand(); usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';"; var roles = Convert.ToString(usersRoleCommand.ExecuteScalar()); Assert.Equal("admin", roles); using var charactersTableInfoCommand = verifyConnection.CreateCommand(); charactersTableInfoCommand.CommandText = "PRAGMA table_info('Characters');"; using var charactersTableInfoReader = charactersTableInfoCommand.ExecuteReader(); var campaignIdNotNull = true; while (charactersTableInfoReader.Read()) { if (!string.Equals(charactersTableInfoReader.GetString(1), "CampaignId", StringComparison.OrdinalIgnoreCase)) continue; campaignIdNotNull = charactersTableInfoReader.GetInt32(3) == 1; break; } Assert.False(campaignIdNotNull); using var historyCommand = verifyConnection.CreateCommand(); historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';"; var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar()); Assert.Equal(1, historyCount); using var modelSyncHistoryCommand = verifyConnection.CreateCommand(); modelSyncHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226090000_ModelSync';"; var modelSyncHistoryCount = Convert.ToInt32(modelSyncHistoryCommand.ExecuteScalar()); Assert.Equal(1, modelSyncHistoryCount); using var rollDiceHistoryCommand = verifyConnection.CreateCommand(); rollDiceHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226100000_AddRollLogDice';"; var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar()); Assert.Equal(1, rollDiceHistoryCount); using var rolesHistoryCommand = verifyConnection.CreateCommand(); rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';"; var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar()); Assert.Equal(1, rolesHistoryCount); using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand(); authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); Assert.Equal(1, authorizationRolesHistoryCount); using var rolemasterHistoryCommand = verifyConnection.CreateCommand(); rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); Assert.Equal(1, rolemasterHistoryCount); using var retryHistoryCommand = verifyConnection.CreateCommand(); retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); Assert.Equal(1, retryHistoryCount); } [Fact] public void AuthorizationMigrations_SplitCharactersRebuildFromRolesColumnAddition() { var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-migration-script-{Guid.NewGuid():N}.db"); var options = new DbContextOptionsBuilder().UseSqlite($"Data Source={dbPath}").Options; using var db = new RpgRollerDbContext(options); var migrator = db.GetService(); var charactersScript = migrator.GenerateScript("20260226131003_AddSkillGroupPrototypes", "20260226160859_AddAuthorizationRolesAndCampaignDeletion"); var rolesScript = migrator.GenerateScript("20260226160859_AddAuthorizationRolesAndCampaignDeletion", "20260226170000_AddAuthorizationRoles"); Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", charactersScript); Assert.DoesNotContain("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", charactersScript); Assert.Contains("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", rolesScript); Assert.DoesNotContain("""CREATE TABLE "ef_temp_Characters" (""", rolesScript); } [Fact] public void SqliteSchemaUpgrader_BackfillsSplitAuthorizationRolesMigrationHistory() { var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-split-history-{Guid.NewGuid():N}.db"); using (var connection = new SqliteConnection($"Data Source={dbPath}")) { connection.Open(); using var command = connection.CreateCommand(); command.CommandText = """ CREATE TABLE "__EFMigrationsHistory" ( "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, "ProductVersion" TEXT NOT NULL ); INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20260226084000_InitialSchema', '10.0.2'), ('20260226090000_ModelSync', '10.0.2'), ('20260226100000_AddRollLogDice', '10.0.2'), ('20260226124941_AddSkillGroupsAndCharacterOwnerTransfer', '10.0.2'), ('20260226131003_AddSkillGroupPrototypes', '10.0.2'), ('20260226160859_AddAuthorizationRolesAndCampaignDeletion', '10.0.2'); CREATE TABLE "Users" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY, "Username" TEXT NOT NULL, "UsernameNormalized" TEXT NOT NULL, "PasswordHash" TEXT NOT NULL, "DisplayName" TEXT NOT NULL, "ActiveCharacterId" TEXT NULL, "Roles" TEXT NOT NULL DEFAULT 'admin' ); CREATE TABLE "Sessions" ( "Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY, "UserId" TEXT NOT NULL, "CreatedAtUtc" TEXT NOT NULL ); CREATE TABLE "Campaigns" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY, "GmUserId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Ruleset" TEXT NOT NULL, "Version" INTEGER NOT NULL ); CREATE TABLE "Characters" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY, "OwnerUserId" TEXT NOT NULL, "CampaignId" TEXT NULL, "Name" TEXT NOT NULL ); CREATE INDEX "IX_Characters_OwnerUserId" ON "Characters" ("OwnerUserId"); CREATE INDEX "IX_Characters_CampaignId" ON "Characters" ("CampaignId"); CREATE TABLE "SkillGroups" ( "Id" TEXT NOT NULL CONSTRAINT "PK_SkillGroups" PRIMARY KEY, "CharacterId" TEXT NOT NULL, "Name" TEXT NOT NULL, "AllowFumble" INTEGER NOT NULL, "DiceRollDefinition" TEXT NOT NULL, "WildDice" INTEGER NOT NULL ); CREATE INDEX "IX_SkillGroups_CharacterId" ON "SkillGroups" ("CharacterId"); CREATE TABLE "Skills" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY, "CharacterId" TEXT NOT NULL, "Name" TEXT NOT NULL, "DiceRollDefinition" TEXT NOT NULL, "WildDice" INTEGER NOT NULL, "AllowFumble" INTEGER NOT NULL, "SkillGroupId" TEXT NULL ); CREATE INDEX "IX_Skills_CharacterId" ON "Skills" ("CharacterId"); CREATE INDEX "IX_Skills_SkillGroupId" ON "Skills" ("SkillGroupId"); CREATE TABLE "RollLogEntries" ( "Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY, "CampaignId" TEXT NOT NULL, "CharacterId" TEXT NOT NULL, "SkillId" TEXT NOT NULL, "RollerUserId" TEXT NOT NULL, "Visibility" TEXT NOT NULL, "Result" INTEGER NOT NULL, "Breakdown" TEXT NOT NULL, "TimestampUtc" TEXT NOT NULL, "Dice" TEXT NOT NULL ); CREATE INDEX "IX_RollLogEntries_CampaignId" ON "RollLogEntries" ("CampaignId"); CREATE INDEX "IX_RollLogEntries_CharacterId" ON "RollLogEntries" ("CharacterId"); CREATE INDEX "IX_RollLogEntries_RollerUserId" ON "RollLogEntries" ("RollerUserId"); CREATE INDEX "IX_RollLogEntries_SkillId" ON "RollLogEntries" ("SkillId"); """; _ = command.ExecuteNonQuery(); } var options = new DbContextOptionsBuilder().UseSqlite($"Data Source={dbPath}").Options; using (var db = new RpgRollerDbContext(options)) { SqliteSchemaUpgrader.ApplyPendingChanges(db); } using var verifyConnection = new SqliteConnection($"Data Source={dbPath}"); verifyConnection.Open(); using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand(); authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); Assert.Equal(1, authorizationRolesHistoryCount); using var rolemasterHistoryCommand = verifyConnection.CreateCommand(); rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); Assert.Equal(1, rolemasterHistoryCount); using var retryHistoryCommand = verifyConnection.CreateCommand(); retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); Assert.Equal(1, retryHistoryCount); } [Fact] public void InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling() { var sourceDbPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "RpgRoller", "App_Data", "rpgroller.development.db"); var copiedDbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-dev-copy-{Guid.NewGuid():N}.db"); File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, true); Guid skillId; Guid ownerUserId; Guid characterId; var campaignCountBefore = 0; var skillCountBefore = 0; using (var connection = new SqliteConnection($"Data Source={copiedDbPath}")) { connection.Open(); using var countsCommand = connection.CreateCommand(); countsCommand.CommandText = """ SELECT (SELECT COUNT(*) FROM Campaigns), (SELECT COUNT(*) FROM Skills); """; using var countsReader = countsCommand.ExecuteReader(); Assert.True(countsReader.Read()); campaignCountBefore = countsReader.GetInt32(0); skillCountBefore = countsReader.GetInt32(1); using var existingSkillCommand = connection.CreateCommand(); existingSkillCommand.CommandText = """ SELECT s.Id, c.OwnerUserId, c.Id FROM Skills s INNER JOIN Characters c ON c.Id = s.CharacterId INNER JOIN Campaigns cp ON cp.Id = c.CampaignId WHERE cp.Ruleset = 'D6' ORDER BY s.Name LIMIT 1; """; using var existingSkillReader = existingSkillCommand.ExecuteReader(); Assert.True(existingSkillReader.Read()); skillId = Guid.Parse(existingSkillReader.GetString(0)); ownerUserId = Guid.Parse(existingSkillReader.GetString(1)); characterId = Guid.Parse(existingSkillReader.GetString(2)); using var sessionCommand = connection.CreateCommand(); sessionCommand.CommandText = """ INSERT INTO Sessions ("Token", "UserId", "CreatedAtUtc") VALUES ($token, $userId, $createdAtUtc); """; var tokenParameter = sessionCommand.CreateParameter(); tokenParameter.ParameterName = "$token"; tokenParameter.Value = "migration-test-session"; sessionCommand.Parameters.Add(tokenParameter); var userParameter = sessionCommand.CreateParameter(); userParameter.ParameterName = "$userId"; userParameter.Value = ownerUserId.ToString(); sessionCommand.Parameters.Add(userParameter); var createdAtParameter = sessionCommand.CreateParameter(); createdAtParameter.ParameterName = "$createdAtUtc"; createdAtParameter.Value = DateTimeOffset.UtcNow.ToString("O"); sessionCommand.Parameters.Add(createdAtParameter); _ = sessionCommand.ExecuteNonQuery(); } var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.GetTempPath(), EnvironmentName = Environments.Development }); builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning); builder.Configuration.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}" }); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); using var app = builder.Build(); app.InitializeRpgRollerState(); using var scope = app.Services.CreateScope(); var game = scope.ServiceProvider.GetRequiredService(); var rollResult = game.RollSkill("migration-test-session", skillId, "public"); Assert.True(rollResult.Succeeded); Assert.NotEmpty(ServiceTestSupport.GetValue(rollResult).Dice); var migratedSheet = ServiceTestSupport.GetValue(game.GetCharacterSheet("migration-test-session", characterId)); Assert.Contains(migratedSheet.Skills, skill => skill.Id == skillId); using var verifyConnection = new SqliteConnection($"Data Source={copiedDbPath}"); verifyConnection.Open(); using var countsAfterCommand = verifyConnection.CreateCommand(); countsAfterCommand.CommandText = """ SELECT (SELECT COUNT(*) FROM Campaigns), (SELECT COUNT(*) FROM Skills); """; using var countsAfterReader = countsAfterCommand.ExecuteReader(); Assert.True(countsAfterReader.Read()); Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0)); Assert.Equal(skillCountBefore, countsAfterReader.GetInt32(1)); using var skillsTableInfoCommand = verifyConnection.CreateCommand(); skillsTableInfoCommand.CommandText = "PRAGMA table_info('Skills');"; using var skillsTableInfoReader = skillsTableInfoCommand.ExecuteReader(); var skillColumns = new HashSet(StringComparer.OrdinalIgnoreCase); while (skillsTableInfoReader.Read()) skillColumns.Add(skillsTableInfoReader.GetString(1)); Assert.Contains("FumbleRange", skillColumns); Assert.Contains("RolemasterAutoRetry", skillColumns); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader(); var skillGroupColumns = new HashSet(StringComparer.OrdinalIgnoreCase); while (skillGroupsTableInfoReader.Read()) skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1)); Assert.Contains("FumbleRange", skillGroupColumns); using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand(); authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); Assert.Equal(1, authorizationRolesHistoryCount); using var retryHistoryCommand = verifyConnection.CreateCommand(); retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); Assert.Equal(1, retryHistoryCount); } }