313 lines
11 KiB
C#
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
|
|
}
|