oatpp-authkit/test/test_repository_decorators.cpp
Uwe Schuster 08cd32446f #8: TemporalRepository<T> + ScopeGuardRepository<T> decorators
Two cross-cutting decorators that wrap any Repository<TDto> from #7.

TemporalRepository<TDto>:
- Requires TDto : ITemporalEntity (compile-time static_assert).
- save() finds the existing live version, closes its valid_until, and
  inserts a new row at valid_until = '9999-12-31T23:59:59Z' sentinel.
- findByEntityId() returns the live row; findByEntityIdAt(id, at) does
  the [valid_from, valid_until) point-in-time read.
- list() returns live rows only; history(id) returns all versions
  ordered by valid_from. Implements IHistoryRepository<TDto>.
- softDelete closes the live row without inserting a new version.
- Clock and id-generator are constructor-injected (defaults: system_clock
  + 32-char hex from mt19937_64) so the unit tests are deterministic.

The decorator's contract on the inner repository: list() must expose all
rows including historical, and save() must be upsert keyed by
(entity_id, valid_from). Documented on the class.

ScopeGuardRepository<TDto>:
- Generic; knows nothing about "property"/"tenant"/etc. Constructor
  takes a std::function<bool(ActorContext, TDto)> predicate plus a
  std::function<ActorContext()> accessor (so a single instance can
  serve many requests with different actors).
- list() filters; findByEntityId/save/softDelete throw
  ScopeDeniedException on deny.

Tests cover the five acceptance criteria from the issue body:
  - Temporal save closes the prior version
  - Live read returns only the row with valid_until = sentinel
  - Point-in-time read returns the version live at that time
  - History returns all versions in order
  - Scope guard short-circuits when the predicate returns false

ctest: 6/6 green (4 prior + repository_interface + repository_decorators).

Closes #8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:51:39 +02:00

295 lines
11 KiB
C++

