ores.cpp.eventing-integration-test.nats_integration_test
Table of Contents
Write an entity via its repository, assert the resulting NATS
entity-changed notification is observed on the wire.
eventing-integration-test profile.
See the Template variable reference for the complete list of available variables and their semantics.
1. Template
The full template source. Edit here and re-tangle with
compass build --direct tangle_codegen_templates to regenerate
library/templates/cpp_nats_integration_test.cpp.mustache.
{{! GENERATED FILE — tangled from projects/ores.codegen/library/templates/ores.cpp.eventing-integration-test.nats_integration_test.org. Edit the org source. }}
{{{cpp_license}}}
{{#domain_entity}}
#include "ores.database/domain/context.hpp"
{{#sql.system_scope}}
// System-scoped entity (see the SQL flags): rows belong to the system
// tenant, so the write context below is stamped system and needs the
// tenant_id helpers.
#include "ores.utility/uuid/tenant_id.hpp"
{{/sql.system_scope}}
{{#has_system_tenant_fks}}
// A seeded parent is system-tenant reference data (its soft FK carries
// :use_system_tenant:), so its row is forced to the system tenant and
// written under a system-scoped context, and the tenant_id helpers are
// needed.
#include "ores.utility/uuid/tenant_id.hpp"
{{/has_system_tenant_fks}}
#include "ores.eventing.api/domain/entity_event.hpp"
#include "ores.eventing.api/domain/entity_event_traits.hpp"
#include "ores.eventing.api/domain/event_traits.hpp"
#include "ores.eventing.api/service/event_bus.hpp"
#include "ores.eventing.core/service/entity_event_publisher.hpp"
#include "ores.eventing.core/service/postgres_event_source.hpp"
#include "ores.logging/make_logger.hpp"
#include "ores.nats/domain/wire_codec.hpp"
#include "ores.nats/service/client.hpp"
#include "ores.{{component_include}}/domain/{{entity_singular}}.hpp"
#include "ores.{{component_include}}/domain/{{entity_singular}}_json_io.hpp" // IWYU pragma: keep.
#include "ores.{{component_include}}/eventing/{{entity_singular}}_event.hpp"
#include "ores.{{component_include}}/messaging/{{entity_singular}}_protocol.hpp"
#include "ores.{{component_include}}/{{generator_facet_name}}/{{entity_singular}}_generator.hpp"
#include "ores.{{component_core}}/repository/{{entity_singular}}_repository.hpp"
#include "ores.{{component_core}}/service/{{entity_singular}}_service.hpp"
{{#party_scoped_writes}}
#include "ores.refdata.api/generators/party_generator.hpp"
#include "ores.refdata.core/repository/party_repository.hpp"
{{/party_scoped_writes}}
{{#seed_party}}
// Party seeds (mandatory party_id soft FKs, direct or via a parent's own
// mandatory party_id FK): the party generator and repository are used
// regardless of the child's generator facet, hence the fully-qualified
// refdata paths.
#include "ores.refdata.api/generators/party_generator.hpp"
#include "ores.refdata.core/repository/party_repository.hpp"
{{/seed_party}}
{{#seed_country_sentinel}}
// Country sentinel seed ({{entity_title}}'s insert trigger validates
// country_code against the countries table, and the synthetic generator
// always emits the ZZ sentinel): like the party seeds, the country
// generator and repository are used regardless of the child's generator
// facet, hence the fully-qualified refdata paths.
#include "ores.refdata.api/generators/country_generator.hpp"
#include "ores.refdata.core/repository/country_repository.hpp"
{{/seed_country_sentinel}}
{{#seed_parent_country_sentinel}}
// FK-parent sentinel seed: a seeded calendar parent's insert trigger
// validates country_code against the countries table for the write
// tenant, and the synthetic calendar generator always emits the ZZ
// sentinel -- seed it before the parent write or the parent insert is
// rejected. Like the entity-level sentinel seed, the country generator
// and repository are used regardless of the child's generator facet,
// hence the fully-qualified refdata paths.
#include "ores.refdata.api/generators/country_generator.hpp"
#include "ores.refdata.core/repository/country_repository.hpp"
{{/seed_parent_country_sentinel}}
{{#seed_parent_currency}}
// FK-parent aggregation-currency seed: a seeded portfolio parent's insert
// trigger validates aggregation_ccy against the currencies table for the
// write tenant, and the synthetic portfolio generator always emits the
// X-0 sentinel -- the test seeds it before the parent write or the
// parent insert is rejected. Like the entity-level currency seed, the
// currency generator and repository are used regardless of the child's
// generator facet, hence the fully-qualified refdata paths.
#include "ores.refdata.api/generators/currency_generator.hpp"
#include "ores.refdata.core/repository/currency_repository.hpp"
{{/seed_parent_currency}}
{{#seed_currency}}
// Aggregation-currency seed ({{entity_title}}'s insert trigger validates
// aggregation_ccy against the active currencies for the write tenant,
// and the synthetic currency generator's first code -- X-0 -- is the
// code the {{entity_singular}} generator hardcodes): like the party
// seeds, the currency generator and repository are used regardless of
// the child's generator facet, hence the fully-qualified refdata paths.
#include "ores.refdata.api/generators/currency_generator.hpp"
#include "ores.refdata.core/repository/currency_repository.hpp"
{{/seed_currency}}
{{#foreign_keys}}
{{^nullable}}
{{#parent_entity_singular}}
{{^parent_is_party}}
// Soft-FK parent seeding ({{table}}): the parent may live in another
// component, so its own component names the headers.
#include "ores.{{parent_component_include}}/{{parent_generator_facet_name}}/{{parent_entity_singular}}_generator.hpp"
#include "ores.{{parent_component_core}}/repository/{{parent_entity_singular}}_repository.hpp"
{{#parent_required_fks}}
// Grand-parent seeding ({{table}}): the parent's own mandatory soft FKs
// reference rows the test seeds before the parent, so their generator
// and repository headers are needed too.
#include "ores.{{parent_component_include}}/{{parent_generator_facet_name}}/{{parent_entity_singular}}_generator.hpp"
#include "ores.{{parent_component_core}}/repository/{{parent_entity_singular}}_repository.hpp"
{{/parent_required_fks}}
{{/parent_is_party}}
{{/parent_entity_singular}}
{{#parent_seed_snippet}}
{{#parent_seed_includes}}
// Parent-seed snippet includes ({{table}}): the parent table is
// hand-authored with no modeling org, so the snippet's generator and
// repository headers are named by the org rather than derived.
{{{parent_seed_includes}}}
{{/parent_seed_includes}}
{{/parent_seed_snippet}}
{{/nullable}}
{{/foreign_keys}}
#include "ores.testing/make_generation_context.hpp"
#include "ores.testing/nats_options_helper.hpp"
#include "ores.testing/scoped_database_helper.hpp"
#include "ores.utility/rfl/reflectors.hpp" // IWYU pragma: keep.
#include <boost/uuid/uuid_io.hpp>
#include <catch2/catch_test_macros.hpp>
{{#primary_key.is_compound}}
#include <algorithm>
{{/primary_key.is_compound}}
#include <thread>
// Proves the "write an entity, observe its NATS entity-changed
// notification" pattern end to end for {{entity_singular}} -- the
// production DB-write -> pg_notify -> postgres_event_source ->
// event_bus -> NATS publish chain, assembled directly here the same
// way the production event-registrar wires it.
namespace {
const std::string_view test_suite("{{component}}.tests");
const std::string tags("[eventing][integration]");
{{#party_scoped_writes}}
// {{entity_title}} writes are party-scoped: the session-level
// app.current_party_id GUC must be set before writing.
ores::database::context
write_test_party_and_scope_context(ores::testing::scoped_database_helper& h,
ores::utility::generation::generation_context& ctx) {
using ores::refdata::repository::party_repository;
party_repository party_repo;
auto party = ores::refdata::generators::generate_synthetic_party(ctx);
party.change_reason_code = "system.test";
auto existing = party_repo.read_latest(h.context());
for (const auto& e : existing) {
if (e.tenant_id == party.tenant_id) {
party.parent_party_id = e.id;
break;
}
}
party_repo.write(h.context(), party);
return h.context().with_party(h.tenant_id(), party.id, {party.id}, h.db_user());
}
{{/party_scoped_writes}}
}
using namespace ores::{{component}}::{{generator_facet_name}};
using ores::{{component}}::domain::{{entity_singular}};
using ores::{{component}}::repository::{{entity_singular}}_repository;
{{#seed_country_sentinel}}
using ores::refdata::repository::country_repository;
{{/seed_country_sentinel}}
{{#seed_parent_country_sentinel}}
using ores::refdata::repository::country_repository;
{{/seed_parent_country_sentinel}}
{{#seed_currency}}
using ores::refdata::repository::currency_repository;
{{/seed_currency}}
{{#seed_parent_currency}}
using ores::refdata::repository::currency_repository;
{{/seed_parent_currency}}
using ores::testing::scoped_database_helper;
using namespace ores::logging;
TEST_CASE("write_{{entity_singular}}_publishes_an_event", tags) {
auto lg(make_logger(test_suite));
scoped_database_helper h;
auto ctx = ores::testing::make_generation_context(h);
{{#sql.system_scope}}
// {{entity_title}} rows belong to the system tenant: the insert trigger
// stamps tenant_id = system, so the RLS write policy admits the write
// only under a system-scoped context -- the per-run tenant GUC would
// reject the forced row.
auto party_ctx = h.context().with_tenant(
ores::utility::uuid::tenant_id::system(), h.db_user());
{{/sql.system_scope}}
{{^sql.system_scope}}
{{#party_scoped_writes}}
auto party_ctx = write_test_party_and_scope_context(h, ctx);
{{/party_scoped_writes}}
{{^party_scoped_writes}}
auto& party_ctx = h.context();
{{/party_scoped_writes}}
{{/sql.system_scope}}
// 1. Wire the same DB-notify -> event_bus -> NATS-publish chain the
// production event-registrar wires in the live service, assembled
// directly in the test instead of via a running process.
namespace ev = ores::eventing;
ev::service::event_bus bus;
ev::service::postgres_event_source event_source(party_ctx, bus);
ores::nats::service::client nats(ores::testing::make_nats_options());
nats.connect();
REQUIRE(nats.is_connected());
using event_type = ores::{{component}}::messaging::{{entity_singular}}_event;
auto sub = bus.subscribe<event_type>([&nats](const event_type& e) {
// One payload is addressed by three subjects, so the subject is the
// collection's prefix and the action the event reports.
ev::service::publish_entity_event(
nats, ev::domain::event_subject<event_type>(e.action), e);
});
event_source.register_entity_event_mapping<event_type>("{{sql_name_base}}");
// 2. Subscribe as an external observer would, on the relative subject --
// client::subscribe() prepends the subject_prefix itself. The wildcard
// takes every action: the first write creates the row and a re-drive
// updates it, and the chain is what is under test rather than which of
// the three subjects carried it.
auto observer = nats.subscribe_buffered(
std::string(ev::domain::entity_event_traits<event_type>::subject_prefix) + ".>", 10);
// The listener thread issues LISTEN asynchronously on its own
// dedicated connection. Block until it has actually done so before
// writing -- Postgres does not queue NOTIFYs sent before a matching
// LISTEN is registered.
event_source.start();
REQUIRE(event_source.wait_until_ready());
// 3. Write -- triggers the entity's notify trigger -> pg_notify ->
// the chain wired above -> NATS.
auto v = generate_synthetic_{{entity_singular}}(ctx);
v.{{{audit_prefix}}}change_reason_code = "system.test";
{{#party_scoped_writes}}
v.{{{scaffold_prefix}}}party_id = *party_ctx.party_id();
{{/party_scoped_writes}}
{{#foreign_keys}}
{{^nullable}}
{{#parent_entity_singular}}
{{#use_system_tenant}}
// {{parent_entity_singular}} is system-tenant reference data: reference a
// seeded catalogue row instead of creating one, so the shared system
// catalogue keeps exactly the rows the populate scripts put there. The
// referencing row's insert trigger resolves the parent under the system
// tenant.
{
ores::{{parent_component}}::repository::{{parent_entity_singular}}_repository {{column}}_catalogue_repo;
const auto {{column}}_catalogue = {{column}}_catalogue_repo.read_latest(
party_ctx.with_tenant(ores::utility::uuid::tenant_id::system(), h.db_user()));
REQUIRE_FALSE({{column}}_catalogue.empty());
v.{{{group_prefix}}}{{column}} = {{column}}_catalogue.front().{{target_column}};
}
{{/use_system_tenant}}
{{^use_system_tenant}}
// Seed the active {{parent_entity_singular}} row {{table}} references:
// the insert trigger's existence check rejects a synthetic key that
// matches no active row, so the parent must be written first.
auto {{column}}_parent = ores::{{parent_component}}::{{parent_generator_facet_name}}::generate_synthetic_{{parent_entity_singular}}(ctx);
{{#parent_has_audit_group}}
{{column}}_parent.audit.change_reason_code = "system.test";
{{/parent_has_audit_group}}
{{^parent_has_audit_group}}
{{column}}_parent.change_reason_code = "system.test";
{{/parent_has_audit_group}}
{{#parent_supplies_party}}
// The {{entity_singular}} derives its own party from this
// {{parent_entity_singular}}, so the {{parent_entity_singular}} carries the
// session party rather than a fresh one. Give it any other party and the
// written row is owned by a party the session cannot see, which makes it
// invisible to the very session that wrote it.
{{column}}_parent.{{#parent_has_identity_group}}identity.{{/parent_has_identity_group}}party_id = *party_ctx.party_id();
{{/parent_supplies_party}}
{{#parent_requires_party}}
{{^parent_supplies_party}}
// {{parent_entity_singular}}'s own mandatory party_id FK (session-set in
// production) needs an active party too: seed one, attached under the
// tenant's root party like the direct-party branch below.
auto {{column}}_party = ores::refdata::generators::generate_synthetic_party(ctx);
{{column}}_party.change_reason_code = "system.test";
auto {{column}}_party_existing = ores::refdata::repository::party_repository().read_latest(party_ctx);
for (const auto& e : {{column}}_party_existing) {
if (e.tenant_id == {{column}}_party.tenant_id) {
{{column}}_party.parent_party_id = e.id;
break;
}
}
ores::refdata::repository::party_repository {{column}}_party_repo;
{{column}}_party_repo.write(party_ctx, {{column}}_party);
{{column}}_parent.{{#parent_has_identity_group}}identity.{{/parent_has_identity_group}}party_id = {{column}}_party.id;
{{/parent_supplies_party}}
{{/parent_requires_party}}
{{#parent_is_party}}
// Only one root party (parent_party_id null) is allowed per tenant:
// attach to the existing root party instead of creating a second one.
auto {{column}}_existing = ores::refdata::repository::party_repository().read_latest(party_ctx);
for (const auto& e : {{column}}_existing) {
if (e.tenant_id == {{column}}_parent.tenant_id) {
{{column}}_parent.parent_party_id = e.id;
break;
}
}
{{/parent_is_party}}
{{! Every ancestor row is declared up front, then written in the second
loop below: the write phase patches a referencing row onto its
seeded ancestor before the referencing row's own write, so every
patch target (a row one step shallower in the chain, declared here)
must exist before any write runs. }}
{{#parent_required_fks}}
{{^use_system_tenant}}
auto {{var}} = ores::{{parent_component}}::{{parent_generator_facet_name}}::generate_synthetic_{{parent_entity_singular}}(ctx);
{{#parent_has_audit_group}}
{{var}}.audit.change_reason_code = "system.test";
{{/parent_has_audit_group}}
{{^parent_has_audit_group}}
{{var}}.change_reason_code = "system.test";
{{/parent_has_audit_group}}
{{/use_system_tenant}}
{{/parent_required_fks}}
{{#parent_required_fks}}
{{#requires_party}}
// {{parent_entity_singular}} carries a mandatory party_id FK of its own
// (session-set in production), so seed a party for it before its write,
// exactly as the direct-parent branch does.
auto {{var}}_party = ores::refdata::generators::generate_synthetic_party(ctx);
{{var}}_party.change_reason_code = "system.test";
auto {{var}}_party_existing = ores::refdata::repository::party_repository().read_latest(party_ctx);
for (const auto& e : {{var}}_party_existing) {
if (e.tenant_id == {{var}}_party.tenant_id) {
{{var}}_party.parent_party_id = e.id;
break;
}
}
ores::refdata::repository::party_repository {{var}}_party_repo;
{{var}}_party_repo.write(party_ctx, {{var}}_party);
{{var}}.{{#parent_has_identity_group}}identity.{{/parent_has_identity_group}}party_id = {{var}}_party.id;
{{/requires_party}}
{{#use_system_tenant}}
// {{parent_entity_singular}} is system-tenant reference data: reference a
// seeded catalogue row instead of creating one, as the direct-parent
// system-tenant branch does.
{
ores::{{parent_component}}::repository::{{parent_entity_singular}}_repository {{var}}_repo;
const auto {{var}}_catalogue = {{var}}_repo.read_latest(
party_ctx.with_tenant(ores::utility::uuid::tenant_id::system(), h.db_user()));
REQUIRE_FALSE({{var}}_catalogue.empty());
{{parent_var}}.{{column}} = {{var}}_catalogue.front().{{target_column}};
}
{{/use_system_tenant}}
{{^use_system_tenant}}
// Seed the active {{parent_entity_singular}} row {{table}} references:
// the referencing row's insert trigger rejects a synthetic key that
// matches no active row, so it must be written first.
ores::{{parent_component}}::repository::{{parent_entity_singular}}_repository {{var}}_repo;
{{var}}_repo.write(party_ctx, {{var}});
{{parent_var}}.{{column}} = {{var}}.{{target_column}};
{{/use_system_tenant}}
{{/parent_required_fks}}
{{#parent_seed_country_sentinel}}
// The parent calendar's insert trigger validates country_code
// against the countries table for the write tenant, and the
// synthetic calendar generator always emits the ZZ sentinel --
// seed it before the parent write or the parent insert is
// rejected. Distinct name from the entity-level sentinel seed
// block: both are in scope when the entity also carries the
// seed_country_sentinel flag.
country_repository parent_cty_repo;
parent_cty_repo.write(party_ctx, {generate_country_sentinel(ctx)});
{{/parent_seed_country_sentinel}}
{{#parent_seed_currency}}
// The parent portfolio's insert trigger validates aggregation_ccy
// against the currencies table for the write tenant, and the
// synthetic portfolio generator always emits the X-0 sentinel --
// seed it before the parent write or the parent insert is rejected.
// Distinct name from the entity-level currency seed block: both are
// in scope when the entity also carries the seed_currency flag.
auto parent_ccy = ores::refdata::generators::generate_synthetic_currency(ctx);
parent_ccy.iso_code = "X-0";
currency_repository parent_ccy_repo;
parent_ccy_repo.write(party_ctx, {parent_ccy});
{{/parent_seed_currency}}
ores::{{parent_component}}::repository::{{parent_entity_singular}}_repository {{column}}_repo;
{{column}}_repo.write(party_ctx, {{column}}_parent);
v.{{{group_prefix}}}{{column}} = {{column}}_parent.{{target_column}};
{{/use_system_tenant}}
{{/parent_entity_singular}}
{{#parent_seed_snippet}}
{{{parent_seed_snippet}}}
{{/parent_seed_snippet}}
{{/nullable}}
{{/foreign_keys}}
{{#primary_key.is_uuid}}
const auto id_str = boost::uuids::to_string(v.{{{scaffold_prefix}}}{{primary_key.column}});
{{/primary_key.is_uuid}}
{{^primary_key.is_uuid}}
const auto id_str = v.{{{scaffold_prefix}}}{{primary_key.column}};
{{/primary_key.is_uuid}}
{{#primary_key.is_compound}}
// The notify trigger emits one entity_id per key column, so a
// notification belongs to this row only when every key part is in it.
const std::vector<std::string> key_parts = {
id_str{{#primary_key.extra_columns}},
v.{{{scaffold_prefix}}}{{column}}{{/primary_key.extra_columns}}};
{{/primary_key.is_compound}}
BOOST_LOG_SEV(lg, debug) << "{{entity_title}}: " << v;
{{#seed_country_sentinel}}
// {{entity_title}}'s insert trigger validates country_code against
// the countries table for the write tenant, and the synthetic
// generator always uses the ZZ sentinel -- seed it first (as the
// materialisation tests do) or the insert is rejected.
country_repository cty_repo;
cty_repo.write(party_ctx, {generate_country_sentinel(ctx)});
{{/seed_country_sentinel}}
{{#seed_currency}}
// Seed the active aggregation-currency row the insert trigger
// references: the currency check is strict and tenant-scoped, and
// this process's synthetic-currency counter has moved past the
// X-0 code the {{entity_singular}} generator hardcodes -- so stamp
// it explicitly, mirroring write_synthetic_pairs' pattern.
auto aggregation_ccy = ores::refdata::generators::generate_synthetic_currency(ctx);
aggregation_ccy.iso_code = "X-0";
currency_repository ccy_repo;
ccy_repo.write(party_ctx, {aggregation_ccy});
{{/seed_currency}}
{{entity_singular}}_repository repo;
repo.write(party_ctx, v);
// 4. Poll the observer's buffer for the notification. The chain --
// trigger -> pg_notify -> 100ms listener poll -> event_bus -> NATS
// round trip -- is real, no mocks. Under CI load the listener or
// NATS connection can hiccup once (reconnect backoff 1-5s) and the
// notification in flight is lost forever; a lost notification never
// arrives, so re-drive the write -- a new version row re-fires the
// notify trigger. Bounded: 4 attempts, each polling ~2.5s.
constexpr int max_attempts = 4;
constexpr int polls_per_attempt = 25;
std::vector<ores::nats::message> received;
for (int attempt = 1; attempt <= max_attempts && received.empty(); ++attempt) {
if (attempt > 1) {
BOOST_LOG_SEV(lg, warn) << "No matching notification yet; re-driving write"
<< " (attempt " << attempt << " of " << max_attempts << ")";
repo.write(party_ctx, v);
}
for (int i = 0; i < polls_per_attempt && received.empty(); ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
auto snap = observer.snapshot();
for (const auto& msg : snap) {
auto decoded = ores::nats::default_wire_codec().decode<event_type>(msg.data);
// The event carries the row's own key record, so the row under
// test is recognised by comparing it with the row written.
if (decoded && {{{primary_key.key_equals_v}}})
received.push_back(msg);
}
}
}
event_source.stop();
if (received.empty()) {
// Exhausted the budget: report what the observer did see so a
// genuinely broken chain is diagnosable, not a bare empty check.
const auto final_snapshot = observer.snapshot();
BOOST_LOG_SEV(lg, error) << "No notification for {{entity_singular}} " << id_str{{#primary_key.extra_columns}}
<< "/" << v.{{{scaffold_prefix}}}{{column}}{{/primary_key.extra_columns}}
<< " after " << max_attempts << " writes; observer received "
<< final_snapshot.size() << " message(s) in total";
for (const auto& msg : final_snapshot)
BOOST_LOG_SEV(lg, error) << " unexpected message on subject '" << msg.subject
<< "', " << msg.data.size() << " bytes";
}
REQUIRE_FALSE(received.empty());
BOOST_LOG_SEV(lg, info) << "Received " << received.size()
<< " matching NATS notification(s) for {{entity_singular}} " << id_str{{#primary_key.extra_columns}}
<< "/" << v.{{{scaffold_prefix}}}{{column}}{{/primary_key.extra_columns}};
// 5. CRUD round trip on the same row: update through the
// repository, read the version history through the service, and
// delete. Reads and writes go through party_ctx: for party-scoped
// entities it already carries the visible-party GUC the RLS
// policies filter every service read by; otherwise it is the
// plain test context. The version history grows by one per write
// (the notify re-drive above may have written more than once), so
// only growth is asserted, not an exact count.
{
{{#party_scoped_writes}}
// party_ctx already carries the visible-party set: v's own
// party is the session party the RLS policies filter by.
const auto& crud_ctx = party_ctx;
{{/party_scoped_writes}}
{{^party_scoped_writes}}
{{#has_party_column}}
// The row's party (seeded above for the mandatory party FK)
// scopes the service reads: point the session's visible-party
// set at it directly, the way write_test_party_and_scope_context
// does for party-scoped entities.
const auto crud_party = {{#party_column_optional}}*{{/party_column_optional}}v.party_id;
const auto crud_ctx =
party_ctx.with_party(party_ctx.tenant_id(), crud_party, {crud_party}, h.db_user());
{{/has_party_column}}
{{^has_party_column}}
const auto& crud_ctx = party_ctx;
{{/has_party_column}}
{{/party_scoped_writes}}
ores::{{component}}::service::{{entity_singular}}_service svc(crud_ctx);
v.{{{audit_prefix}}}change_commentary = "updated-by-crud-round-trip";
{{#amend_activity}}
// Rewriting the row is an amendment, not a booking: the booking
// activity names a transition that starts the state machine and is
// rejected on a row that already has a state.
v.{{{group_prefix}}}{{column}} = "{{code}}";
{{/amend_activity}}
repo.write(crud_ctx, v);
auto versions = svc.get_{{entity_singular_short}}_history({{#key_is_primary}}id_str{{/key_is_primary}}{{^key_is_primary}}v.{{{scaffold_prefix}}}{{declared_key}}{{/key_is_primary}}{{#primary_key.extra_columns}}, v.{{{scaffold_prefix}}}{{column}}{{/primary_key.extra_columns}});
REQUIRE(versions.size() >= 2);
REQUIRE(versions.front().{{{audit_prefix}}}change_commentary == "updated-by-crud-round-trip");
svc.delete_{{entity_singular_short}}({{#primary_key.is_single_uuid}}v.{{{scaffold_prefix}}}{{primary_key.column}}{{/primary_key.is_single_uuid}}{{^primary_key.is_single_uuid}}id_str{{#primary_key.extra_columns}}, v.{{{scaffold_prefix}}}{{column}}{{/primary_key.extra_columns}}{{/primary_key.is_single_uuid}});
// Delete soft-closes the active row (the instead-of delete
// rule sets valid_to): the row disappears from latest reads,
// and the version history keeps every version.
REQUIRE_FALSE(svc.{{#service_find_prefix}}find_{{/service_find_prefix}}{{^service_find_prefix}}get_{{/service_find_prefix}}{{entity_singular_short}}({{#primary_key.is_single_uuid}}v.{{{scaffold_prefix}}}{{primary_key.column}}{{/primary_key.is_single_uuid}}{{^primary_key.is_single_uuid}}id_str{{#primary_key.extra_columns}}, v.{{{scaffold_prefix}}}{{column}}{{/primary_key.extra_columns}}{{/primary_key.is_single_uuid}}).has_value());
REQUIRE(svc.get_{{entity_singular_short}}_history({{#key_is_primary}}id_str{{/key_is_primary}}{{^key_is_primary}}v.{{{scaffold_prefix}}}{{declared_key}}{{/key_is_primary}}{{#primary_key.extra_columns}}, v.{{{scaffold_prefix}}}{{column}}{{/primary_key.extra_columns}}).size() == versions.size());
}
}
{{/domain_entity}}