Files
git.stella-ops.org/src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/SchemaComplianceTests.cs

313 lines
11 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Schema compliance tests.
/// Verifies that database migrations align with specification documents.
/// </summary>
[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();
/// <summary>
/// Verifies that database specification document exists.
/// </summary>
[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");
}
/// <summary>
/// Verifies that all migration files follow naming convention.
/// </summary>
[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");
}
}
/// <summary>
/// Verifies that migrations use schema-qualified table names.
/// </summary>
[Fact]
public void Migrations_UseSchemaQualifiedTableNames()
{
// Arrange
var migrationFiles = GetMigrationFiles();
var violations = new List<string>();
// 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))}");
}
/// <summary>
/// Verifies that migration files are idempotent (use IF NOT EXISTS / IF EXISTS).
/// </summary>
[Fact]
public void Migrations_AreIdempotent()
{
// Arrange
var migrationFiles = GetMigrationFiles();
var nonIdempotent = new List<string>();
// 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}");
}
}
}
/// <summary>
/// Verifies that schema documentation exists for all schemas used in migrations.
/// </summary>
[Fact]
public void SchemaDocumentation_ExistsForAllSchemas()
{
// Arrange
var migrationFiles = GetMigrationFiles();
var schemasUsed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var schemasDocumented = new HashSet<string>(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");
}
/// <summary>
/// Verifies that migrations have corresponding down/rollback scripts where appropriate.
/// </summary>
[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<string>();
// 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<string> GetMigrationFiles()
{
var migrationDirs = new List<string>();
// Find all Migrations directories
if (Directory.Exists(SrcPath))
{
migrationDirs.AddRange(
Directory.GetDirectories(SrcPath, "Migrations", SearchOption.AllDirectories));
}
var allMigrations = new List<string>();
foreach (var dir in migrationDirs)
{
allMigrations.AddRange(Directory.GetFiles(dir, "*.sql", SearchOption.AllDirectories));
}
return [.. allMigrations];
}
#endregion
}