Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
COCKROACH_URL: jdbc:postgresql://localhost:26257/cbsa?sslmode=disable
CBSA_TESTS_USE_LOCAL_COCKROACH: "true"
COCKROACH_URL: jdbc:postgresql://localhost:26257/cbsa_test?sslmode=disable
COCKROACH_USER: root
COCKROACH_PASSWORD: ""
steps:
Expand Down Expand Up @@ -54,7 +55,7 @@ jobs:
sleep 1
done
cockroach sql --insecure --host=localhost:26257 \
-e "CREATE DATABASE IF NOT EXISTS cbsa"
-e "CREATE DATABASE IF NOT EXISTS cbsa_test"

- name: Build and verify
run: ./mvnw -B -ntp verify
8 changes: 8 additions & 0 deletions src/main/java/com/augment/cbsa/CbsaApplication.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package com.augment.cbsa;

import com.augment.cbsa.service.RandomCustomerNumberGenerator;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.context.annotation.Bean;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CbsaApplication {

@Bean
RandomCustomerNumberGenerator randomCustomerNumberGenerator() {
return highestCustomerNumber -> ThreadLocalRandom.current().nextLong(1, highestCustomerNumber + 1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

highestCustomerNumber + 1 can overflow if the DB ever contains a very large customer_number (there’s no DB constraint on the range), which would make ThreadLocalRandom.nextLong(...) throw and turn random lookups into 500s.

Severity: medium

🤖 Was this useful? React with 👍 or 👎

}

public static void main(String[] args) {
SpringApplication.run(CbsaApplication.class, args);
}
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/com/augment/cbsa/domain/CustomerDetails.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.augment.cbsa.domain;

import java.time.LocalDate;
import java.util.Objects;

public record CustomerDetails(
String sortcode,
long customerNumber,
String name,
String address,
LocalDate dateOfBirth,
int creditScore,
LocalDate csReviewDate
) {

public CustomerDetails {
Objects.requireNonNull(sortcode, "sortcode must not be null");
Objects.requireNonNull(name, "name must not be null");
Objects.requireNonNull(address, "address must not be null");
Objects.requireNonNull(dateOfBirth, "dateOfBirth must not be null");

if (creditScore < 0 || creditScore > 999) {
throw new IllegalArgumentException("creditScore must be between 0 and 999");
}
}
}
4 changes: 4 additions & 0 deletions src/main/java/com/augment/cbsa/domain/InqcustRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.augment.cbsa.domain;

public record InqcustRequest(long customerNumber) {
}
45 changes: 45 additions & 0 deletions src/main/java/com/augment/cbsa/domain/InqcustResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.augment.cbsa.domain;

import java.util.Objects;

public record InqcustResult(
boolean inquirySuccess,
String failCode,
long customerNumber,
CustomerDetails customer,
String message
) {

public InqcustResult {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InqcustResult enforces the presence/absence of customer, but it still allows constructing a failure result with message == null. That can later blow up when building CbsaFailureResponse (which requires a non-null message).

Severity: low

🤖 Was this useful? React with 👍 or 👎

Objects.requireNonNull(failCode, "failCode must not be null");

if (inquirySuccess && customer == null) {
throw new IllegalArgumentException("Successful results must include a customer");
}

if (!inquirySuccess && customer != null) {
throw new IllegalArgumentException("Failure results must not include a customer");
}

if (!inquirySuccess && (message == null || message.isBlank())) {
throw new IllegalArgumentException("Failure results must include a non-blank message");
}
}

public static InqcustResult success(CustomerDetails customer) {
Objects.requireNonNull(customer, "customer must not be null");
return new InqcustResult(true, "0", customer.customerNumber(), customer, null);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InqcustResult.success() will throw a NullPointerException if called with a null customer (due to customer.customerNumber()), which is a less clear failure mode than the record's invariant checks.

Severity: low

🤖 Was this useful? React with 👍 or 👎

}

public static InqcustResult failure(String failCode, long customerNumber, String message) {
return new InqcustResult(false, failCode, customerNumber, null, message);
}

public boolean isNotFoundFailure() {
return !inquirySuccess && "1".equals(failCode);
}

public boolean isRandomRetryExhaustedFailure() {
return !inquirySuccess && "R".equals(failCode);
}
}
22 changes: 22 additions & 0 deletions src/main/java/com/augment/cbsa/error/CbsaAbendException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.augment.cbsa.error;

import java.util.Objects;

public class CbsaAbendException extends RuntimeException {

private final String abendCode;

public CbsaAbendException(String abendCode, String message) {
super(message);
this.abendCode = Objects.requireNonNull(abendCode, "abendCode must not be null");
}

public CbsaAbendException(String abendCode, String message, Throwable cause) {
super(message, cause);
this.abendCode = Objects.requireNonNull(abendCode, "abendCode must not be null");
}

public String getAbendCode() {
return abendCode;
}
}
54 changes: 54 additions & 0 deletions src/main/java/com/augment/cbsa/error/CbsaExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.augment.cbsa.error;

import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;

@RestControllerAdvice
public class CbsaExceptionHandler {

private static final Logger logger = LoggerFactory.getLogger(CbsaExceptionHandler.class);

@ExceptionHandler({
MethodArgumentNotValidException.class,
BindException.class,
HandlerMethodValidationException.class,
ConstraintViolationException.class
})
public ResponseEntity<ProblemDetail> handleValidation(Exception exception) {
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problemDetail.setTitle("Validation failed");
problemDetail.setDetail("Request validation failed.");
return ResponseEntity.badRequest().body(problemDetail);
}

@ExceptionHandler(CbsaAbendException.class)
public ResponseEntity<ProblemDetail> handleAbend(CbsaAbendException exception) {
logger.error("CBSA abend while processing request [abendCode={}]", exception.getAbendCode(), exception);

ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problemDetail.setTitle("CBSA abend");
problemDetail.setDetail("Service abend");
problemDetail.setProperty("abendCode", exception.getAbendCode());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problemDetail);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception exception) {
logger.error("Unhandled exception while processing request", exception);

ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problemDetail.setTitle("Unexpected error");
problemDetail.setDetail("Internal server error");
problemDetail.setProperty("abendCode", "UNEX");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problemDetail);
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/augment/cbsa/error/CbsaFailureResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.augment.cbsa.error;

import java.util.Objects;

public record CbsaFailureResponse(String failCode, String message) {

public CbsaFailureResponse {
Objects.requireNonNull(failCode, "failCode must not be null");
Objects.requireNonNull(message, "message must not be null");
}
}
46 changes: 46 additions & 0 deletions src/main/java/com/augment/cbsa/repository/CustomerRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.augment.cbsa.repository;

import com.augment.cbsa.domain.CustomerDetails;
import com.augment.cbsa.jooq.tables.records.CustomerRecord;
import java.util.Optional;
import org.jooq.DSLContext;
import org.springframework.stereotype.Repository;

import static com.augment.cbsa.jooq.Tables.CUSTOMER;

@Repository
public class CustomerRepository {

private final DSLContext dsl;

public CustomerRepository(DSLContext dsl) {
this.dsl = dsl;
}

public Optional<CustomerDetails> findBySortcodeAndCustomerNumber(String sortcode, long customerNumber) {
return dsl.selectFrom(CUSTOMER)
.where(CUSTOMER.SORTCODE.eq(sortcode))
.and(CUSTOMER.CUSTOMER_NUMBER.eq(customerNumber))
.fetchOptional(this::toDomain);
}

public Optional<CustomerDetails> findLastBySortcode(String sortcode) {
return dsl.selectFrom(CUSTOMER)
.where(CUSTOMER.SORTCODE.eq(sortcode))
.orderBy(CUSTOMER.CUSTOMER_NUMBER.desc())
.limit(1)
.fetchOptional(this::toDomain);
}

private CustomerDetails toDomain(CustomerRecord record) {
return new CustomerDetails(
record.getSortcode(),
record.getCustomerNumber(),
record.getName(),
record.getAddress(),
record.getDateOfBirth(),
record.getCreditScore() == null ? 0 : record.getCreditScore(),
record.getCsReviewDate()
);
}
}
92 changes: 92 additions & 0 deletions src/main/java/com/augment/cbsa/service/InqcustService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.augment.cbsa.service;

import com.augment.cbsa.domain.CustomerDetails;
import com.augment.cbsa.domain.InqcustRequest;
import com.augment.cbsa.domain.InqcustResult;
import com.augment.cbsa.error.CbsaAbendException;
import com.augment.cbsa.repository.CustomerRepository;
import java.util.Objects;
import java.util.Optional;
import org.jooq.exception.DataAccessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class InqcustService {

private static final String ABEND_CODE = "CVR1";
private static final String NO_CUSTOMERS_EXIST_MESSAGE = "No customers exist.";
private static final String NOT_FOUND_CODE = "1";
private static final String RANDOM_RETRY_EXHAUSTED_CODE = "R";
private static final long RANDOM_CUSTOMER_NUMBER = 0L;
private static final long LAST_CUSTOMER_NUMBER = 9_999_999_999L;
private static final int RANDOM_RETRY_LIMIT = 1000;

private final CustomerRepository customerRepository;
private final RandomCustomerNumberGenerator randomCustomerNumberGenerator;
private final String sortcode;

public InqcustService(
CustomerRepository customerRepository,
@Value("${cbsa.sortcode}") String sortcode,
RandomCustomerNumberGenerator randomCustomerNumberGenerator
) {
this.customerRepository = customerRepository;
this.sortcode = sortcode;
this.randomCustomerNumberGenerator = randomCustomerNumberGenerator;
}

public InqcustResult inquire(InqcustRequest request) {
Objects.requireNonNull(request, "request must not be null");

try {
long customerNumber = request.customerNumber();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If inquire() is called with a null request, this will throw a NullPointerException before reaching the DataAccessException handling. Consider defensively null-checking the request at the method boundary to fail with a clearer error.

Severity: low

🤖 Was this useful? React with 👍 or 👎


if (customerNumber == RANDOM_CUSTOMER_NUMBER) {
return findRandomCustomer();
}

if (customerNumber == LAST_CUSTOMER_NUMBER) {
return customerRepository.findLastBySortcode(sortcode)
.map(InqcustResult::success)
.orElseGet(() -> InqcustResult.failure(
NOT_FOUND_CODE,
customerNumber,
NO_CUSTOMERS_EXIST_MESSAGE
));
}

return customerRepository.findBySortcodeAndCustomerNumber(sortcode, customerNumber)
.map(InqcustResult::success)
.orElseGet(() -> InqcustResult.failure(
NOT_FOUND_CODE,
customerNumber,
"Customer number %d was not found.".formatted(customerNumber)
));
} catch (DataAccessException exception) {
throw new CbsaAbendException(ABEND_CODE, "INQCUST failed to read the customer data.", exception);
}
}

private InqcustResult findRandomCustomer() {
Optional<CustomerDetails> lastCustomer = customerRepository.findLastBySortcode(sortcode);
if (lastCustomer.isEmpty() || lastCustomer.get().customerNumber() < 1) {
return InqcustResult.failure(NOT_FOUND_CODE, RANDOM_CUSTOMER_NUMBER, NO_CUSTOMERS_EXIST_MESSAGE);
}

long highestCustomerNumber = lastCustomer.get().customerNumber();
for (int attempt = 0; attempt < RANDOM_RETRY_LIMIT; attempt++) {
long candidate = randomCustomerNumberGenerator.nextCustomerNumber(highestCustomerNumber);
Optional<CustomerDetails> customer = customerRepository.findBySortcodeAndCustomerNumber(sortcode, candidate);
if (customer.isPresent()) {
return InqcustResult.success(customer.get());
}
}

return InqcustResult.failure(
RANDOM_RETRY_EXHAUSTED_CODE,
RANDOM_CUSTOMER_NUMBER,
"Unable to find a random customer after exhausting retry attempts."
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.augment.cbsa.service;

@FunctionalInterface
public interface RandomCustomerNumberGenerator {

long nextCustomerNumber(long highestCustomerNumber);
}
Loading
Loading