using Microsoft.Data.Sqlite; using Microsoft.AspNetCore.Builder; 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); 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 rolemasterHistoryCommand = verifyConnection.CreateCommand(); rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); Assert.Equal(1, rolemasterHistoryCount); } [Fact] public void AuthorizationRolesAndCampaignDeletion_MigrationScript_DoesNotMixUsersSqlIntoCharactersRebuild() { 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 script = migrator.GenerateScript( fromMigration: "20260226131003_AddSkillGroupPrototypes", toMigration: "20260226160859_AddAuthorizationRolesAndCampaignDeletion"); Assert.Contains("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", script); Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", script); Assert.DoesNotContain("UPDATE Users", script); } [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, overwrite: 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); 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); } }