sln build fix (again), tests fixes, audit work and doctors work
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user