From 94b95789ab6509096c3f5080c0c63269c24912d5 Mon Sep 17 00:00:00 2001 From: Augment Agent Date: Fri, 1 May 2026 21:23:12 +0000 Subject: [PATCH] test: cover inner PROCTRAN HWPT/XRTY catch in CRECUST and UPDCUST Use MockConnection + MockDataProvider + a Spring exception-translator ExecuteListener to drive the public createCustomer/updateCustomer through to the inner PROCTRAN-insert catch: - non-40001 SQLException -> CbsaAbendException("HWPT", " failed to write the audit trail.") - 40001 SQLException -> rethrown unchanged so CrdbRetry retries; after exhaustion the outer catch surfaces XRTY The inline ExecuteListener mirrors JooqAutoConfiguration's DefaultExceptionTranslatorExecuteListener (package-private, so we reproduce it via SQLStateSQLExceptionTranslator). Closes #35. --- .../repository/CrecustRepositoryUnitTest.java | 152 +++++++++++++++-- .../repository/UpdcustRepositoryUnitTest.java | 159 ++++++++++++++++-- 2 files changed, 285 insertions(+), 26 deletions(-) diff --git a/src/test/java/com/augment/cbsa/repository/CrecustRepositoryUnitTest.java b/src/test/java/com/augment/cbsa/repository/CrecustRepositoryUnitTest.java index 11bdc1e..6fb1bc6 100644 --- a/src/test/java/com/augment/cbsa/repository/CrecustRepositoryUnitTest.java +++ b/src/test/java/com/augment/cbsa/repository/CrecustRepositoryUnitTest.java @@ -3,38 +3,56 @@ import com.augment.cbsa.domain.CrecustCommand; import com.augment.cbsa.domain.CrecustResult; import com.augment.cbsa.error.CbsaAbendException; +import java.sql.Connection; import java.sql.SQLException; import java.time.LocalDate; import java.time.LocalTime; +import java.util.Locale; +import org.jooq.Configuration; import org.jooq.DSLContext; +import org.jooq.ExecuteContext; +import org.jooq.ExecuteListener; +import org.jooq.SQLDialect; import org.jooq.TransactionalCallable; +import org.jooq.impl.DSL; +import org.jooq.tools.jdbc.MockConnection; +import org.jooq.tools.jdbc.MockDataProvider; +import org.jooq.tools.jdbc.MockResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator; +import static com.augment.cbsa.jooq.Tables.CONTROL; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; /** - * Unit-level coverage for the outer DataAccessException catch - * in {@link CrecustRepository#createCustomer(CrecustCommand)}: serialization-retry - * exhaustion escaping {@code CrdbRetry.run(...)} surfaces as {@code XRTY}, while - * any other DAE is re-thrown so the global handler classifies it as {@code UNEX}. + * Unit-level coverage for the DataAccessException catches in + * {@link CrecustRepository#createCustomer(CrecustCommand)}: * - *

The inner PROCTRAN-insert catch (HWPT wrapping vs SQLSTATE - * 40001 rethrow) is intentionally not covered here: it sits inside the - * {@code dsl.transactionResult(configuration -> ...)} lambda and is only - * reachable after stubbing {@code DSL.using(Configuration)} plus the full - * fluent CONTROL/CUSTOMER/PROCTRAN chains, which is best done as integration - * coverage. See #36 - * for the production fix (Spring Boot's {@code DefaultExceptionTranslatorExecuteListener} - * substitutes Spring DAEs for jOOQ DAEs, so the inner catches do not fire in - * production today) and the integration tests that should land alongside it. + *

+ * + *

