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.
+ *
+ * - 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 CONTROL select / CUSTOMER
+ * insert / CONTROL 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 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:
+ *
+ * - building a real {@code Configuration} backed by a
+ * {@link MockConnection} whose {@link MockDataProvider}:
+ *
+ * - returns {@code (CUSTOMER_COUNT=0, CUSTOMER_LAST=0)} for the
+ * CONTROL {@code SELECT ... FOR UPDATE},
+ * - reports 1 row affected for the CUSTOMER insert and the
+ * CONTROL 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("\"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);
+ });
+ }
}