diff --git a/DorkNet.Server.Tests/SchemaBootstrapParityTests.cs b/DorkNet.Server.Tests/SchemaBootstrapParityTests.cs new file mode 100644 index 0000000..3060da9 --- /dev/null +++ b/DorkNet.Server.Tests/SchemaBootstrapParityTests.cs @@ -0,0 +1,271 @@ +using System.Text.RegularExpressions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Tests; + +/// +/// Guards the two ways a column reaches a database that already exists, because +/// getting either one wrong is invisible until a live server starts returning +/// 500s. +/// +/// Every other test in this suite runs against a FRESH database, where +/// EnsureCreated builds the whole schema straight from the entity model — +/// so every column always exists and none of this is exercised. Only an +/// *existing* database can be missing a column, and that is exactly what took +/// down club home, matchmake/dorm, the presence heartbeat and the chat inbox at +/// once: three columns were added to the entity model with an EF migration, but +/// the Postgres path never replays migrations, so a deployed database never got +/// them. +/// +/// The two mechanisms, and the invariant each test asserts: +/// +/// +/// SQLite applies committed EF migrations via Database.Migrate(). +/// So the migration history alone must be able to build the current model. +/// Postgres is EnsureCreated-only and NEVER replays +/// migrations. A database created before a column existed only gets it from an +/// idempotent Ensure* ALTER in 's +/// DatabaseBootstrap. So anything added after the Initial migration must appear +/// there too. +/// +/// +public sealed class SchemaBootstrapParityTests +{ + /// Every table and column in the entity model must be reachable on + /// a database that already exists — via a committed migration, an + /// idempotent Ensure* patch, or both. + /// + /// That union is exactly what boot does: SQLite runs Database.Migrate() + /// and then the Ensure* helpers, and Postgres runs the helpers alone. + /// A property with NEITHER reaches only brand-new databases, and every + /// query touching it throws at runtime on a deployed server. + /// + /// The union is asserted rather than migrations alone because a large amount + /// of the schema legitimately predates this rule and lives only in the + /// helpers. Tightening that up is a separate cleanup; what must never happen + /// is a column covered by nothing. + [Fact] + public async Task Every_model_column_reaches_an_existing_database() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var db = new DorkNetDbContext(options); + await db.Database.MigrateAsync(); + + var actual = await ReadSqliteSchemaAsync(connection); + var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: false); + var missing = new List(); + + foreach (var entity in db.Model.GetEntityTypes()) + { + var table = entity.GetTableName(); + if (string.IsNullOrEmpty(table)) continue; + + var migrated = actual.TryGetValue(table, out var columns); + if (!migrated && !patchedTables.Contains(table)) + { + missing.Add($"{table} (whole table)"); + continue; + } + // A table the helpers create carries its own columns in the same + // CREATE, so only migrated tables need a per-column check. + if (!migrated) continue; + + foreach (var property in entity.GetProperties()) + { + var column = property.GetColumnName(); + if (string.IsNullOrEmpty(column)) continue; + if (columns!.Contains(column)) continue; + if (patchedColumns.Contains($"{table}.{column}")) continue; + missing.Add($"{table}.{column}"); + } + } + + Assert.True(missing.Count == 0, + $""" + {missing.Count} model table(s)/column(s) are covered by NOTHING. + + They exist on a freshly-created database (EnsureCreated builds the + whole model) but never appear on one that already exists, so a + deployed server throws "no such column" / "column does not exist" + on every request that touches them. + + Add a migration AND an idempotent ALTER in an Ensure*Async helper in + DorkNet.Server/Startup/DatabaseBootstrap.cs for: + + {string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal))} + """); + } + + /// Every column added AFTER the Initial migration must also be + /// added by an idempotent Postgres ALTER in DatabaseBootstrap. + /// + /// Postgres never replays migrations, so a migration alone reaches only + /// brand-new databases. A production database created before the column + /// existed gets it from the Ensure* helpers or not at all — the exact hole + /// that broke matchmake/dorm (PrivateInstances.RoomCode), + /// club/home/me (Clubs.ClubChatEnabled) and the chat inbox + /// (ChatMessages.ModerationState). + [Fact] + public void Post_initial_columns_are_patched_for_existing_postgres_databases() + { + var migrations = FindRepoDirectory("DorkNet.Server", "Migrations"); + var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: true); + + // Anything AddColumn'd by a migration other than Initial post-dates the + // baseline, so an existing Postgres database will not have it. + var gaps = new List(); + foreach (var file in Directory.GetFiles(migrations, "*.cs")) + { + var name = Path.GetFileNameWithoutExtension(file); + if (name.Contains("Designer", StringComparison.Ordinal)) continue; + if (name.Contains("Initial", StringComparison.Ordinal)) continue; + if (name.Contains("ModelSnapshot", StringComparison.Ordinal)) continue; + + var text = File.ReadAllText(file); + foreach (Match m in Regex.Matches(text, + @"AddColumn<[^>]+>\(\s*name:\s*""(?\w+)""\s*,\s*table:\s*""(?\w+)""", + RegexOptions.Singleline)) + { + var table = m.Groups["table"].Value; + var column = m.Groups["column"].Value; + if (patchedTables.Contains(table)) continue; // whole table is created by a patch + if (patchedColumns.Contains($"{table}.{column}")) continue; + gaps.Add($"{table}.{column} (added by {name})"); + } + + foreach (Match m in Regex.Matches(text, + @"CreateTable\(\s*name:\s*""(?
\w+)""", + RegexOptions.Singleline)) + { + var table = m.Groups["table"].Value; + if (!patchedTables.Contains(table)) + gaps.Add($"{table} (whole table, created by {name})"); + } + } + + Assert.True(gaps.Count == 0, + $""" + {gaps.Count} schema change(s) reach new databases only. + + The Postgres path is EnsureCreated-only and never replays + migrations, so a migration alone does NOT add these to a database + that already exists — a deployed server will throw + "column does not exist" on every request that touches them. + + Add an idempotent ALTER/CREATE to an Ensure*Async helper in + DorkNet.Server/Startup/DatabaseBootstrap.cs for: + + {string.Join(Environment.NewLine, gaps.Order(StringComparer.Ordinal))} + """); + } + + /// Tables and columns the Ensure* helpers add by raw SQL. + /// + /// The helpers are private, so this reads their statements out of the source + /// rather than executing them. In that source the SQL sits inside verbatim + /// strings, so a quoted identifier appears as two literal quote characters + /// (""Playlists""). + /// + /// keys off IF NOT EXISTS: the + /// Postgres branch uses it, while the SQLite branch omits it and swallows + /// the duplicate-column error instead. So requiring it isolates the branch + /// that an existing Postgres database actually depends on. + private static (HashSet Tables, HashSet Columns) ReadBootstrapPatches( + bool postgresOnly) + { + var bootstrap = Path.Combine( + FindRepoDirectory("DorkNet.Server", "Startup"), "DatabaseBootstrap.cs"); + Assert.True(File.Exists(bootstrap), $"missing {bootstrap}"); + var sql = File.ReadAllText(bootstrap); + + // The source mixes both C# string styles, so a quoted identifier shows + // up as either two quote characters (verbatim, @"... ""X"" ...") or one + // (raw, """... "X" ..."""). Accept either. + const string Q = "\"{1,2}"; + var ifNotExists = postgresOnly ? @"IF\s+NOT\s+EXISTS\s+" : @"(?:IF\s+NOT\s+EXISTS\s+)?"; + + var columns = Regex + .Matches(sql, + $@"ALTER\s+TABLE\s+{Q}(?
\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}(?\w+){Q}", + RegexOptions.IgnoreCase) + .Select(m => $"{m.Groups["table"].Value}.{m.Groups["column"].Value}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var tables = Regex + .Matches(sql, + $@"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+{Q}(?
\w+){Q}", + RegexOptions.IgnoreCase) + .Select(m => m.Groups["table"].Value) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Some helpers loop a column-name array through one interpolated ALTER + // (EnsureGameRewardColumnsAsync does). The statement alone only shows + // "{col}", so pair each such ALTER with the nearest preceding + // `new[] { "A", "B" }` and expand it. + foreach (Match alter in Regex.Matches(sql, + $@"ALTER\s+TABLE\s+{Q}(?
\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}\{{(?\w+)\}}{Q}", + RegexOptions.IgnoreCase)) + { + var loop = Regex.Matches( + sql[..alter.Index], + $@"foreach\s*\(\s*var\s+{Regex.Escape(alter.Groups["var"].Value)}\s+in\s+new\[\]\s*\{{(?[^}}]*)\}}") + .Cast() + .LastOrDefault(); + if (loop is null) continue; + + foreach (Match item in Regex.Matches(loop.Groups["items"].Value, "\"(?\\w+)\"")) + columns.Add($"{alter.Groups["table"].Value}.{item.Groups["name"].Value}"); + } + + return (tables, columns); + } + + private static async Task>> ReadSqliteSchemaAsync( + SqliteConnection connection) + { + var schema = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var tables = new List(); + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) tables.Add(reader.GetString(0)); + } + + foreach (var table in tables) + { + var columns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = $"PRAGMA table_info(\"{table}\");"; + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) columns.Add(reader.GetString(1)); + schema[table] = columns; + } + + return schema; + } + + /// Walk up from the test binaries to the repo root, then into the + /// requested source directory. + private static string FindRepoDirectory(params string[] segments) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "DorkNet.sln"))) + dir = dir.Parent; + + Assert.NotNull(dir); + var path = Path.Combine(new[] { dir!.FullName }.Concat(segments).ToArray()); + Assert.True(Directory.Exists(path), $"missing source directory {path}"); + return path; + } +} diff --git a/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.cs b/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.cs index 69c7597..e2ffc18 100644 --- a/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.cs +++ b/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.cs @@ -51,7 +51,7 @@ namespace DorkNet.Server.Migrations table: "Playlists", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: true); migrationBuilder.AddColumn( name: "SupportsLevelVoting", @@ -65,21 +65,21 @@ namespace DorkNet.Server.Migrations table: "Playlists", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: true); migrationBuilder.AddColumn( name: "SupportsTeleportVR", table: "Playlists", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: true); migrationBuilder.AddColumn( name: "SupportsWalkVR", table: "Playlists", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: true); migrationBuilder.AddColumn( name: "WarningMask",