Each decorator now bundles its schema prereqs alongside its code via DecoratorPrereq (additive CREATE-IF-NOT-EXISTS) and ReshapeStep (non-idempotent reshape gated on a detectSql probe). applyDecoratorMigrations<Decorators...>(table, probe, exec, recorder) walks the listed decorators at startup, runs every PREREQ, runs every reshape step whose probe returns false. Database-agnostic — consumer wires probe/exec to their DbClient. SCHEMA_MIGRATIONS_TABLE_SQL is provided for observability; the detect-probe is the source of truth. TemporalRepository ships add_valid_from / add_valid_until / drop_unique_entity_id / composite_unique (UNIQUE(entity_id, valid_until) so close-then-insert can run in a deferred-FK transaction). AuditLogRepository ships the audit_log CREATE TABLE. ScopeGuardRepository ships nothing — exposes empty PREREQ + zero-length RESHAPE_STEPS so it can be listed in applyDecoratorMigrations alongside the schema-touching decorators without SFINAE. Closes #12 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
9.1 KiB
C++
238 lines
9.1 KiB
C++
// 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 <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, 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, 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<std::string> appliedDdl; // every exec(sql) lands here
|
|
std::set<std::string> knownTrue; // probe(sql) returns true iff sql is in this set
|
|
std::vector<std::string> 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<RecordedStep>& 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<RecordedStep> recorded;
|
|
applyDecoratorMigration<AuditLogRepository<TestDto>>(
|
|
"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<RecordedStep> recorded;
|
|
applyDecoratorMigration<ScopeGuardRepository<TestDto>>(
|
|
"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<RecordedStep> recorded;
|
|
applyDecoratorMigration<TemporalRepository<TestDto>>(
|
|
"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<RecordedStep> recorded;
|
|
applyDecoratorMigration<TemporalRepository<TestDto>>(
|
|
"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<RecordedStep> recorded;
|
|
applyDecoratorMigration<TemporalRepository<TestDto>>(
|
|
"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<RecordedStep> recorded;
|
|
applyDecoratorMigrations<
|
|
AuditLogRepository<TestDto>,
|
|
TemporalRepository<TestDto>,
|
|
ScopeGuardRepository<TestDto>>(
|
|
"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<TestDto>,
|
|
TemporalRepository<TestDto>>(
|
|
"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;
|
|
}
|