See #36 + * for the production fix (PR #37) that ensures these jOOQ DAE catches actually + * fire under Spring Boot's {@code DefaultExceptionTranslatorExecuteListener}. */ @ExtendWith(MockitoExtension.class) class CrecustRepositoryUnitTest { @@ -88,4 +106,112 @@ void nonSerializationDataAccessExceptionIsRethrown() { .isInstanceOf(DataAccessException.class) .hasMessageContaining("non-retryable"); } + + @Test + void nonSerializationProctranInsertFailureWrapsAsHwptAbend() { + SQLException proctranSqle = new SQLException("PROCTRAN insert failed", "23505"); + wireTransactionWithProctranFailure(proctranSqle); + + assertThatThrownBy(() -> repository.createCustomer(COMMAND)) + .isInstanceOf(CbsaAbendException.class) + .satisfies(thrown -> { + CbsaAbendException abend = (CbsaAbendException) thrown; + assertThat(abend.getAbendCode()).isEqualTo("HWPT"); + assertThat(abend.getMessage()) + .isEqualTo("CRECUST failed to write the audit trail."); + }); + } + + @Test + void serializationProctranInsertFailureSurfacesAsXrtyAfterRetryExhaustion() { + SQLException proctranSqle = new SQLException("Serialization failure", "40001"); + wireTransactionWithProctranFailure(proctranSqle); + + // The inner catch must re-throw the SQLSTATE 40001 DAE unchanged so + // CrdbRetry sees it; after MAX_ATTEMPTS retries the outer catch + // converts it to XRTY. We assert the terminal classification rather + // than count attempts to keep the test resilient to retry tuning. + assertThatThrownBy(() -> repository.createCustomer(COMMAND)) + .isInstanceOf(CbsaAbendException.class) + .satisfies(thrown -> { + CbsaAbendException abend = (CbsaAbendException) thrown; + assertThat(abend.getAbendCode()).isEqualTo("XRTY"); + assertThat(abend.getMessage()) + .isEqualTo("CRECUST aborted after exhausting Cockroach serialization retries."); + }); + } + + /** + * Drive the public {@code createCustomer} through to the inner PROCTRAN + * catch by: + *

