// ----------------------------------------------------------------------------- // SchemaComplianceTests.cs // Tests that verify database schemas comply with specification documents // Sprint: Testing Enhancement Advisory - Phase 1.1 // ----------------------------------------------------------------------------- using System.Collections.Immutable; using System.Text.RegularExpressions; using FluentAssertions; using StellaOps.TestKit; using Xunit; namespace StellaOps.Architecture.Contracts.Tests; /// /// Schema compliance tests. /// Verifies that database migrations align with specification documents. /// [Trait("Category", TestCategories.Architecture)] [Trait("Category", TestCategories.Contract)] public partial class SchemaComplianceTests { private static readonly string RepoRoot = FindRepoRoot(); private static readonly string DocsDbPath = Path.Combine(RepoRoot, "docs", "db"); private static readonly string SrcPath = Path.Combine(RepoRoot, "src"); [GeneratedRegex(@"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_]+\.)?([a-z_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex CreateTableRegex(); [GeneratedRegex(@"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([a-z_]+\.)?([a-z_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex AlterTableRegex(); [GeneratedRegex(@"CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex CreateIndexRegex(); /// /// Verifies that database specification document exists. /// [Fact] public void DatabaseSpecification_Exists() { // Arrange var specPath = Path.Combine(DocsDbPath, "SPECIFICATION.md"); // Assert File.Exists(specPath).Should().BeTrue( "Database specification document should exist at docs/db/SPECIFICATION.md"); } /// /// Verifies that all migration files follow naming convention. /// [Fact] public void MigrationFiles_FollowNamingConvention() { // Arrange var migrationFiles = GetMigrationFiles(); // Act & Assert foreach (var file in migrationFiles) { var fileName = Path.GetFileName(file); // Should start with a number (version/sequence) fileName.Should().MatchRegex(@"^\d+", $"Migration file {fileName} should start with a version number"); // Should have .sql extension Path.GetExtension(file).Should().Be(".sql", $"Migration file {fileName} should have .sql extension"); } } /// /// Verifies that migrations use schema-qualified table names. /// [Fact] public void Migrations_UseSchemaQualifiedTableNames() { // Arrange var migrationFiles = GetMigrationFiles(); var violations = new List(); // Act foreach (var file in migrationFiles) { var content = File.ReadAllText(file); var fileName = Path.GetFileName(file); // Check CREATE TABLE statements var createMatches = CreateTableRegex().Matches(content); foreach (Match match in createMatches) { var schema = match.Groups[1].Value; var table = match.Groups[2].Value; if (string.IsNullOrEmpty(schema)) { violations.Add($"{fileName}: CREATE TABLE {table} missing schema qualifier"); } } // Check ALTER TABLE statements var alterMatches = AlterTableRegex().Matches(content); foreach (Match match in alterMatches) { var schema = match.Groups[1].Value; var table = match.Groups[2].Value; if (string.IsNullOrEmpty(schema)) { violations.Add($"{fileName}: ALTER TABLE {table} missing schema qualifier"); } } } // Assert violations.Should().BeEmpty( $"All table operations should use schema-qualified names. Violations: {string.Join(", ", violations.Take(10))}"); } /// /// Verifies that migration files are idempotent (use IF NOT EXISTS / IF EXISTS). /// [Fact] public void Migrations_AreIdempotent() { // Arrange var migrationFiles = GetMigrationFiles(); var nonIdempotent = new List(); // Act foreach (var file in migrationFiles) { var content = File.ReadAllText(file); var fileName = Path.GetFileName(file); // Check CREATE TABLE without IF NOT EXISTS if (Regex.IsMatch(content, @"CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)", RegexOptions.IgnoreCase)) { nonIdempotent.Add($"{fileName}: CREATE TABLE without IF NOT EXISTS"); } // Check CREATE INDEX without IF NOT EXISTS if (Regex.IsMatch(content, @"CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?!IF\s+NOT\s+EXISTS)", RegexOptions.IgnoreCase)) { nonIdempotent.Add($"{fileName}: CREATE INDEX without IF NOT EXISTS"); } } // Assert - this is a warning, not a hard failure // Some migrations may intentionally not be idempotent if (nonIdempotent.Any()) { Console.WriteLine("Warning: Non-idempotent migrations found:"); foreach (var item in nonIdempotent) { Console.WriteLine($" - {item}"); } } } /// /// Verifies that schema documentation exists for all schemas used in migrations. /// [Fact] public void SchemaDocumentation_ExistsForAllSchemas() { // Arrange var migrationFiles = GetMigrationFiles(); var schemasUsed = new HashSet(StringComparer.OrdinalIgnoreCase); var schemasDocumented = new HashSet(StringComparer.OrdinalIgnoreCase); // Find schemas used in migrations foreach (var file in migrationFiles) { var content = File.ReadAllText(file); // Extract schema names from CREATE SCHEMA var createSchemaMatches = Regex.Matches(content, @"CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_]+)", RegexOptions.IgnoreCase); foreach (Match match in createSchemaMatches) { schemasUsed.Add(match.Groups[1].Value); } // Extract schema names from table operations var tableMatches = CreateTableRegex().Matches(content); foreach (Match match in tableMatches) { var schema = match.Groups[1].Value.TrimEnd('.'); if (!string.IsNullOrEmpty(schema)) { schemasUsed.Add(schema); } } } // Find documented schemas var schemaDocsPath = Path.Combine(DocsDbPath, "schemas"); if (Directory.Exists(schemaDocsPath)) { var docFiles = Directory.GetFiles(schemaDocsPath, "*.md", SearchOption.TopDirectoryOnly); foreach (var docFile in docFiles) { var schemaName = Path.GetFileNameWithoutExtension(docFile); schemasDocumented.Add(schemaName); } } // Assert var undocumented = schemasUsed.Except(schemasDocumented).ToList(); // Output for visibility if (undocumented.Any()) { Console.WriteLine($"Schemas without documentation: {string.Join(", ", undocumented)}"); } // Soft assertion - warn but don't fail schemasUsed.Should().NotBeEmpty("Should find schemas used in migrations"); } /// /// Verifies that migrations have corresponding down/rollback scripts where appropriate. /// [Fact] public void Migrations_HaveDownScripts() { // Arrange var migrationFiles = GetMigrationFiles(); var upScripts = migrationFiles.Where(f => !Path.GetFileName(f).Contains("_down", StringComparison.OrdinalIgnoreCase) && !Path.GetFileName(f).Contains("_rollback", StringComparison.OrdinalIgnoreCase)).ToList(); var missingDownScripts = new List(); // Act foreach (var upScript in upScripts) { var fileName = Path.GetFileName(upScript); var directory = Path.GetDirectoryName(upScript)!; // Look for corresponding down script var baseName = Path.GetFileNameWithoutExtension(fileName); var expectedDownNames = new[] { $"{baseName}_down.sql", $"{baseName}_rollback.sql", $"{baseName}.down.sql" }; var hasDownScript = expectedDownNames.Any(downName => File.Exists(Path.Combine(directory, downName))); if (!hasDownScript) { missingDownScripts.Add(fileName); } } // Assert - informational if (missingDownScripts.Any()) { Console.WriteLine($"Migrations without down scripts ({missingDownScripts.Count}):"); foreach (var script in missingDownScripts.Take(10)) { Console.WriteLine($" - {script}"); } } } #region Helper Methods private static string FindRepoRoot() { var current = Directory.GetCurrentDirectory(); while (current is not null) { if (Directory.Exists(Path.Combine(current, ".git")) || File.Exists(Path.Combine(current, "CLAUDE.md"))) { return current; } current = Directory.GetParent(current)?.FullName; } return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..")); } private static ImmutableArray GetMigrationFiles() { var migrationDirs = new List(); // Find all Migrations directories if (Directory.Exists(SrcPath)) { migrationDirs.AddRange( Directory.GetDirectories(SrcPath, "Migrations", SearchOption.AllDirectories)); } var allMigrations = new List(); foreach (var dir in migrationDirs) { allMigrations.AddRange(Directory.GetFiles(dir, "*.sql", SearchOption.AllDirectories)); } return [.. allMigrations]; } #endregion }