oatpp-authkit/test/test_temporal_field_traits.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

127 lines
4.5 KiB
C++

// Tests for the oatpp-authkit#10 TemporalFieldTraits<T> extension.
//
// Exercises the temporal decorator against a DTO whose column names are
// NOT entity_id / valid_from / valid_until. The trait specialisation
// supplied via OATPP_AUTHKIT_REGISTER_TEMPORAL bridges the canonical
// names used by TemporalRepository to whatever the DTO actually calls
// them — here `id`, `effective_from`, `effective_until`. Same save/close/
// history flow as the existing decorator tests; only the field names move.
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
// DTO with intentionally non-canonical column names. Without the trait,
// TemporalRepository<T> couldn't reach these fields.
class OddNamesDto : public oatpp::DTO {
DTO_INIT(OddNamesDto, DTO)
DTO_FIELD(String, row_pk);
DTO_FIELD(String, id); // entity_id (logical), per the original test intent
DTO_FIELD(String, effective_from);
DTO_FIELD(String, effective_until);
DTO_FIELD(String, payload);
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, row_pk, id, effective_from, effective_until)
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
// Same in-memory adapter shape as the decorator tests — keys rows by
// (id, effective_from), exposes ALL rows via list().
class InMemoryAllRows : public oatpp_authkit::repo::Repository<OddNamesDto> {
std::map<std::string, oatpp::Object<OddNamesDto>> rows; // keyed by row_pk
public:
oatpp::Object<OddNamesDto> findByEntityId(const oatpp::String& id) override {
for (auto& kv : rows) if (kv.second->id && std::string(*kv.second->id) == std::string(*id)) return kv.second;
return nullptr;
}
oatpp::Vector<oatpp::Object<OddNamesDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<OddNamesDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<OddNamesDto>& dto) override {
rows[std::string(*dto->row_pk)] = dto;
}
void softDelete(const oatpp::String& id) override {
for (auto it = rows.begin(); it != rows.end(); ) {
if (it->second->id && std::string(*it->second->id) == std::string(*id)) it = rows.erase(it); else ++it;
}
}
};
struct StepClock {
int64_t ms{1700000000000LL};
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
};
void test_save_close_and_history_against_renamed_columns() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<OddNamesDto> repo(inner,
[clock]{ return (*clock)(); });
// First save — id auto-allocated, effective_from = now, effective_until = SENTINEL.
auto v1 = OddNamesDto::createShared();
v1->payload = oatpp::String("first");
repo.save(v1);
REQUIRE(v1->id);
REQUIRE(std::string(*v1->effective_until)
== TemporalRepository<OddNamesDto>::SENTINEL);
// Second save — close prior, insert new live.
auto v2 = OddNamesDto::createShared();
v2->id = v1->id;
v2->payload = oatpp::String("second");
repo.save(v2);
auto live = repo.findByEntityId(v1->id);
REQUIRE(live);
REQUIRE(std::string(*live->payload) == "second");
// history() returns both versions, oldest first.
auto h = repo.history(v1->id);
REQUIRE(h->size() == 2);
REQUIRE(std::string(*(*h)[0]->payload) == "first");
REQUIRE(std::string(*(*h)[1]->payload) == "second");
// softDelete closes live.
repo.softDelete(v1->id);
REQUIRE(!repo.findByEntityId(v1->id));
}
} // namespace
int main() {
test_save_close_and_history_against_renamed_columns();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}