// Tests for the oatpp-authkit#8 repository decorators (TemporalRepository,
// ScopeGuardRepository). Validates the acceptance criteria from the issue:
// - Temporal save closes the prior version
// - Live read returns only the row with valid_until = sentinel
// - Point-in-time read returns the version live at that time
// - History returns all versions in order
// - Scope guard short-circuits when the predicate returns false
//
// The in-memory backing store keys rows by (entity_id, valid_from), matching
// the upsert contract documented on TemporalRepository<TDto>.
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/ITemporalEntity.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/ActorContext.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 {
class MockTemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity {
DTO_INIT(MockTemporalDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, name);
DTO_FIELD(String, scope); // For ScopeGuardRepository — emulates a property_id-style field.
};
#include OATPP_CODEGEN_END(DTO)
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)
// In-memory adapter: rows keyed by (entity_id, valid_from). save() upserts.
// Exposes ALL rows via list() — the temporal decorator filters to live.
class InMemoryAllRows : public oatpp_authkit::repo::Repository<MockTemporalDto> {
std::map<std::pair<std::string, std::string>, oatpp::Object<MockTemporalDto>> rows;
public:
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
// Not used by TemporalRepository — included for interface completeness.
for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second;
return nullptr;
}
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<MockTemporalDto>& dto) override {
rows[{*dto->entity_id, *dto->valid_from}] = dto;
}
void softDelete(const oatpp::String& id) override {
for (auto it = rows.begin(); it != rows.end(); ) {
if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it;
}
}
};
// Fixed-time clock for deterministic tests. Returns successive timestamps
// 1000ms apart so point-in-time reads can pick a value strictly between
// version boundaries.
struct StepClock {
int64_t ms{1700000000000LL};
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
};
void test_save_closes_prior_version_and_inserts_new() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); },
[]{ return oatpp::String("alice"); });
// First save — entity_id auto-allocated, valid_from = now1, valid_until = SENTINEL.
auto v1 = MockTemporalDto::createShared();
v1->name = oatpp::String("alice v1");
repo.save(v1);
REQUIRE(v1->entity_id);
REQUIRE(std::string(*v1->valid_until)
== TemporalRepository<MockTemporalDto>::SENTINEL);
REQUIRE(inner->list()->size() == 1);
// Second save — old version's valid_until is closed; new live row inserted.
auto v2 = MockTemporalDto::createShared();
v2->entity_id = v1->entity_id;
v2->name = oatpp::String("alice v2");
repo.save(v2);
auto allAfter = inner->list();
REQUIRE(allAfter->size() == 2);
int liveCount = 0;
for (auto& row : *allAfter) {
if (std::string(*row->valid_until)
== TemporalRepository<MockTemporalDto>::SENTINEL) ++liveCount;
}
REQUIRE(liveCount == 1); // Only one row is live.
}
void test_live_read_returns_only_sentinel_row() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); });
auto v1 = MockTemporalDto::createShared();
v1->entity_id = oatpp::String("abc");
v1->name = oatpp::String("v1");
repo.save(v1);
auto v2 = MockTemporalDto::createShared();
v2->entity_id = oatpp::String("abc");
v2->name = oatpp::String("v2");
repo.save(v2);
auto live = repo.findByEntityId(oatpp::String("abc"));
REQUIRE(live);
REQUIRE(std::string(*live->name) == "v2");
auto liveList = repo.list();
REQUIRE(liveList->size() == 1);
REQUIRE(std::string(*(*liveList)[0]->name) == "v2");
}
void test_point_in_time_read_returns_version_live_at_t() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
StepClock clock;
int64_t t1 = clock.ms;
auto repo = std::make_shared<TemporalRepository<MockTemporalDto>>(
inner, [&clock]{ return clock(); });
auto v1 = MockTemporalDto::createShared();
v1->entity_id = oatpp::String("abc");
v1->name = oatpp::String("v1");
repo->save(v1); // valid_from = t1, valid_until = SENTINEL → t2 after save 2
// Pick a point strictly inside v1's interval [t1, t2).
int64_t betweenSaves = t1 + 500; // t2 = t1 + 1000 with our StepClock
auto v2 = MockTemporalDto::createShared();
v2->entity_id = oatpp::String("abc");
v2->name = oatpp::String("v2");
repo->save(v2); // closes v1 at t2; v2 valid_from = t2
// Read as-of betweenSaves — should still see v1.
auto atT2 = repo->findByEntityIdAt(oatpp::String("abc"),
TemporalAt::at(betweenSaves));
REQUIRE(atT2);
REQUIRE(std::string(*atT2->name) == "v1");
// Read as-of t1 — should also see v1.
auto atT1 = repo->findByEntityIdAt(oatpp::String("abc"), TemporalAt::at(t1));
REQUIRE(atT1);
REQUIRE(std::string(*atT1->name) == "v1");
}
void test_history_returns_versions_in_order() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); });
for (const char* n : {"v1", "v2", "v3"}) {
auto dto = MockTemporalDto::createShared();
dto->entity_id = oatpp::String("abc");
dto->name = oatpp::String(n);
repo.save(dto);
}
auto h = repo.history(oatpp::String("abc"));
REQUIRE(h->size() == 3);
REQUIRE(std::string(*(*h)[0]->name) == "v1");
REQUIRE(std::string(*(*h)[1]->name) == "v2");
REQUIRE(std::string(*(*h)[2]->name) == "v3");
}
void test_soft_delete_closes_live_without_new_version() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<MockTemporalDto> repo(inner,
[clock]{ return (*clock)(); });
auto v = MockTemporalDto::createShared();
v->entity_id = oatpp::String("abc");
v->name = oatpp::String("dead");
repo.save(v);
repo.softDelete(oatpp::String("abc"));
REQUIRE(!repo.findByEntityId(oatpp::String("abc")));
auto remaining = inner->list();
REQUIRE(remaining->size() == 1); // historical row still exists
REQUIRE(std::string(*(*remaining)[0]->valid_until)
!= TemporalRepository<MockTemporalDto>::SENTINEL);
}
// ---- ScopeGuardRepository ----
void test_scope_guard_denies_when_predicate_false() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
// Seed inner with two rows in different scopes.
for (const char* sc : {"prop-A", "prop-B"}) {
auto dto = MockTemporalDto::createShared();
dto->entity_id = oatpp::String(sc); // reuse scope as id for simplicity
dto->valid_from = oatpp::String("2020-01-01T00:00:00Z");
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
dto->name = oatpp::String(sc);
dto->scope = oatpp::String(sc);
inner->save(dto);
}
ActorContext actor;
actor.userId = "u1";
actor.allowedScopes = {"prop-A"};
ScopeGuardRepository<MockTemporalDto> guarded(inner,
// Predicate: only allow rows whose scope is in the actor's allowedScopes.
[](const ActorContext& a, const oatpp::Object<MockTemporalDto>& d) {
if (!d || !d->scope) return false;
const std::string s = std::string(*d->scope);
for (auto& as : a.allowedScopes) if (as == s) return true;
return false;
},
[actor]{ return actor; });
// list filters to allowed rows only.
auto allowed = guarded.list();
REQUIRE(allowed->size() == 1);
REQUIRE(std::string(*(*allowed)[0]->scope) == "prop-A");
// findByEntityId on a denied row throws.
bool threwOnFind = false;
try { (void)guarded.findByEntityId(oatpp::String("prop-B")); }
catch (const ScopeDeniedException&) { threwOnFind = true; }
REQUIRE(threwOnFind);
// findByEntityId on an allowed row returns it.
auto okRow = guarded.findByEntityId(oatpp::String("prop-A"));
REQUIRE(okRow);
// save of a denied scope throws.
auto evil = MockTemporalDto::createShared();
evil->entity_id = oatpp::String("xxx");
evil->valid_from = oatpp::String("2020-01-01T00:00:00Z");
evil->valid_until = oatpp::String("9999-12-31T23:59:59Z");
evil->scope = oatpp::String("prop-B");
bool threwOnSave = false;
try { guarded.save(evil); }
catch (const ScopeDeniedException&) { threwOnSave = true; }
REQUIRE(threwOnSave);
// softDelete of a denied row throws.
bool threwOnDelete = false;
try { guarded.softDelete(oatpp::String("prop-B")); }
catch (const ScopeDeniedException&) { threwOnDelete = true; }
REQUIRE(threwOnDelete);
}
} // namespace
int main() {
test_save_closes_prior_version_and_inserts_new();
test_live_read_returns_only_sentinel_row();
test_point_in_time_read_returns_version_live_at_t();
test_history_returns_versions_in_order();
test_soft_delete_closes_live_without_new_version();
test_scope_guard_denies_when_predicate_false();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}