// Tests for authkit#14 — declarative schema contract (D-replace). // // Verifies SchemaBuilder composes decorator contributions into a single // CREATE TABLE per entity, sidecar tables emit separately, and // SchemaContract::verify catches missing columns/tables. // // No real SQL engine — `exec` collects emitted DDL strings and `probe` // is driven by a fake "known-rows" set. #include "oatpp-authkit/repo/SchemaContract.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); 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; // A minimal "concrete repo" stand-in that contributes the entity_id + // id + name columns. Concrete repos in real consumers (e.g. fewo's // ConcretePersonRepository) would expose kSchema the same way. class TestConcreteRepo { public: inline static constexpr ColumnSpec kColumns[] = { {"id", "INTEGER PRIMARY KEY AUTOINCREMENT"}, {"entity_id", "TEXT NOT NULL"}, {"name", "TEXT NOT NULL"}, }; inline static constexpr DecoratorSchema kSchema = { "TestConcreteRepo", kColumns, sizeof(kColumns) / sizeof(kColumns[0]), nullptr, 0, nullptr, 0, }; }; struct FakeDb { std::vector execLog; std::set knownTrue; SqlExec exec() { return [this](const std::string& s){ execLog.push_back(s); }; } SqlProbe probe() { return [this](const std::string& s){ return knownTrue.count(s) > 0; }; } }; bool contains(const std::string& haystack, const std::string& needle) { return haystack.find(needle) != std::string::npos; } // Test 1: instantiate substitutes {table}. void test_instantiate() { REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons"); REQUIRE(instantiate("ix_{table}_a_{table}_b", "p") == "ix_p_a_p_b"); REQUIRE(instantiate("no placeholder", "tbl") == "no placeholder"); } // Test 2: SchemaBuilder emits one CREATE per entity table with all // composed columns and a CREATE INDEX per index spec. void test_builder_composes_entity_table() { FakeDb db; SchemaBuilder< TestConcreteRepo, TemporalRepository, ScopeGuardRepository>::create("persons", db.exec()); // No sidecars from this stack → one CREATE TABLE + one CREATE INDEX. REQUIRE(db.execLog.size() == 2); const auto& table = db.execLog[0]; REQUIRE(contains(table, "CREATE TABLE IF NOT EXISTS persons")); REQUIRE(contains(table, "id INTEGER PRIMARY KEY AUTOINCREMENT")); REQUIRE(contains(table, "entity_id TEXT NOT NULL")); REQUIRE(contains(table, "name TEXT NOT NULL")); REQUIRE(contains(table, "valid_from")); REQUIRE(contains(table, "valid_until")); const auto& idx = db.execLog[1]; REQUIRE(contains(idx, "CREATE UNIQUE INDEX IF NOT EXISTS ux_persons_entity_valid_until")); REQUIRE(contains(idx, "ON persons (entity_id, valid_until)")); } // Test 3: AuditLog contributes a sidecar table; entity table is unaffected. void test_builder_emits_sidecar() { FakeDb db; SchemaBuilder< TestConcreteRepo, AuditLogRepository>::create("persons", db.exec()); // sidecar (audit_log) emitted first, then entity table. REQUIRE(db.execLog.size() == 2); REQUIRE(contains(db.execLog[0], "CREATE TABLE IF NOT EXISTS audit_log")); REQUIRE(contains(db.execLog[0], "actor_user_id TEXT")); REQUIRE(contains(db.execLog[0], "timestamp_ms INTEGER NOT NULL")); REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons")); // AuditLog adds nothing to the entity table. REQUIRE(!contains(db.execLog[1], "actor_user_id")); } // Test 4: ScopeGuard contributes nothing; full stack emits sidecars from // AuditLog + entity table with Temporal columns + temporal index. void test_builder_full_stack() { FakeDb db; SchemaBuilder< TestConcreteRepo, TemporalRepository, ScopeGuardRepository, AuditLogRepository>::create("persons", db.exec()); // audit_log sidecar + persons table + temporal index = 3 REQUIRE(db.execLog.size() == 3); REQUIRE(contains(db.execLog[0], "audit_log")); REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons")); REQUIRE(contains(db.execLog[1], "valid_until")); REQUIRE(contains(db.execLog[2], "ux_persons_entity_valid_until")); } // Defined at namespace scope: local classes can't carry static data members. struct DupRepo { inline static constexpr ColumnSpec kCols[] = { {"valid_from", "TEXT NOT NULL DEFAULT 'override'"}, }; inline static constexpr DecoratorSchema kSchema = { "DupRepo", kCols, sizeof(kCols)/sizeof(kCols[0]), nullptr, 0, nullptr, 0, }; }; // Test 5: column dedup — if two layers contribute the same column name, // first wins, no duplicates in the CREATE. void test_builder_dedups_columns() { FakeDb db; SchemaBuilder< TestConcreteRepo, TemporalRepository, DupRepo>::create("persons", db.exec()); // The DupRepo's contribution is silently skipped (Temporal got there first). REQUIRE(db.execLog.size() == 2); REQUIRE(!contains(db.execLog[0], "DEFAULT 'override'")); } // Test 6: SchemaContract::verify passes when every column is present. void test_verify_pass() { FakeDb db; // Mark every required column as present. db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'"); db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'"); db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'"); db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_from'"); db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'"); db.knownTrue.insert("SELECT 1 FROM sqlite_master WHERE type='table' AND name='audit_log'"); // Should not throw. SchemaContract< TestConcreteRepo, TemporalRepository, ScopeGuardRepository, AuditLogRepository>::verify("persons", db.probe()); } // Test 7: SchemaContract::verify throws when a required column is missing. void test_verify_throws_on_missing_column() { FakeDb db; db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'"); db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'"); db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'"); // valid_from missing on purpose. db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'"); bool threw = false; try { SchemaContract< TestConcreteRepo, TemporalRepository>::verify("persons", db.probe()); } catch (const SchemaContractViolation& e) { threw = true; REQUIRE(contains(e.what(), "valid_from")); REQUIRE(contains(e.what(), "TemporalRepository")); } REQUIRE(threw); } // Test 8: SchemaContract::verify throws when a sidecar is missing. void test_verify_throws_on_missing_sidecar() { FakeDb db; // No audit_log table registered. bool threw = false; try { SchemaContract>::verify("persons", db.probe()); } catch (const SchemaContractViolation& e) { threw = true; REQUIRE(contains(e.what(), "audit_log")); REQUIRE(contains(e.what(), "AuditLogRepository")); } REQUIRE(threw); } } // namespace int main() { test_instantiate(); test_builder_composes_entity_table(); test_builder_emits_sidecar(); test_builder_full_stack(); test_builder_dedups_columns(); test_verify_pass(); test_verify_throws_on_missing_column(); test_verify_throws_on_missing_sidecar(); std::printf("test_schema_contract: OK\n"); return 0; }