// Tests for authkit#12 — per-decorator migration kit. // // Verifies the runner against a tiny in-memory "schema" (a set of applied // step names + a boolean per "table-has-column" probe). No real SQL // engine — the kit's contract is to call `probe`/`exec` in the right // shape, and that's what we test. // // Coverage: // - PREREQ runs once per applyDecoratorMigration call // - RESHAPE_STEPS skip when probe returns true (already applied) // - RESHAPE_STEPS apply when probe returns false (fresh) // - Re-running on a fully-applied schema is a no-op // - Partial-state recovery: probes mark some steps as applied, runner // applies only the rest // - {table} placeholder substitution // - StepRecorder fires for every applied step, never for skipped // - ScopeGuardRepository's empty migrations are a clean no-op #include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp-authkit/repo/TemporalRepository.hpp" #include "oatpp-authkit/repo/AuditLogRepository.hpp" #include "oatpp-authkit/repo/ScopeGuardRepository.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp" #include "oatpp/core/macro/codegen.hpp" #include "oatpp/core/Types.hpp" #include #include #include #include #include #define REQUIRE(cond) do { \ if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \ #cond, __FILE__, __LINE__); std::abort(); } } while (0) #include OATPP_CODEGEN_BEGIN(DTO) namespace { class TestDto : public oatpp::DTO { DTO_INIT(TestDto, DTO) DTO_FIELD(String, id); // per-row PK DTO_FIELD(String, entity_id); DTO_FIELD(String, valid_from); DTO_FIELD(String, valid_until); DTO_FIELD(String, name); }; } // namespace #include OATPP_CODEGEN_END(DTO) OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, id, entity_id, valid_from, valid_until) namespace { using namespace oatpp_authkit::repo; // Tiny fake schema for the runner to talk to. struct FakeDb { std::set appliedDdl; // every exec(sql) lands here std::set knownTrue; // probe(sql) returns true iff sql is in this set std::vector execLog; // ordered exec history SqlExec exec() { return [this](const std::string& sql) { appliedDdl.insert(sql); execLog.push_back(sql); }; } SqlProbe probe() { return [this](const std::string& sql) { return knownTrue.count(sql) > 0; }; } }; struct RecordedStep { std::string decorator; std::string table; std::string step; }; StepRecorder makeRecorder(std::vector& out) { return [&out](const char* d, const std::string& t, const char* s) { out.push_back({d, t, s}); }; } // Test 1: instantiate substitutes {table} everywhere. void test_instantiate() { REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons"); REQUIRE(instantiate("ALTER TABLE {table} ADD COLUMN x; CREATE INDEX i ON {table}(y)", "addr") == "ALTER TABLE addr ADD COLUMN x; CREATE INDEX i ON addr(y)"); REQUIRE(instantiate("no placeholder here", "tbl") == "no placeholder here"); REQUIRE(instantiate("", "tbl") == ""); } // Test 2: AuditLog has PREREQ, no RESHAPE — fresh apply records exactly one step. void test_audit_fresh_apply() { FakeDb db; std::vector recorded; applyDecoratorMigration>( "ignored", db.probe(), db.exec(), makeRecorder(recorded)); REQUIRE(db.execLog.size() == 1); REQUIRE(db.execLog[0].find("CREATE TABLE IF NOT EXISTS audit_log") != std::string::npos); REQUIRE(recorded.size() == 1); REQUIRE(recorded[0].decorator == std::string("AuditLogRepository")); REQUIRE(recorded[0].step == std::string("PREREQ_SQL")); } // Test 3: ScopeGuard has neither — runner is a no-op. void test_scopeguard_noop() { FakeDb db; std::vector recorded; applyDecoratorMigration>( "persons", db.probe(), db.exec(), makeRecorder(recorded)); REQUIRE(db.execLog.empty()); REQUIRE(recorded.empty()); } // Test 4: TemporalRepository fresh apply — probe returns false for every // detect, every reshape step runs, recorder fires per step. void test_temporal_fresh_apply() { FakeDb db; std::vector recorded; applyDecoratorMigration>( "persons", db.probe(), db.exec(), makeRecorder(recorded)); // Four reshape steps, all executed (drop_unique_entity_id has a noop // apply but still counts as executed). REQUIRE(db.execLog.size() == 4); REQUIRE(recorded.size() == 4); REQUIRE(recorded[0].step == std::string("add_valid_from")); REQUIRE(recorded[1].step == std::string("add_valid_until")); REQUIRE(recorded[2].step == std::string("drop_unique_entity_id")); REQUIRE(recorded[3].step == std::string("composite_unique")); // {table} substituted in apply SQL. REQUIRE(db.execLog[0].find("ALTER TABLE persons") != std::string::npos); REQUIRE(db.execLog[3].find("ON persons(entity_id, valid_until)") != std::string::npos); } // Test 5: re-apply on already-shaped table is fully no-op. void test_temporal_reapply_noop() { FakeDb db; // Pre-populate probe truth set with every detect query — every step // is "already applied". db.knownTrue.insert(instantiate( "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons")); db.knownTrue.insert(instantiate( "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'", "persons")); db.knownTrue.insert(instantiate( "SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'", "persons")); db.knownTrue.insert(instantiate( "SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'", "persons")); std::vector recorded; applyDecoratorMigration>( "persons", db.probe(), db.exec(), makeRecorder(recorded)); REQUIRE(db.execLog.empty()); REQUIRE(recorded.empty()); } // Test 6: partial state recovery — only the missing step runs. void test_temporal_partial_recovery() { FakeDb db; // valid_from already exists, valid_until doesn't, composite index doesn't. db.knownTrue.insert(instantiate( "SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons")); std::vector recorded; applyDecoratorMigration>( "persons", db.probe(), db.exec(), makeRecorder(recorded)); REQUIRE(db.execLog.size() == 3); // skipped add_valid_from, ran the rest REQUIRE(recorded.size() == 3); REQUIRE(recorded[0].step == std::string("add_valid_until")); REQUIRE(recorded[1].step == std::string("drop_unique_entity_id")); REQUIRE(recorded[2].step == std::string("composite_unique")); } // Test 7: variadic stack runs every decorator's migrations in order. void test_stack_runs_in_order() { FakeDb db; std::vector recorded; applyDecoratorMigrations< AuditLogRepository, TemporalRepository, ScopeGuardRepository>( "persons", db.probe(), db.exec(), makeRecorder(recorded)); // Audit (1) + Temporal (4) + ScopeGuard (0) = 5 REQUIRE(recorded.size() == 5); REQUIRE(recorded[0].decorator == std::string("AuditLogRepository")); REQUIRE(recorded[1].decorator == std::string("TemporalRepository")); REQUIRE(recorded[4].decorator == std::string("TemporalRepository")); // ScopeGuard contributed zero — never appears in the log. for (const auto& r : recorded) { REQUIRE(r.decorator != std::string("ScopeGuardRepository")); } } // Test 8: schema_migrations table SQL is a CREATE IF NOT EXISTS (idempotent // by construction). Asserting the literal so consumers can rely on the // column shape. void test_schema_migrations_table_sql() { std::string sql(SCHEMA_MIGRATIONS_TABLE_SQL); REQUIRE(sql.find("CREATE TABLE IF NOT EXISTS oatpp_authkit_schema_migrations") != std::string::npos); REQUIRE(sql.find("decorator") != std::string::npos); REQUIRE(sql.find("table_name") != std::string::npos); REQUIRE(sql.find("step") != std::string::npos); REQUIRE(sql.find("applied_at") != std::string::npos); REQUIRE(sql.find("PRIMARY KEY (decorator, table_name, step)") != std::string::npos); } // Test 9: missing recorder is fine (default-constructed callable is nullable). void test_recorder_optional() { FakeDb db; applyDecoratorMigrations< AuditLogRepository, TemporalRepository>( "persons", db.probe(), db.exec()); // no recorder REQUIRE(db.execLog.size() == 5); } } // namespace int main() { test_instantiate(); test_audit_fresh_apply(); test_scopeguard_noop(); test_temporal_fresh_apply(); test_temporal_reapply_noop(); test_temporal_partial_recovery(); test_stack_runs_in_order(); test_schema_migrations_table_sql(); test_recorder_optional(); std::printf("test_decorator_migrations: OK\n"); return 0; }