#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "attributes/AttributeSetResourceAccount.h" #include "economy/EconomyLedgerResourceAccount.h" #include "economy/ResourceType.h" #include "transaction/ResourceAccountParticipant.h" #include #include #include #include #include #include #include #include namespace { using eve::resource::CostInput; using eve::resource::CostSpec; static_assert(!std::is_constructible_v); CostSpec makeCost(std::initializer_list items) { auto result = CostSpec::from(items); REQUIRE(result.ok()); return std::move(result).takeValue(); } template void runCommonAccountContract(Account& account, Balance&& balance) { const CostSpec cost = makeCost({{"mana", 40}}); auto affordable = account.canAfford(cost); REQUIRE(affordable.ok()); CHECK(affordable.value().affordable); auto reservationResult = account.reserve(cost); REQUIRE(reservationResult.ok()); const auto reservation = reservationResult.value(); CHECK(reservation.isValid()); CHECK_EQ(balance("mana"), std::int64_t{100}); const CostSpec blockedCost = makeCost({{"mana", 70}}); auto blocked = account.reserve(blockedCost); CHECK(!blocked.ok()); CHECK_EQ(static_cast(blocked.code()), static_cast(eve::StatusCode::Rejected)); CHECK_EQ(balance("mana"), std::int64_t{100}); auto committed = account.commit(reservation); REQUIRE(committed.ok()); CHECK(committed.value().isValid()); CHECK_EQ(static_cast(committed.value().operation), static_cast(eve::resource::ReceiptOperation::Debit)); CHECK_EQ(balance("mana"), std::int64_t{60}); // A copied credential has the same account nonce and id, but the account // lifecycle still permits only one terminal operation. const auto copiedReservation = reservation; auto duplicateCommit = account.commit(copiedReservation); CHECK(!duplicateCommit.ok()); CHECK_EQ(static_cast(duplicateCommit.code()), static_cast(eve::StatusCode::Conflict)); auto duplicateRollback = account.rollback(copiedReservation); CHECK(!duplicateRollback.ok()); CHECK_EQ(static_cast(duplicateRollback.code()), static_cast(eve::StatusCode::Conflict)); const CostSpec credit = makeCost({{"mana", 5}, {"stamina", 3}}); auto credited = account.credit(credit); REQUIRE(credited.ok()); CHECK_EQ(static_cast(credited.value().operation), static_cast(eve::resource::ReceiptOperation::Credit)); CHECK_EQ(balance("mana"), std::int64_t{65}); CHECK_EQ(balance("stamina"), std::int64_t{3}); } class CommitFailureParticipant final : public eve::transaction::ITransactionParticipant { public: std::string_view name() const noexcept override { return "test-commit-failure"; } [[nodiscard]] eve::Result prepare(const eve::transaction::TransactionContext& context) override { (void)context; prepared = true; return eve::Result::success(); } [[nodiscard]] eve::Result commit(const eve::transaction::TransactionContext& context) override { (void)context; return eve::Result::failure( eve::Diagnostic::error(eve::DiagnosticCode::Failed, "injected commit failure", "test.commit")); } [[nodiscard]] eve::Result rollback(const eve::transaction::TransactionContext& context) override { (void)context; prepared = false; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); } [[nodiscard]] eve::Result compensate(const eve::transaction::TransactionContext& context) override { (void)context; return eve::Result::failure(eve::Diagnostic::error( eve::DiagnosticCode::Conflict, "injected participant was never committed", "test.compensate")); } bool prepared = false; }; } // namespace TEST_CASE("resource.account.attributeSetMatchesCommonDebitCreditReservationContract") { eve::attributes::AttributeSet attributes("account.attributes"); attributes.setBase("mana", 100.0); eve::attributes::AttributeSetResourceAccount account(attributes); runCommonAccountContract(account, [&attributes](std::string_view resource) { return static_cast(attributes.getBase(std::string(resource), 0.0)); }); } TEST_CASE("resource.account.economyLedgerMatchesCommonDebitCreditReservationContract") { eve::economy::EconomyLedger ledger; CHECK_EQ(ledger.credit("mana", 100), 100); eve::economy::EconomyLedgerResourceAccount account(ledger); runCommonAccountContract(account, [&ledger](std::string_view resource) { return static_cast(ledger.get(std::string(resource))); }); } TEST_CASE("resource.account.multiResourceDebitFailureIsAtomicForBothAdapters") { const CostSpec impossible = makeCost({{"gold", 4}, {"mana", 100}}); eve::attributes::AttributeSet attributes; attributes.setBase("gold", 10.0); attributes.setBase("mana", 3.0); eve::attributes::AttributeSetResourceAccount attributeAccount(attributes); auto attributeDebit = attributeAccount.debit(impossible); CHECK(!attributeDebit.ok()); CHECK_EQ(static_cast(attributeDebit.code()), static_cast(eve::StatusCode::Rejected)); CHECK_EQ(attributes.getBase("gold"), 10.0); CHECK_EQ(attributes.getBase("mana"), 3.0); eve::economy::EconomyLedger ledger; CHECK_EQ(ledger.credit("gold", 10), 10); CHECK_EQ(ledger.credit("mana", 3), 3); eve::economy::EconomyLedgerResourceAccount economyAccount(ledger); auto economyDebit = economyAccount.debit(impossible); CHECK(!economyDebit.ok()); CHECK_EQ(static_cast(economyDebit.code()), static_cast(eve::StatusCode::Rejected)); CHECK_EQ(ledger.get("gold"), 10); CHECK_EQ(ledger.get("mana"), 3); } TEST_CASE("resource.account.rollbackReleasesReservationWithoutChangingBalance") { eve::attributes::AttributeSet attributes; attributes.setBase("mana", 50.0); eve::attributes::AttributeSetResourceAccount account(attributes); const CostSpec cost = makeCost({{"mana", 30}}); auto reservationResult = account.reserve(cost); REQUIRE(reservationResult.ok()); const auto reservation = reservationResult.value(); auto blocked = account.canAfford(makeCost({{"mana", 25}})); REQUIRE(blocked.ok()); CHECK(!blocked.value().affordable); CHECK_EQ(attributes.getBase("mana"), 50.0); auto rolledBack = account.rollback(reservation); REQUIRE(rolledBack.ok()); CHECK_EQ(attributes.getBase("mana"), 50.0); auto available = account.canAfford(makeCost({{"mana", 25}})); REQUIRE(available.ok()); CHECK(available.value().affordable); const auto copiedReservation = reservation; auto duplicateRollback = account.rollback(copiedReservation); CHECK(!duplicateRollback.ok()); CHECK_EQ(static_cast(duplicateRollback.code()), static_cast(eve::StatusCode::Conflict)); auto duplicateCommit = account.commit(copiedReservation); CHECK(!duplicateCommit.ok()); CHECK_EQ(static_cast(duplicateCommit.code()), static_cast(eve::StatusCode::Conflict)); } TEST_CASE("resource.account.sameTypeAccountsRejectForeignCredentialsEvenWithSameIdAndCost") { eve::attributes::AttributeSet firstAttributes; eve::attributes::AttributeSet secondAttributes; firstAttributes.setBase("mana", 10.0); secondAttributes.setBase("mana", 10.0); eve::attributes::AttributeSetResourceAccount first(firstAttributes); eve::attributes::AttributeSetResourceAccount second(secondAttributes); const CostSpec cost = makeCost({{"mana", 5}}); auto firstReservationResult = first.reserve(cost); auto secondReservationResult = second.reserve(cost); REQUIRE(firstReservationResult.ok()); REQUIRE(secondReservationResult.ok()); const auto firstReservation = firstReservationResult.value(); const auto secondReservation = secondReservationResult.value(); CHECK_EQ(firstReservation.id.value(), secondReservation.id.value()); CHECK_NE(firstReservation.account.value(), secondReservation.account.value()); auto foreignCommit = second.commit(firstReservation); CHECK(!foreignCommit.ok()); CHECK_EQ(static_cast(foreignCommit.code()), static_cast(eve::StatusCode::Conflict)); auto foreignRollback = second.rollback(firstReservation); CHECK(!foreignRollback.ok()); CHECK_EQ(static_cast(foreignRollback.code()), static_cast(eve::StatusCode::Conflict)); auto reverseForeignCommit = first.commit(secondReservation); CHECK(!reverseForeignCommit.ok()); CHECK_EQ(static_cast(reverseForeignCommit.code()), static_cast(eve::StatusCode::Conflict)); auto reverseForeignRollback = first.rollback(secondReservation); CHECK(!reverseForeignRollback.ok()); CHECK_EQ(static_cast(reverseForeignRollback.code()), static_cast(eve::StatusCode::Conflict)); CHECK_EQ(secondAttributes.getBase("mana"), 10.0); auto ownCommit = first.commit(firstReservation); REQUIRE(ownCommit.ok()); CHECK_EQ(firstAttributes.getBase("mana"), 5.0); auto ownRollback = second.rollback(secondReservation); REQUIRE(ownRollback.ok()); CHECK_EQ(secondAttributes.getBase("mana"), 10.0); } TEST_CASE("resource.account.economyAccountsRejectForeignCredentialsEvenWithSameIdAndCost") { eve::economy::EconomyLedger firstLedger; eve::economy::EconomyLedger secondLedger; CHECK_EQ(firstLedger.credit("gold", 10), 10); CHECK_EQ(secondLedger.credit("gold", 10), 10); eve::economy::EconomyLedgerResourceAccount first(firstLedger); eve::economy::EconomyLedgerResourceAccount second(secondLedger); const CostSpec cost = makeCost({{"gold", 5}}); auto firstReservationResult = first.reserve(cost); auto secondReservationResult = second.reserve(cost); REQUIRE(firstReservationResult.ok()); REQUIRE(secondReservationResult.ok()); const auto firstReservation = firstReservationResult.value(); const auto secondReservation = secondReservationResult.value(); CHECK_EQ(firstReservation.id.value(), secondReservation.id.value()); CHECK_NE(firstReservation.account.value(), secondReservation.account.value()); auto foreignCommit = second.commit(firstReservation); CHECK(!foreignCommit.ok()); CHECK_EQ(static_cast(foreignCommit.code()), static_cast(eve::StatusCode::Conflict)); auto foreignRollback = second.rollback(firstReservation); CHECK(!foreignRollback.ok()); CHECK_EQ(static_cast(foreignRollback.code()), static_cast(eve::StatusCode::Conflict)); auto reverseForeignCommit = first.commit(secondReservation); CHECK(!reverseForeignCommit.ok()); CHECK_EQ(static_cast(reverseForeignCommit.code()), static_cast(eve::StatusCode::Conflict)); auto reverseForeignRollback = first.rollback(secondReservation); CHECK(!reverseForeignRollback.ok()); CHECK_EQ(static_cast(reverseForeignRollback.code()), static_cast(eve::StatusCode::Conflict)); CHECK_EQ(secondLedger.get("gold"), 10); auto ownCommit = first.commit(firstReservation); REQUIRE(ownCommit.ok()); CHECK_EQ(firstLedger.get("gold"), 5); auto ownRollback = second.rollback(secondReservation); REQUIRE(ownRollback.ok()); CHECK_EQ(secondLedger.get("gold"), 10); } TEST_CASE("resource.account.crossAdapterCredentialsRejectCommitAndRollback") { eve::attributes::AttributeSet attributes; attributes.setBase("gold", 10.0); eve::attributes::AttributeSetResourceAccount attributeAccount(attributes); eve::economy::EconomyLedger ledger; CHECK_EQ(ledger.credit("gold", 10), 10); eve::economy::EconomyLedgerResourceAccount economyAccount(ledger); const CostSpec cost = makeCost({{"gold", 5}}); auto attributeReservationResult = attributeAccount.reserve(cost); auto economyReservationResult = economyAccount.reserve(cost); REQUIRE(attributeReservationResult.ok()); REQUIRE(economyReservationResult.ok()); const auto attributeReservation = attributeReservationResult.value(); const auto economyReservation = economyReservationResult.value(); CHECK_EQ(attributeReservation.id.value(), economyReservation.id.value()); CHECK_NE(attributeReservation.account.value(), economyReservation.account.value()); auto wrongEconomyCommit = economyAccount.commit(attributeReservation); CHECK(!wrongEconomyCommit.ok()); CHECK_EQ(static_cast(wrongEconomyCommit.code()), static_cast(eve::StatusCode::Conflict)); auto wrongAttributeRollback = attributeAccount.rollback(economyReservation); CHECK(!wrongAttributeRollback.ok()); CHECK_EQ(static_cast(wrongAttributeRollback.code()), static_cast(eve::StatusCode::Conflict)); auto wrongAttributeCommit = attributeAccount.commit(economyReservation); CHECK(!wrongAttributeCommit.ok()); CHECK_EQ(static_cast(wrongAttributeCommit.code()), static_cast(eve::StatusCode::Conflict)); auto wrongEconomyRollback = economyAccount.rollback(attributeReservation); CHECK(!wrongEconomyRollback.ok()); CHECK_EQ(static_cast(wrongEconomyRollback.code()), static_cast(eve::StatusCode::Conflict)); CHECK_EQ(attributes.getBase("gold"), 10.0); CHECK_EQ(ledger.get("gold"), 10); REQUIRE(attributeAccount.rollback(attributeReservation).ok()); REQUIRE(economyAccount.rollback(economyReservation).ok()); } TEST_CASE("resource.account.capacityFailureDoesNotPartiallyCreditEconomyLedger") { eve::economy::ResourceTypeRegistry::clear(); eve::economy::ResourceTypeDef definition; definition.id = "gold"; definition.stockMax = 10; CHECK(eve::economy::ResourceTypeRegistry::registerType(definition)); eve::economy::EconomyLedger ledger; CHECK_EQ(ledger.credit("gold", 8), 8); CHECK_EQ(ledger.credit("wood", 1), 1); eve::economy::EconomyLedgerResourceAccount account(ledger); const CostSpec cost = makeCost({{"gold", 4}, {"wood", 2}}); auto result = account.credit(cost); CHECK(!result.ok()); CHECK_EQ(static_cast(result.code()), static_cast(eve::StatusCode::Rejected)); CHECK_EQ(ledger.get("gold"), 8); CHECK_EQ(ledger.get("wood"), 1); eve::economy::ResourceTypeRegistry::clear(); } TEST_CASE("resource.account.transactionCommitFailureCompensatesCommittedDebit") { eve::attributes::AttributeSet attributes; attributes.setBase("mana", 100.0); eve::attributes::AttributeSetResourceAccount account(attributes); const CostSpec cost = makeCost({{"mana", 40}}); eve::transaction::ResourceDebitParticipant debit(account, cost); CommitFailureParticipant failing; std::array participants{&debit, &failing}; eve::transaction::Coordinator coordinator; auto transaction = coordinator.execute( eve::transaction::TransactionContext("resource-atomic"), std::span(participants.data(), participants.size())); CHECK(!transaction.ok()); CHECK_EQ(static_cast(transaction.code()), static_cast(eve::StatusCode::Failed)); CHECK_EQ(static_cast(debit.state()), static_cast(eve::transaction::ResourceDebitState::Compensated)); CHECK(!failing.prepared); CHECK_EQ(attributes.getBase("mana"), 100.0); }