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>
242 lines
8.7 KiB
C++
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;
|
|
}
|