oatpp-authkit/test/test_schema_contract.cpp
Uwe Schuster 606db5a109 #14 PR 0: replace imperative migration kit with declarative SchemaContract
D-replace per #14: rip out PREREQ + RESHAPE_STEPS + applyDecoratorMigrations
and replace with declarative DecoratorSchema (entity columns + indexes +
sidecar tables). SchemaBuilder<Decorators...>::create composes the stack
into a single CREATE TABLE per entity table; SchemaContract::verify
introspects-and-asserts at runtime so code can never run against an
under-migrated DB. Atlas (atlasgo.io) becomes the authority for schema
evolution between deploys — decorator code never runs ALTER at runtime.

- TemporalRepository contributes valid_from/valid_until + UNIQUE composite index
- AuditLogRepository contributes the audit_log sidecar table
- ScopeGuardRepository declares empty contributions for clean stacking
- 8 new tests in test_schema_contract.cpp covering compose / dedup / verify
- README updated; bumped 0.8.0 → 0.9.0

fewo-webapp does not yet call applyDecoratorMigrations, so this is a
clean cut — no consumer-side breakage. PRs 1-4 (role_templates,
user_property_permissions, user_group_permissions, users) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:14:51 +02:00

242 lines
8.7 KiB
C++

// 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 <cassert>
#include <cstdio>
#include <set>
#include <string>
#include <vector>
#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<std::string> execLog;
std::set<std::string> 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<TestDto>,
ScopeGuardRepository<TestDto>>::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<TestDto>>::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<TestDto>,
ScopeGuardRepository<TestDto>,
AuditLogRepository<TestDto>>::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<TestDto>,
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<TestDto>,
ScopeGuardRepository<TestDto>,
AuditLogRepository<TestDto>>::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<TestDto>>::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<AuditLogRepository<TestDto>>::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;
}