+ * This avoids static mocking (subclass MockMaker can't do that) and + * avoids deep-stubbing every overloaded {@code .set(...)} link. + */ + @SuppressWarnings("unchecked") + private void wireTransactionWithProctranFailure(SQLException proctranFailure) { + MockDataProvider provider = ctx -> { + String sql = ctx.sql(); + String upper = sql == null ? "" : sql.toUpperCase(Locale.ROOT); + if (upper.contains("\"CONTROL\"") && upper.startsWith("SELECT")) { + DSLContext create = DSL.using(SQLDialect.POSTGRES); + org.jooq.Record2 row = + create.newRecord(CONTROL.CUSTOMER_COUNT, CONTROL.CUSTOMER_LAST); + row.value1(0L); + row.value2(0L); + org.jooq.Result> result = + create.newResult(CONTROL.CUSTOMER_COUNT, CONTROL.CUSTOMER_LAST); + result.add(row); + return new MockResult[] { new MockResult(1, result) }; + } + if (upper.contains("\"PROCTRAN\"")) { + throw proctranFailure; + } + // CUSTOMER insert and CONTROL update both report 1 row affected. + return new MockResult[] { new MockResult(1) }; + }; + Connection connection = new MockConnection(provider); + // Mirror what JooqAutoConfiguration installs in production: + // translate the underlying SQLException into a Spring DAE via + // SQLStateSQLExceptionTranslator (which preserves SQLSTATE 40001 + // as TransientDataAccessException). The repository catches + // org.springframework.dao.DataAccessException, so without this + // listener the test would surface the raw jOOQ DAE. + SQLStateSQLExceptionTranslator translator = new SQLStateSQLExceptionTranslator(); + ExecuteListener springTranslator = new ExecuteListener() { + @Override + public void exception(ExecuteContext ctx) { + SQLException sqle = ctx.sqlException(); + if (sqle != null) { + DataAccessException translated = translator.translate("jOOQ", ctx.sql(), sqle); + if (translated != null) { + ctx.exception(translated); + } + } + } + }; + Configuration realConfiguration = DSL.using(connection, SQLDialect.POSTGRES) + .configuration() + .deriveAppending(springTranslator); + + when(dsl.transactionResult((TransactionalCallable) any())) + .thenAnswer(invocation -> { + TransactionalCallable callable = invocation.getArgument(0); + return callable.run(realConfiguration); + }); + } } diff --git a/src/test/java/com/augment/cbsa/repository/UpdcustRepositoryUnitTest.java b/src/test/java/com/augment/cbsa/repository/UpdcustRepositoryUnitTest.java index d89a054..a2bb3ac 100644 --- a/src/test/java/com/augment/cbsa/repository/UpdcustRepositoryUnitTest.java +++ b/src/test/java/com/augment/cbsa/repository/UpdcustRepositoryUnitTest.java @@ -3,38 +3,56 @@ import com.augment.cbsa.domain.UpdcustRequest; import com.augment.cbsa.domain.UpdcustResult; import com.augment.cbsa.error.CbsaAbendException; +import java.sql.Connection; import java.sql.SQLException; import java.time.LocalDate; import java.time.LocalTime; +import java.util.Locale; +import org.jooq.Configuration; import org.jooq.DSLContext; +import org.jooq.ExecuteContext; +import org.jooq.ExecuteListener; +import org.jooq.SQLDialect; import org.jooq.TransactionalCallable; +import org.jooq.impl.DSL; +import org.jooq.tools.jdbc.MockConnection; +import org.jooq.tools.jdbc.MockDataProvider; +import org.jooq.tools.jdbc.MockResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator; +import static com.augment.cbsa.jooq.Tables.CUSTOMER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; /** - * Unit-level coverage for the outer DataAccessException catch - * in {@link UpdcustRepository#updateCustomer}: serialization-retry exhaustion - * escaping {@code CrdbRetry.run(...)} surfaces as {@code XRTY}, while any other - * DAE is re-thrown so the global handler classifies it as {@code UNEX}. + * Unit-level coverage for the DataAccessException catches in + * {@link UpdcustRepository#updateCustomer}: * - *

The inner PROCTRAN-insert catch (HWPT wrapping vs SQLSTATE - * 40001 rethrow) is intentionally not covered here: it sits inside the - * {@code dsl.transactionResult(configuration -> ...)} lambda and is only - * reachable after stubbing {@code DSL.using(Configuration)} plus the full - * fluent CUSTOMER/PROCTRAN chains, which is best done as integration coverage. - * See #36 - * for the production fix (Spring Boot's {@code DefaultExceptionTranslatorExecuteListener} - * substitutes Spring DAEs for jOOQ DAEs, so the inner catches do not fire in - * production today) and the integration tests that should land alongside it. + *

    + *
  • The outer catch turns serialization-retry exhaustion + * escaping {@code CrdbRetry.run(...)} into {@code XRTY}, and re-throws + * any other DAE so the global handler classifies it as {@code UNEX}. + *
  • The inner PROCTRAN-insert catch wraps non-retryable + * DAEs as {@code CbsaAbendException("HWPT")} and re-throws SQLSTATE + * {@code 40001} DAEs unchanged so {@code CrdbRetry} can retry. The + * inner block sits inside the {@code dsl.transactionResult(configuration + * -> ...)} lambda; we drive it by handing the lambda a real jOOQ + * {@code Configuration} backed by a {@link MockConnection} whose + * {@link MockDataProvider} succeeds for the CUSTOMER select / CUSTOMER + * update and throws on the PROCTRAN insert. + *
+ * + *

See #36 + * for the production fix (PR #37) that ensures these jOOQ DAE catches actually + * fire under Spring Boot's {@code DefaultExceptionTranslatorExecuteListener}. */ @ExtendWith(MockitoExtension.class) class UpdcustRepositoryUnitTest { @@ -91,4 +109,119 @@ void nonSerializationDataAccessExceptionIsRethrown() { .isInstanceOf(DataAccessException.class) .hasMessageContaining("non-retryable"); } + + @Test + void nonSerializationProctranInsertFailureWrapsAsHwptAbend() { + SQLException proctranSqle = new SQLException("PROCTRAN insert failed", "23505"); + wireTransactionWithProctranFailure(proctranSqle); + + assertThatThrownBy(() -> repository.updateCustomer( + SORTCODE, REQUEST, TRANSACTION_REFERENCE, TRANSACTION_DATE, TRANSACTION_TIME)) + .isInstanceOf(CbsaAbendException.class) + .satisfies(thrown -> { + CbsaAbendException abend = (CbsaAbendException) thrown; + assertThat(abend.getAbendCode()).isEqualTo("HWPT"); + assertThat(abend.getMessage()) + .isEqualTo("UPDCUST failed to write the audit trail."); + }); + } + + @Test + void serializationProctranInsertFailureSurfacesAsXrtyAfterRetryExhaustion() { + SQLException proctranSqle = new SQLException("Serialization failure", "40001"); + wireTransactionWithProctranFailure(proctranSqle); + + // The inner catch must re-throw the SQLSTATE 40001 DAE unchanged so + // CrdbRetry sees it; after MAX_ATTEMPTS retries the outer catch + // converts it to XRTY. We assert the terminal classification rather + // than count attempts to keep the test resilient to retry tuning. + assertThatThrownBy(() -> repository.updateCustomer( + SORTCODE, REQUEST, TRANSACTION_REFERENCE, TRANSACTION_DATE, TRANSACTION_TIME)) + .isInstanceOf(CbsaAbendException.class) + .satisfies(thrown -> { + CbsaAbendException abend = (CbsaAbendException) thrown; + assertThat(abend.getAbendCode()).isEqualTo("XRTY"); + assertThat(abend.getMessage()) + .isEqualTo("UPDCUST aborted after exhausting Cockroach serialization retries."); + }); + } + + /** + * Drive the public {@code updateCustomer} through to the inner PROCTRAN + * catch by: + *

    + *
  • building a real {@code Configuration} backed by a + * {@link MockConnection} whose {@link MockDataProvider}: + *
      + *
    • returns a single CUSTOMER row for the {@code SELECT ... FOR + * UPDATE} so {@code fetchOne} surfaces an existing record,
    • + *
    • reports 1 row affected for the CUSTOMER update so the method + * reaches the PROCTRAN insert,
    • + *
    • throws {@code sqle} on the PROCTRAN insert.
    • + *
    + *
  • stubbing {@code dsl.transactionResult(callable)} on the outer mock + * to invoke the callable with that real configuration. The lambda's + * {@code DSL.using(configuration)} call then returns a real + * {@code DSLContext} that runs SQL through the provider. + *
+ * This avoids static mocking (subclass MockMaker can't do that) and avoids + * deep-stubbing every overloaded {@code .set(...)} link. + */ + @SuppressWarnings("unchecked") + private void wireTransactionWithProctranFailure(SQLException proctranFailure) { + MockDataProvider provider = ctx -> { + String sql = ctx.sql(); + String upper = sql == null ? "" : sql.toUpperCase(Locale.ROOT); + if (upper.contains("\"CUSTOMER\"") && upper.startsWith("SELECT")) { + DSLContext create = DSL.using(SQLDialect.POSTGRES); + org.jooq.Result result = + create.newResult(CUSTOMER); + com.augment.cbsa.jooq.tables.records.CustomerRecord row = + create.newRecord(CUSTOMER); + row.setSortcode(SORTCODE); + row.setCustomerNumber(REQUEST.customerNumber()); + row.setName("Mr Old Name"); + row.setAddress("0 Old Street"); + row.setDateOfBirth(LocalDate.of(2000, 1, 10)); + row.setCreditScore((short) 500); + row.setCsReviewDate(LocalDate.of(2026, 5, 8)); + result.add(row); + return new MockResult[] { new MockResult(1, result) }; + } + if (upper.contains("\"PROCTRAN\"")) { + throw proctranFailure; + } + // CUSTOMER update reports 1 row affected. + return new MockResult[] { new MockResult(1) }; + }; + Connection connection = new MockConnection(provider); + // Mirror what JooqAutoConfiguration installs in production: translate + // the underlying SQLException into a Spring DAE via + // SQLStateSQLExceptionTranslator (which preserves SQLSTATE 40001 as + // TransientDataAccessException). The repository catches + // org.springframework.dao.DataAccessException, so without this listener + // the test would surface the raw jOOQ DAE. + SQLStateSQLExceptionTranslator translator = new SQLStateSQLExceptionTranslator(); + ExecuteListener springTranslator = new ExecuteListener() { + @Override + public void exception(ExecuteContext ctx) { + SQLException sqle = ctx.sqlException(); + if (sqle != null) { + DataAccessException translated = translator.translate("jOOQ", ctx.sql(), sqle); + if (translated != null) { + ctx.exception(translated); + } + } + } + }; + Configuration realConfiguration = DSL.using(connection, SQLDialect.POSTGRES) + .configuration() + .deriveAppending(springTranslator); + + when(dsl.transactionResult((TransactionalCallable) any())) + .thenAnswer(invocation -> { + TransactionalCallable callable = invocation.getArgument(0); + return callable.run(realConfiguration); + }); + } }