yams/tests/unit/metadata/migration_validation_test.cpp

399 lines
12 KiB
C++

// Copyright 2025 YAMS Project
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* @file migration_validation_test.cpp
* @brief Validates that database migrations execute successfully
*
* This test ensures:
* - All migrations can be registered and applied
* - Database reaches expected schema version
* - New migrations are automatically tested (no hardcoded version checks)
* - Migration rollback/idempotency works correctly
*/
#include <catch2/catch_test_macros.hpp>
#include <yams/metadata/connection_pool.h>
#include <yams/metadata/database.h>
#include <yams/metadata/migration.h>
#include <chrono>
#include <filesystem>
#include <string>
using namespace yams;
using namespace yams::metadata;
namespace {
/**
* @brief Test fixture for migration validation
*/
class MigrationTestFixture {
public:
MigrationTestFixture() {
// Create unique temporary database path to avoid conflicts between sections
auto timestamp = std::chrono::steady_clock::now().time_since_epoch().count();
temp_db_path_ = std::filesystem::temp_directory_path() /
("yams_migration_test_" + std::to_string(timestamp));
std::filesystem::create_directories(temp_db_path_);
db_file_ = temp_db_path_ / "test.db";
// Create connection pool
pool_ = std::make_unique<ConnectionPool>(db_file_.string());
auto init_result = pool_->initialize();
if (!init_result) {
throw std::runtime_error("Failed to initialize connection pool: " +
init_result.error().message);
}
}
~MigrationTestFixture() {
pool_.reset();
try {
std::filesystem::remove_all(temp_db_path_);
} catch (const std::filesystem::filesystem_error&) {
// Ignore cleanup errors
}
}
/**
* @brief Apply all migrations and return the final version
*/
Result<int> applyAllMigrations() {
int final_version = 0;
auto result = pool_->withConnection([&](Database& db) -> Result<void> {
MigrationManager mm(db);
// Initialize migration manager
auto init_result = mm.initialize();
if (!init_result) {
return init_result.error();
}
// Register all migrations
mm.registerMigrations(YamsMetadataMigrations::getAllMigrations());
// Apply migrations
auto migrate_result = mm.migrate();
if (!migrate_result) {
return migrate_result.error();
}
// Get current version
auto version_result = mm.getCurrentVersion();
if (!version_result) {
return version_result.error();
}
final_version = version_result.value();
return Result<void>();
});
if (!result) {
return result.error();
}
return final_version;
}
/**
* @brief Get the expected latest migration version
* This is determined dynamically from the registered migrations
*/
Result<int> getExpectedLatestVersion() {
int expected_version = 0;
auto result = pool_->withConnection([&](Database& db) -> Result<void> {
MigrationManager mm(db);
auto init_result = mm.initialize();
if (!init_result) {
return init_result.error();
}
mm.registerMigrations(YamsMetadataMigrations::getAllMigrations());
expected_version = mm.getLatestVersion();
return Result<void>();
});
if (!result) {
return result.error();
}
return expected_version;
}
/**
* @brief Check if migrations are idempotent (can be re-run)
*/
Result<bool> testIdempotency() {
// Apply migrations first time
auto first_result = applyAllMigrations();
if (!first_result) {
return first_result.error();
}
int first_version = first_result.value();
// Apply migrations second time (should be no-op)
auto second_result = applyAllMigrations();
if (!second_result) {
return second_result.error();
}
int second_version = second_result.value();
// Versions should match
return first_version == second_version;
}
ConnectionPool* getPool() { return pool_.get(); }
private:
std::filesystem::path temp_db_path_;
std::filesystem::path db_file_;
std::unique_ptr<ConnectionPool> pool_;
};
} // anonymous namespace
// Shared fixture (created once, reused across all tests)
MigrationTestFixture& getFixture() {
static MigrationTestFixture instance;
return instance;
}
// ============================================================================
// Migration Execution Tests
// ============================================================================
TEST_CASE("All migrations execute successfully", "[catch2][unit][metadata][migration]") {
auto& fixture = getFixture();
SECTION("Migrations reach expected version") {
auto expected_result = fixture.getExpectedLatestVersion();
REQUIRE(expected_result);
auto actual_result = fixture.applyAllMigrations();
REQUIRE(actual_result);
int expected_version = expected_result.value();
int actual_version = actual_result.value();
INFO("Expected version: " << expected_version);
INFO("Actual version: " << actual_version);
REQUIRE(actual_version == expected_version);
REQUIRE(actual_version > 0); // Sanity check: should have at least one migration
}
SECTION("Migration version is at least v34 (semantic neighbor adjacency order index)") {
// Floor checks that all expected schema is present:
// v28: feedback_events
// v31: semantic_duplicate_groups
// v32: idx_kg_edges_uq (audit fix B — addEdgesUnique INSERT OR IGNORE)
// v33: idx_metadata_doc_key + idx_kg_doc_entities_doc_extractor
// (audit fixes M2 + M4)
// v34: idx_kg_edges_src_rel_weight_time for graph expansion reads
auto result = fixture.applyAllMigrations();
REQUIRE(result);
int version = result.value();
REQUIRE(version >= 34);
INFO("Current migration version: " << version);
}
}
TEST_CASE("Migration manager provides correct version information",
"[catch2][unit][metadata][migration]") {
// Use a fresh fixture for this test since we need to verify version progression from 0
MigrationTestFixture fixture;
SECTION("Latest available version is retrievable before migration") {
auto expected_result = fixture.getExpectedLatestVersion();
REQUIRE(expected_result);
int latest_version = expected_result.value();
REQUIRE(latest_version > 0);
INFO("Latest available migration version: " << latest_version);
}
SECTION("Current version increases after migration") {
auto pool = fixture.getPool();
// Get initial version (should be 0 for fresh DB)
int initial_version = 0;
auto init_result = pool->withConnection([&](Database& db) -> Result<void> {
MigrationManager mm(db);
auto init = mm.initialize();
if (!init)
return init.error();
auto ver = mm.getCurrentVersion();
if (!ver)
return ver.error();
initial_version = ver.value();
return Result<void>();
});
REQUIRE(init_result);
REQUIRE(initial_version == 0);
// Apply migrations
auto migrate_result = fixture.applyAllMigrations();
REQUIRE(migrate_result);
int final_version = migrate_result.value();
REQUIRE(final_version > initial_version);
}
}
TEST_CASE("Migrations are idempotent", "[catch2][unit][metadata][migration]") {
auto& fixture = getFixture();
SECTION("Re-running migrations does not change version") {
auto idempotency_result = fixture.testIdempotency();
REQUIRE(idempotency_result);
REQUIRE(idempotency_result.value() == true);
}
SECTION("Re-running migrations does not fail") {
// First application
auto first_result = fixture.applyAllMigrations();
REQUIRE(first_result);
// Second application (should succeed without errors)
auto second_result = fixture.applyAllMigrations();
REQUIRE(second_result);
}
}
TEST_CASE("Migration manager handles errors gracefully", "[catch2][unit][metadata][migration]") {
auto& fixture = getFixture();
auto pool = fixture.getPool();
SECTION("Initialize can be called multiple times") {
auto result = pool->withConnection([](Database& db) -> Result<void> {
MigrationManager mm(db);
auto first_init = mm.initialize();
REQUIRE(first_init);
auto second_init = mm.initialize();
REQUIRE(second_init);
return Result<void>();
});
REQUIRE(result);
}
}
// ============================================================================
// Schema Validation After Migration
// ============================================================================
TEST_CASE("Core tables exist after migration", "[catch2][unit][metadata][migration]") {
auto& fixture = getFixture();
// Apply migrations first
auto migration_result = fixture.applyAllMigrations();
REQUIRE(migration_result);
auto pool = fixture.getPool();
SECTION("Documents table exists") {
bool table_exists = false;
auto result = pool->withConnection([&](Database& db) -> Result<void> {
auto stmt_result = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='documents'");
if (!stmt_result)
return stmt_result.error();
auto stmt = std::move(stmt_result.value());
auto step_result = stmt.step();
if (!step_result)
return step_result.error();
table_exists = step_result.value();
return Result<void>();
});
REQUIRE(result);
REQUIRE(table_exists);
}
SECTION("Symbol_metadata table exists (v16)") {
bool table_exists = false;
auto result = pool->withConnection([&](Database& db) -> Result<void> {
auto stmt_result = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='symbol_metadata'");
if (!stmt_result)
return stmt_result.error();
auto stmt = std::move(stmt_result.value());
auto step_result = stmt.step();
if (!step_result)
return step_result.error();
table_exists = step_result.value();
return Result<void>();
});
REQUIRE(result);
REQUIRE(table_exists);
}
SECTION("Symbol_metadata unique index exists (v25)") {
bool index_exists = false;
auto result = pool->withConnection([&](Database& db) -> Result<void> {
auto stmt_result = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND "
"name='ux_symbol_document_qualified'");
if (!stmt_result)
return stmt_result.error();
auto stmt = std::move(stmt_result.value());
auto step_result = stmt.step();
if (!step_result)
return step_result.error();
index_exists = step_result.value();
return Result<void>();
});
REQUIRE(result);
REQUIRE(index_exists);
}
SECTION("Metadata table exists") {
bool table_exists = false;
auto result = pool->withConnection([&](Database& db) -> Result<void> {
auto stmt_result =
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='metadata'");
if (!stmt_result)
return stmt_result.error();
auto stmt = std::move(stmt_result.value());
auto step_result = stmt.step();
if (!step_result)
return step_result.error();
table_exists = step_result.value();
return Result<void>();
});
REQUIRE(result);
REQUIRE(table_exists);
}
}