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.

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"
#include "ores.eventing.api/domain/entity_change_event.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}}_changed_event.hpp"
#include "ores.{{component_include}}/{{generator_facet_name}}/{{entity_singular}}_generator.hpp"
#include "ores.{{component_core}}/repository/{{entity_singular}}_repository.hpp"
{{#sql.party_id_from_session}}
#include "ores.refdata.api/generators/party_generator.hpp"
#include "ores.refdata.core/repository/party_repository.hpp"
{{/sql.party_id_from_session}}
{{#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_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's own generator and
// repository live in the same component as the child.
#include "ores.{{component_include}}/{{parent_generator_facet_name}}/{{parent_entity_singular}}_generator.hpp"
#include "ores.{{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.{{component_include}}/{{parent_generator_facet_name}}/{{parent_entity_singular}}_generator.hpp"
#include "ores.{{component_core}}/repository/{{parent_entity_singular}}_repository.hpp"
{{/parent_required_fks}}
{{/parent_is_party}}
{{/parent_entity_singular}}
{{/nullable}}
{{/foreign_keys}}
#include "ores.testing/make_generation_context.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>
#include <cstdlib>
#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]");

{{#sql.party_id_from_session}}
// {{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());
}
{{/sql.party_id_from_session}}

// Reads NATS connection settings the same way every service resolves
// them at startup -- CMake bakes every .env variable into the ctest
// process environment, so these are populated identically for both
// `compass build` local runs and CI.
ores::nats::config::nats_options test_nats_options() {
    auto env = [](const char* name) -> std::string {
        const char* v = std::getenv(name);
        return v ? std::string(v) : std::string();
    };

    ores::nats::config::nats_options opts;
    opts.url = env("ORES_NATS_URL");
    if (opts.url.empty())
        opts.url = "nats://localhost:4222";
    opts.subject_prefix = env("ORES_NATS_SUBJECT_PREFIX");
    opts.tls_ca_cert = env("ORES_NATS_TLS_CA");
    opts.tls_client_cert = env("ORES_NATS_TLS_CERT");
    opts.tls_client_key = env("ORES_NATS_TLS_KEY");
    return opts;
}

}

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}}
using ores::testing::scoped_database_helper;
using namespace ores::logging;

TEST_CASE("write_{{entity_singular}}_publishes_nats_changed_event", tags) {
    auto lg(make_logger(test_suite));

    scoped_database_helper h;
    auto ctx = ores::testing::make_generation_context(h);
{{#sql.party_id_from_session}}
    auto party_ctx = write_test_party_and_scope_context(h, ctx);
{{/sql.party_id_from_session}}
{{^sql.party_id_from_session}}
    auto& party_ctx = h.context();
{{/sql.party_id_from_session}}

    // 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(test_nats_options());
    nats.connect();
    REQUIRE(nats.is_connected());

    auto sub = bus.subscribe<ores::{{component}}::eventing::{{entity_singular}}_changed_event>(
        [&nats](const ores::{{component}}::eventing::{{entity_singular}}_changed_event& e) {
            ev::service::publish_entity_event(
                nats,
                std::string(ev::domain::event_traits<
                            ores::{{component}}::eventing::{{entity_singular}}_changed_event>::name),
                ev::domain::entity_change_event{.entity = "ores.{{component}}.{{entity_singular}}",
                                                .timestamp = e.timestamp,
{{#primary_key.is_text}}
                                                .entity_ids = e.{{primary_key.column}}s,
{{/primary_key.is_text}}
{{#primary_key.is_uuid}}
                                                .entity_ids = e.{{entity_singular_short}}_ids,
{{/primary_key.is_uuid}}
                                                .tenant_id = e.tenant_id});
        });

    event_source.register_mapping<ores::{{component}}::eventing::{{entity_singular}}_changed_event>(
        "ores.{{component}}.{{entity_singular}}", "{{sql_name_base}}");

    // 2. Subscribe as an external observer would, on the relative subject --
    // client::subscribe() prepends the subject_prefix itself.
    auto observer = nats.subscribe_buffered(
        std::string(ev::domain::event_traits<
                    ores::{{component}}::eventing::{{entity_singular}}_changed_event>::name),
        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);
{{#has_audit_group}}
    v.audit.change_reason_code = "system.test";
{{/has_audit_group}}
{{^has_audit_group}}
    v.change_reason_code = "system.test";
{{/has_audit_group}}
{{#sql.party_id_from_session}}
{{#has_identity_group}}
    v.identity.party_id = *party_ctx.party_id();
{{/has_identity_group}}
{{^has_identity_group}}
    v.party_id = *party_ctx.party_id();
{{/has_identity_group}}
{{/sql.party_id_from_session}}
{{#foreign_keys}}
{{^nullable}}
{{#parent_entity_singular}}
    // 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::{{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_requires_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::{{component}}::generators::generate_synthetic_party(ctx);
    {{column}}_party.change_reason_code = "system.test";
    auto {{column}}_party_existing = ores::{{component}}::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::{{component}}::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_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::{{component}}::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}}
{{#parent_required_fks}}
    // Seed the active {{parent_entity_singular}} row {{table}} references:
    // the parent's own existence check rejects a synthetic key that
    // matches no active row, so the grandparent must be written first.
    auto {{var}} = ores::{{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}}
    ores::{{component}}::repository::{{parent_entity_singular}}_repository {{var}}_repo;
    {{var}}_repo.write(party_ctx, {{var}});
    {{parent_var}}.{{column}} = {{var}}.{{target_column}};
{{/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}}
    ores::{{component}}::repository::{{parent_entity_singular}}_repository {{column}}_repo;
    {{column}}_repo.write(party_ctx, {{column}}_parent);
    v.{{#is_identity_group_column}}identity.{{/is_identity_group_column}}{{column}} = {{column}}_parent.{{target_column}};
{{/parent_entity_singular}}
{{/nullable}}
{{/foreign_keys}}
{{#has_identity_group}}
{{#primary_key.is_uuid}}
    const auto id_str = boost::uuids::to_string(v.identity.{{primary_key.column}});
{{/primary_key.is_uuid}}
{{^primary_key.is_uuid}}
    const auto id_str = v.identity.{{primary_key.column}};
{{/primary_key.is_uuid}}
{{/has_identity_group}}
{{^has_identity_group}}
{{#primary_key.is_uuid}}
    const auto id_str = boost::uuids::to_string(v.{{primary_key.column}});
{{/primary_key.is_uuid}}
{{^primary_key.is_uuid}}
    const auto id_str = v.{{primary_key.column}};
{{/primary_key.is_uuid}}
{{/has_identity_group}}
    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 = 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. Generous
    // timeout: trigger -> pg_notify -> 100ms listener poll -> event_bus
    // -> NATS round trip, all real, no mocks.
    std::vector<ores::nats::message> received;
    for (int i = 0; i < 50 && 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<ev::domain::entity_change_event>(
                    msg.data);
            if (decoded && decoded->entity == "ores.{{component}}.{{entity_singular}}") {
                for (const auto& changed_id : decoded->entity_ids) {
                    if (changed_id == id_str)
                        received.push_back(msg);
                }
            }
        }
    }

    event_source.stop();

    REQUIRE_FALSE(received.empty());
    BOOST_LOG_SEV(lg, info) << "Received " << received.size()
                            << " matching NATS notification(s) for {{entity_singular}} " << id_str;
}
{{/domain_entity}}

See also

Emacs 29.3 (Org mode 9.6.15)