oatpp-authkit/test/test_decorator_migrations.cpp
Uwe Schuster 792e509b67 #13: TemporalRepository save — stable-live + historical-copy semantics
The decorator's save() flow now preserves the live row's id PK across
updates and captures each prior version as a fresh row with a new id.
This unblocks fewo-webapp#459: the consumer's composite-FK schema needs
stable child references to the live row (UNIQUE(entity_id, valid_until)
with ON UPDATE CASCADE on every child FK), which the previous
close-then-insert flow couldn't provide.

New flow on update (when a live row exists for entity_id):
  1. Clone the live row in memory (cloneDto via oatpp reflection),
     assign a fresh id and set valid_until=now, save → INSERT historical.
  2. Set the new dto's id=live.id (preserve PK), valid_from=now,
     valid_until=SENTINEL, save → inner UPDATEs the live row in place by
     PK.

Inner adapter contract changes from "upsert keyed by (entity_id,
valid_from)" to "upsert keyed by id (per-row PK)". TemporalFieldTraits
gains an id() accessor; OATPP_AUTHKIT_REGISTER_TEMPORAL grows from 4 to
5 args (Dto + IdMember + EntityIdMember + FromMember + UntilMember).

Tests: test_repository_decorators asserts livePk stability across saves
and fresh historicalPk per version; remaining decorator tests updated to
the 5-arg macro form. README's TemporalRepository.hpp row rewritten to
describe the new write semantics.

Bumped CMake version 0.7.0 → 0.8.0 (semantic break — save() no longer
reallocates the live PK; consumers depending on the old contract need
audit).

Closes #13

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:10:03 +02:00

239 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, 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<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;
}