From ed0d24ab418d60adcb034615a5dcacccb7b0c592 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:37:56 +0000 Subject: [PATCH 1/3] Phase 1B: carddemo-refdata Spring Boot service for transaction types, categories, and disclosure groups - JPA entities mapping COBOL copybooks CVTRA03Y, CVTRA04Y, CVTRA02Y - Full CRUD REST API under /reference/{types,categories,disclosure-groups} - Bean validation: type code length, category code range, interest rate precision - PostgreSQL-backed with H2 test profile - 63 passing tests (unit + integration) --- services/carddemo-refdata/.gitignore | 1 + services/carddemo-refdata/README.md | 142 +++++++++++++++++ services/carddemo-refdata/pom.xml | 70 +++++++++ .../carddemo/refdata/RefDataApplication.java | 12 ++ .../controller/DisclosureGroupController.java | 72 +++++++++ .../controller/GlobalExceptionHandler.java | 30 ++++ .../TransactionCategoryController.java | 69 ++++++++ .../controller/TransactionTypeController.java | 66 ++++++++ .../refdata/entity/DisclosureGroup.java | 99 ++++++++++++ .../refdata/entity/DisclosureGroupId.java | 63 ++++++++ .../refdata/entity/TransactionCategory.java | 77 +++++++++ .../refdata/entity/TransactionCategoryId.java | 51 ++++++ .../refdata/entity/TransactionType.java | 56 +++++++ .../repository/DisclosureGroupRepository.java | 14 ++ .../TransactionCategoryRepository.java | 12 ++ .../repository/TransactionTypeRepository.java | 7 + .../service/DisclosureGroupService.java | 60 +++++++ .../service/TransactionCategoryService.java | 56 +++++++ .../service/TransactionTypeService.java | 51 ++++++ .../src/main/resources/application.yml | 22 +++ .../refdata/RefDataApplicationTest.java | 14 ++ .../DisclosureGroupControllerTest.java | 148 ++++++++++++++++++ .../TransactionCategoryControllerTest.java | 135 ++++++++++++++++ .../TransactionTypeControllerTest.java | 143 +++++++++++++++++ .../TransactionTypeRepositoryTest.java | 42 +++++ .../service/DisclosureGroupServiceTest.java | 106 +++++++++++++ .../TransactionCategoryServiceTest.java | 104 ++++++++++++ .../service/TransactionTypeServiceTest.java | 104 ++++++++++++ .../src/test/resources/application-test.yml | 13 ++ 29 files changed, 1839 insertions(+) create mode 100644 services/carddemo-refdata/.gitignore create mode 100644 services/carddemo-refdata/README.md create mode 100644 services/carddemo-refdata/pom.xml create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/RefDataApplication.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/DisclosureGroupController.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionCategoryController.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionTypeController.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroup.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroupId.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategory.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategoryId.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionType.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/DisclosureGroupRepository.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionCategoryRepository.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionTypeRepository.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java create mode 100644 services/carddemo-refdata/src/main/resources/application.yml create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/RefDataApplicationTest.java create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/DisclosureGroupControllerTest.java create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionCategoryControllerTest.java create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionTypeControllerTest.java create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/repository/TransactionTypeRepositoryTest.java create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java create mode 100644 services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java create mode 100644 services/carddemo-refdata/src/test/resources/application-test.yml diff --git a/services/carddemo-refdata/.gitignore b/services/carddemo-refdata/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/services/carddemo-refdata/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/services/carddemo-refdata/README.md b/services/carddemo-refdata/README.md new file mode 100644 index 00000000..04309ae9 --- /dev/null +++ b/services/carddemo-refdata/README.md @@ -0,0 +1,142 @@ +# CardDemo Reference Data Service (`carddemo-refdata`) + +Spring Boot 3.x microservice that replaces the COBOL/DB2 reference-data subsystem of the CardDemo mainframe application with a modern REST API backed by PostgreSQL. + +## Legacy COBOL → Java Mapping + +| COBOL Artifact | Purpose | Java Equivalent | +|---|---|---| +| **CVTRA03Y.cpy** — `TRAN-TYPE-RECORD` (RECLN=60) | Transaction type master | `TransactionType` entity | +| **CVTRA04Y.cpy** — `TRAN-CAT-RECORD` (RECLN=60) | Transaction category (composite key: type + cat) | `TransactionCategory` entity | +| **CVTRA02Y.cpy** — `DIS-GROUP-RECORD` (RECLN=50) | Disclosure group with interest rate | `DisclosureGroup` entity | +| **COTRTLIC.cbl** (2 098 LOC) | CICS list/delete transaction types & categories | `TransactionTypeController`, `TransactionCategoryController` (GET / DELETE) | +| **COTRTUPC.cbl** (1 702 LOC) | CICS add/edit transaction types & categories | Same controllers (POST / PUT) | +| **COBTUPDT.cbl** | Batch maintenance of transaction types | Superseded by REST CRUD | + +### Field-Level Mapping + +#### TransactionType — `CVTRA03Y.cpy` + +| COBOL Field | PIC | Java Field | Type | +|---|---|---|---| +| `TRAN-TYPE` | `X(02)` | `typeCode` | `String(2)` — Primary Key | +| `TRAN-TYPE-DESC` | `X(50)` | `description` | `String(50)` | +| `FILLER` | `X(08)` | — | not mapped | + +#### TransactionCategory — `CVTRA04Y.cpy` + +| COBOL Field | PIC | Java Field | Type | +|---|---|---|---| +| `TRAN-TYPE-CD` | `X(02)` | `typeCode` | `String(2)` — Composite PK part 1 | +| `TRAN-CAT-CD` | `9(04)` | `categoryCode` | `int` (0–9999) — Composite PK part 2 | +| `TRAN-CAT-TYPE-DESC` | `X(50)` | `description` | `String(50)` | +| `FILLER` | `X(04)` | — | not mapped | + +#### DisclosureGroup — `CVTRA02Y.cpy` + +| COBOL Field | PIC | Java Field | Type | +|---|---|---|---| +| `DIS-ACCT-GROUP-ID` | `X(10)` | `accountGroupId` | `String(10)` — Composite PK part 1 | +| `DIS-TRAN-TYPE-CD` | `X(02)` | `transactionTypeCode` | `String(2)` — Composite PK part 2 | +| `DIS-TRAN-CAT-CD` | `9(04)` | `transactionCategoryCode` | `int` (0–9999) — Composite PK part 3 | +| `DIS-INT-RATE` | `S9(04)V99` | `interestRate` | `BigDecimal(6,2)` | +| `FILLER` | `X(28)` | — | not mapped | + +## REST API + +| Method | Path | Description | +|---|---|---| +| `GET` | `/reference/types` | List all transaction types | +| `POST` | `/reference/types` | Create a transaction type | +| `GET` | `/reference/types/{code}` | Get a single type | +| `PUT` | `/reference/types/{code}` | Update a type | +| `DELETE` | `/reference/types/{code}` | Delete a type | +| `GET` | `/reference/categories` | List all categories | +| `POST` | `/reference/categories` | Create a category | +| `GET` | `/reference/categories/{typeCode}/{catCode}` | Get a single category | +| `PUT` | `/reference/categories/{typeCode}/{catCode}` | Update a category | +| `DELETE` | `/reference/categories/{typeCode}/{catCode}` | Delete a category | +| `GET` | `/reference/disclosure-groups` | List all disclosure groups | +| `POST` | `/reference/disclosure-groups` | Create a disclosure group | +| `GET` | `/reference/disclosure-groups/{groupId}/{typeCode}/{catCode}` | Get a single group | +| `PUT` | `/reference/disclosure-groups/{groupId}/{typeCode}/{catCode}` | Update a group | +| `DELETE` | `/reference/disclosure-groups/{groupId}/{typeCode}/{catCode}` | Delete a group | + +## Validation Rules + +- **Type code**: 1–2 characters (`@Size(min=1, max=2)`, `@NotBlank`) +- **Category code**: 0–9999 (`@Min(0)`, `@Max(9999)`) +- **Account group ID**: 1–10 characters +- **Interest rate**: `BigDecimal` with up to 4 integer digits and 2 fractional digits (`@Digits(integer=4, fraction=2)`) + +## Running Locally + +### Prerequisites +- Java 17+ +- Maven 3.6+ +- PostgreSQL 14+ (or use the H2 test profile) + +### Build & Test + +```bash +cd services/carddemo-refdata +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 # adjust as needed +mvn clean test # runs all 63 tests against H2 +mvn spring-boot:run # starts on port 8082 (requires PostgreSQL) +``` + +### Configuration + +| Property | Default | Description | +|---|---|---| +| `spring.datasource.url` | `jdbc:postgresql://localhost:5432/carddemo` | JDBC URL | +| `spring.datasource.username` | `carddemo` | DB user | +| `spring.datasource.password` | `carddemo` | DB password | +| `server.port` | `8082` | HTTP port | + +## Project Structure + +``` +services/carddemo-refdata/ +├── pom.xml +├── README.md +└── src/ + ├── main/java/com/carddemo/refdata/ + │ ├── RefDataApplication.java + │ ├── controller/ + │ │ ├── DisclosureGroupController.java + │ │ ├── GlobalExceptionHandler.java + │ │ ├── TransactionCategoryController.java + │ │ └── TransactionTypeController.java + │ ├── entity/ + │ │ ├── DisclosureGroup.java + │ │ ├── DisclosureGroupId.java + │ │ ├── TransactionCategory.java + │ │ ├── TransactionCategoryId.java + │ │ └── TransactionType.java + │ ├── repository/ + │ │ ├── DisclosureGroupRepository.java + │ │ ├── TransactionCategoryRepository.java + │ │ └── TransactionTypeRepository.java + │ └── service/ + │ ├── DisclosureGroupService.java + │ ├── TransactionCategoryService.java + │ └── TransactionTypeService.java + ├── main/resources/ + │ └── application.yml + └── test/ + ├── java/com/carddemo/refdata/ + │ ├── RefDataApplicationTest.java + │ ├── controller/ + │ │ ├── DisclosureGroupControllerTest.java + │ │ ├── TransactionCategoryControllerTest.java + │ │ └── TransactionTypeControllerTest.java + │ ├── repository/ + │ │ └── TransactionTypeRepositoryTest.java + │ └── service/ + │ ├── DisclosureGroupServiceTest.java + │ ├── TransactionCategoryServiceTest.java + │ └── TransactionTypeServiceTest.java + └── resources/ + └── application-test.yml +``` diff --git a/services/carddemo-refdata/pom.xml b/services/carddemo-refdata/pom.xml new file mode 100644 index 00000000..a39bc9ba --- /dev/null +++ b/services/carddemo-refdata/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.6 + + + + com.carddemo + carddemo-refdata + 0.1.0-SNAPSHOT + CardDemo Reference Data Service + Spring Boot service for CardDemo transaction type, category, and disclosure group reference data (Phase 1B) + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.postgresql + postgresql + runtime + + + + + com.h2database + h2 + test + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/RefDataApplication.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/RefDataApplication.java new file mode 100644 index 00000000..2324e874 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/RefDataApplication.java @@ -0,0 +1,12 @@ +package com.carddemo.refdata; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class RefDataApplication { + + public static void main(String[] args) { + SpringApplication.run(RefDataApplication.class, args); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/DisclosureGroupController.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/DisclosureGroupController.java new file mode 100644 index 00000000..559a256a --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/DisclosureGroupController.java @@ -0,0 +1,72 @@ +package com.carddemo.refdata.controller; + +import java.util.List; + +import com.carddemo.refdata.entity.DisclosureGroup; +import com.carddemo.refdata.service.DisclosureGroupService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/reference/disclosure-groups") +public class DisclosureGroupController { + + private final DisclosureGroupService service; + + public DisclosureGroupController(DisclosureGroupService service) { + this.service = service; + } + + @GetMapping + public List list() { + return service.findAll(); + } + + @GetMapping("/{groupId}/{typeCode}/{catCode}") + public ResponseEntity get(@PathVariable String groupId, + @PathVariable String typeCode, + @PathVariable int catCode) { + DisclosureGroup entity = service.findByKey(groupId, typeCode, catCode); + if (entity == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(entity); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody DisclosureGroup entity) { + DisclosureGroup created = service.create(entity); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PutMapping("/{groupId}/{typeCode}/{catCode}") + public ResponseEntity update(@PathVariable String groupId, + @PathVariable String typeCode, + @PathVariable int catCode, + @Valid @RequestBody DisclosureGroup entity) { + DisclosureGroup updated = service.update(groupId, typeCode, catCode, entity); + if (updated == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(updated); + } + + @DeleteMapping("/{groupId}/{typeCode}/{catCode}") + public ResponseEntity delete(@PathVariable String groupId, + @PathVariable String typeCode, + @PathVariable int catCode) { + if (!service.delete(groupId, typeCode, catCode)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.noContent().build(); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java new file mode 100644 index 00000000..cde46a38 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java @@ -0,0 +1,30 @@ +package com.carddemo.refdata.controller; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + for (FieldError fe : ex.getBindingResult().getFieldErrors()) { + errors.put(fe.getField(), fe.getDefaultMessage()); + } + return ResponseEntity.badRequest().body(errors); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleConflict(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", ex.getMessage())); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionCategoryController.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionCategoryController.java new file mode 100644 index 00000000..e91039a0 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionCategoryController.java @@ -0,0 +1,69 @@ +package com.carddemo.refdata.controller; + +import java.util.List; + +import com.carddemo.refdata.entity.TransactionCategory; +import com.carddemo.refdata.service.TransactionCategoryService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/reference/categories") +public class TransactionCategoryController { + + private final TransactionCategoryService service; + + public TransactionCategoryController(TransactionCategoryService service) { + this.service = service; + } + + @GetMapping + public List list() { + return service.findAll(); + } + + @GetMapping("/{typeCode}/{catCode}") + public ResponseEntity get(@PathVariable String typeCode, + @PathVariable int catCode) { + TransactionCategory entity = service.findByKey(typeCode, catCode); + if (entity == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(entity); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody TransactionCategory entity) { + TransactionCategory created = service.create(entity); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PutMapping("/{typeCode}/{catCode}") + public ResponseEntity update(@PathVariable String typeCode, + @PathVariable int catCode, + @Valid @RequestBody TransactionCategory entity) { + TransactionCategory updated = service.update(typeCode, catCode, entity); + if (updated == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(updated); + } + + @DeleteMapping("/{typeCode}/{catCode}") + public ResponseEntity delete(@PathVariable String typeCode, + @PathVariable int catCode) { + if (!service.delete(typeCode, catCode)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.noContent().build(); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionTypeController.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionTypeController.java new file mode 100644 index 00000000..2de4c9d7 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/TransactionTypeController.java @@ -0,0 +1,66 @@ +package com.carddemo.refdata.controller; + +import java.util.List; + +import com.carddemo.refdata.entity.TransactionType; +import com.carddemo.refdata.service.TransactionTypeService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/reference/types") +public class TransactionTypeController { + + private final TransactionTypeService service; + + public TransactionTypeController(TransactionTypeService service) { + this.service = service; + } + + @GetMapping + public List list() { + return service.findAll(); + } + + @GetMapping("/{code}") + public ResponseEntity get(@PathVariable String code) { + TransactionType entity = service.findByCode(code); + if (entity == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(entity); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody TransactionType entity) { + TransactionType created = service.create(entity); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PutMapping("/{code}") + public ResponseEntity update(@PathVariable String code, + @Valid @RequestBody TransactionType entity) { + TransactionType updated = service.update(code, entity); + if (updated == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(updated); + } + + @DeleteMapping("/{code}") + public ResponseEntity delete(@PathVariable String code) { + if (!service.delete(code)) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.noContent().build(); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroup.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroup.java new file mode 100644 index 00000000..de1b523e --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroup.java @@ -0,0 +1,99 @@ +package com.carddemo.refdata.entity; + +import java.math.BigDecimal; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * Maps to COBOL copybook CVTRA02Y.cpy — DIS-GROUP-RECORD (RECLN=50). + * + *
+ *   05 DIS-GROUP-KEY
+ *     10 DIS-ACCT-GROUP-ID  PIC X(10)     -> accountGroupId  CHAR(10), PK part 1
+ *     10 DIS-TRAN-TYPE-CD   PIC X(02)     -> transactionTypeCode CHAR(2), PK part 2
+ *     10 DIS-TRAN-CAT-CD    PIC 9(04)     -> transactionCategoryCode INT, PK part 3
+ *   05 DIS-INT-RATE         PIC S9(04)V99 -> interestRate DECIMAL(6,2)
+ *   05 FILLER               PIC X(28)     -- padding, not mapped
+ * 
+ */ +@Entity +@Table(name = "disclosure_group") +@IdClass(DisclosureGroupId.class) +public class DisclosureGroup { + + @Id + @Column(name = "account_group_id", length = 10, nullable = false) + @NotBlank + @Size(min = 1, max = 10) + private String accountGroupId; + + @Id + @Column(name = "transaction_type_code", length = 2, nullable = false) + @NotBlank + @Size(min = 1, max = 2) + private String transactionTypeCode; + + @Id + @Column(name = "transaction_category_code", nullable = false) + @Min(0) + @Max(9999) + private int transactionCategoryCode; + + @Column(name = "interest_rate", precision = 6, scale = 2) + @NotNull + @Digits(integer = 4, fraction = 2) + private BigDecimal interestRate; + + public DisclosureGroup() { + } + + public DisclosureGroup(String accountGroupId, String transactionTypeCode, + int transactionCategoryCode, BigDecimal interestRate) { + this.accountGroupId = accountGroupId; + this.transactionTypeCode = transactionTypeCode; + this.transactionCategoryCode = transactionCategoryCode; + this.interestRate = interestRate; + } + + public String getAccountGroupId() { + return accountGroupId; + } + + public void setAccountGroupId(String accountGroupId) { + this.accountGroupId = accountGroupId; + } + + public String getTransactionTypeCode() { + return transactionTypeCode; + } + + public void setTransactionTypeCode(String transactionTypeCode) { + this.transactionTypeCode = transactionTypeCode; + } + + public int getTransactionCategoryCode() { + return transactionCategoryCode; + } + + public void setTransactionCategoryCode(int transactionCategoryCode) { + this.transactionCategoryCode = transactionCategoryCode; + } + + public BigDecimal getInterestRate() { + return interestRate; + } + + public void setInterestRate(BigDecimal interestRate) { + this.interestRate = interestRate; + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroupId.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroupId.java new file mode 100644 index 00000000..31dc9829 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/DisclosureGroupId.java @@ -0,0 +1,63 @@ +package com.carddemo.refdata.entity; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Composite primary key for DisclosureGroup, matching the COBOL + * DIS-GROUP-KEY in CVTRA02Y.cpy (DIS-ACCT-GROUP-ID + DIS-TRAN-TYPE-CD + DIS-TRAN-CAT-CD). + */ +public class DisclosureGroupId implements Serializable { + + private String accountGroupId; + private String transactionTypeCode; + private int transactionCategoryCode; + + public DisclosureGroupId() { + } + + public DisclosureGroupId(String accountGroupId, String transactionTypeCode, int transactionCategoryCode) { + this.accountGroupId = accountGroupId; + this.transactionTypeCode = transactionTypeCode; + this.transactionCategoryCode = transactionCategoryCode; + } + + public String getAccountGroupId() { + return accountGroupId; + } + + public void setAccountGroupId(String accountGroupId) { + this.accountGroupId = accountGroupId; + } + + public String getTransactionTypeCode() { + return transactionTypeCode; + } + + public void setTransactionTypeCode(String transactionTypeCode) { + this.transactionTypeCode = transactionTypeCode; + } + + public int getTransactionCategoryCode() { + return transactionCategoryCode; + } + + public void setTransactionCategoryCode(int transactionCategoryCode) { + this.transactionCategoryCode = transactionCategoryCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DisclosureGroupId that = (DisclosureGroupId) o; + return transactionCategoryCode == that.transactionCategoryCode + && Objects.equals(accountGroupId, that.accountGroupId) + && Objects.equals(transactionTypeCode, that.transactionTypeCode); + } + + @Override + public int hashCode() { + return Objects.hash(accountGroupId, transactionTypeCode, transactionCategoryCode); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategory.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategory.java new file mode 100644 index 00000000..95b6b2cc --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategory.java @@ -0,0 +1,77 @@ +package com.carddemo.refdata.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Maps to COBOL copybook CVTRA04Y.cpy — TRAN-CAT-RECORD (RECLN=60). + * + *
+ *   05 TRAN-CAT-KEY
+ *     10 TRAN-TYPE-CD    PIC X(02)   -> typeCode   CHAR(2), composite PK part 1
+ *     10 TRAN-CAT-CD     PIC 9(04)   -> categoryCode INT, composite PK part 2
+ *   05 TRAN-CAT-TYPE-DESC PIC X(50)  -> description VARCHAR(50)
+ *   05 FILLER             PIC X(04)  -- padding, not mapped
+ * 
+ */ +@Entity +@Table(name = "transaction_category") +@IdClass(TransactionCategoryId.class) +public class TransactionCategory { + + @Id + @Column(name = "type_code", length = 2, nullable = false) + @NotBlank + @Size(min = 1, max = 2) + private String typeCode; + + @Id + @Column(name = "category_code", nullable = false) + @Min(0) + @Max(9999) + private int categoryCode; + + @Column(name = "description", length = 50) + @Size(max = 50) + private String description; + + public TransactionCategory() { + } + + public TransactionCategory(String typeCode, int categoryCode, String description) { + this.typeCode = typeCode; + this.categoryCode = categoryCode; + this.description = description; + } + + public String getTypeCode() { + return typeCode; + } + + public void setTypeCode(String typeCode) { + this.typeCode = typeCode; + } + + public int getCategoryCode() { + return categoryCode; + } + + public void setCategoryCode(int categoryCode) { + this.categoryCode = categoryCode; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategoryId.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategoryId.java new file mode 100644 index 00000000..a194e2a8 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionCategoryId.java @@ -0,0 +1,51 @@ +package com.carddemo.refdata.entity; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Composite primary key for TransactionCategory, matching the COBOL + * TRAN-CAT-KEY group in CVTRA04Y.cpy (TRAN-TYPE-CD + TRAN-CAT-CD). + */ +public class TransactionCategoryId implements Serializable { + + private String typeCode; + private int categoryCode; + + public TransactionCategoryId() { + } + + public TransactionCategoryId(String typeCode, int categoryCode) { + this.typeCode = typeCode; + this.categoryCode = categoryCode; + } + + public String getTypeCode() { + return typeCode; + } + + public void setTypeCode(String typeCode) { + this.typeCode = typeCode; + } + + public int getCategoryCode() { + return categoryCode; + } + + public void setCategoryCode(int categoryCode) { + this.categoryCode = categoryCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TransactionCategoryId that = (TransactionCategoryId) o; + return categoryCode == that.categoryCode && Objects.equals(typeCode, that.typeCode); + } + + @Override + public int hashCode() { + return Objects.hash(typeCode, categoryCode); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionType.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionType.java new file mode 100644 index 00000000..6a041e00 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/entity/TransactionType.java @@ -0,0 +1,56 @@ +package com.carddemo.refdata.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Maps to COBOL copybook CVTRA03Y.cpy — TRAN-TYPE-RECORD (RECLN=60). + * + *
+ *   05 TRAN-TYPE      PIC X(02)   -> typeCode  CHAR(2), PK
+ *   05 TRAN-TYPE-DESC PIC X(50)   -> description VARCHAR(50)
+ *   05 FILLER         PIC X(08)   -- padding, not mapped
+ * 
+ */ +@Entity +@Table(name = "transaction_type") +public class TransactionType { + + @Id + @Column(name = "type_code", length = 2, nullable = false) + @NotBlank + @Size(min = 1, max = 2) + private String typeCode; + + @Column(name = "description", length = 50) + @Size(max = 50) + private String description; + + public TransactionType() { + } + + public TransactionType(String typeCode, String description) { + this.typeCode = typeCode; + this.description = description; + } + + public String getTypeCode() { + return typeCode; + } + + public void setTypeCode(String typeCode) { + this.typeCode = typeCode; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/DisclosureGroupRepository.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/DisclosureGroupRepository.java new file mode 100644 index 00000000..155445ce --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/DisclosureGroupRepository.java @@ -0,0 +1,14 @@ +package com.carddemo.refdata.repository; + +import java.util.List; + +import com.carddemo.refdata.entity.DisclosureGroup; +import com.carddemo.refdata.entity.DisclosureGroupId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DisclosureGroupRepository extends JpaRepository { + + List findByAccountGroupId(String accountGroupId); + + List findByTransactionTypeCode(String transactionTypeCode); +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionCategoryRepository.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionCategoryRepository.java new file mode 100644 index 00000000..69b2fbb7 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionCategoryRepository.java @@ -0,0 +1,12 @@ +package com.carddemo.refdata.repository; + +import java.util.List; + +import com.carddemo.refdata.entity.TransactionCategory; +import com.carddemo.refdata.entity.TransactionCategoryId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TransactionCategoryRepository extends JpaRepository { + + List findByTypeCode(String typeCode); +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionTypeRepository.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionTypeRepository.java new file mode 100644 index 00000000..29f63e34 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/repository/TransactionTypeRepository.java @@ -0,0 +1,7 @@ +package com.carddemo.refdata.repository; + +import com.carddemo.refdata.entity.TransactionType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TransactionTypeRepository extends JpaRepository { +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java new file mode 100644 index 00000000..1739806a --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java @@ -0,0 +1,60 @@ +package com.carddemo.refdata.service; + +import java.util.List; + +import com.carddemo.refdata.entity.DisclosureGroup; +import com.carddemo.refdata.entity.DisclosureGroupId; +import com.carddemo.refdata.repository.DisclosureGroupRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class DisclosureGroupService { + + private final DisclosureGroupRepository repository; + + public DisclosureGroupService(DisclosureGroupRepository repository) { + this.repository = repository; + } + + public List findAll() { + return repository.findAll(); + } + + public DisclosureGroup findByKey(String groupId, String typeCode, int catCode) { + return repository.findById(new DisclosureGroupId(groupId, typeCode, catCode)).orElse(null); + } + + public DisclosureGroup create(DisclosureGroup entity) { + DisclosureGroupId id = new DisclosureGroupId( + entity.getAccountGroupId(), + entity.getTransactionTypeCode(), + entity.getTransactionCategoryCode()); + if (repository.existsById(id)) { + throw new IllegalArgumentException("Disclosure group already exists: " + + entity.getAccountGroupId() + "/" + entity.getTransactionTypeCode() + + "/" + entity.getTransactionCategoryCode()); + } + return repository.save(entity); + } + + public DisclosureGroup update(String groupId, String typeCode, int catCode, DisclosureGroup entity) { + DisclosureGroupId id = new DisclosureGroupId(groupId, typeCode, catCode); + DisclosureGroup existing = repository.findById(id).orElse(null); + if (existing == null) { + return null; + } + existing.setInterestRate(entity.getInterestRate()); + return repository.save(existing); + } + + public boolean delete(String groupId, String typeCode, int catCode) { + DisclosureGroupId id = new DisclosureGroupId(groupId, typeCode, catCode); + if (!repository.existsById(id)) { + return false; + } + repository.deleteById(id); + return true; + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java new file mode 100644 index 00000000..694d96e3 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java @@ -0,0 +1,56 @@ +package com.carddemo.refdata.service; + +import java.util.List; + +import com.carddemo.refdata.entity.TransactionCategory; +import com.carddemo.refdata.entity.TransactionCategoryId; +import com.carddemo.refdata.repository.TransactionCategoryRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class TransactionCategoryService { + + private final TransactionCategoryRepository repository; + + public TransactionCategoryService(TransactionCategoryRepository repository) { + this.repository = repository; + } + + public List findAll() { + return repository.findAll(); + } + + public TransactionCategory findByKey(String typeCode, int categoryCode) { + return repository.findById(new TransactionCategoryId(typeCode, categoryCode)).orElse(null); + } + + public TransactionCategory create(TransactionCategory entity) { + TransactionCategoryId id = new TransactionCategoryId(entity.getTypeCode(), entity.getCategoryCode()); + if (repository.existsById(id)) { + throw new IllegalArgumentException( + "Transaction category already exists: " + entity.getTypeCode() + "/" + entity.getCategoryCode()); + } + return repository.save(entity); + } + + public TransactionCategory update(String typeCode, int categoryCode, TransactionCategory entity) { + TransactionCategoryId id = new TransactionCategoryId(typeCode, categoryCode); + TransactionCategory existing = repository.findById(id).orElse(null); + if (existing == null) { + return null; + } + existing.setDescription(entity.getDescription()); + return repository.save(existing); + } + + public boolean delete(String typeCode, int categoryCode) { + TransactionCategoryId id = new TransactionCategoryId(typeCode, categoryCode); + if (!repository.existsById(id)) { + return false; + } + repository.deleteById(id); + return true; + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java new file mode 100644 index 00000000..3634aad2 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java @@ -0,0 +1,51 @@ +package com.carddemo.refdata.service; + +import java.util.List; + +import com.carddemo.refdata.entity.TransactionType; +import com.carddemo.refdata.repository.TransactionTypeRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class TransactionTypeService { + + private final TransactionTypeRepository repository; + + public TransactionTypeService(TransactionTypeRepository repository) { + this.repository = repository; + } + + public List findAll() { + return repository.findAll(); + } + + public TransactionType findByCode(String code) { + return repository.findById(code).orElse(null); + } + + public TransactionType create(TransactionType entity) { + if (repository.existsById(entity.getTypeCode())) { + throw new IllegalArgumentException("Transaction type already exists: " + entity.getTypeCode()); + } + return repository.save(entity); + } + + public TransactionType update(String code, TransactionType entity) { + TransactionType existing = repository.findById(code).orElse(null); + if (existing == null) { + return null; + } + existing.setDescription(entity.getDescription()); + return repository.save(existing); + } + + public boolean delete(String code) { + if (!repository.existsById(code)) { + return false; + } + repository.deleteById(code); + return true; + } +} diff --git a/services/carddemo-refdata/src/main/resources/application.yml b/services/carddemo-refdata/src/main/resources/application.yml new file mode 100644 index 00000000..db6cc13c --- /dev/null +++ b/services/carddemo-refdata/src/main/resources/application.yml @@ -0,0 +1,22 @@ +spring: + application: + name: carddemo-refdata + + datasource: + url: jdbc:postgresql://localhost:5432/carddemo + username: carddemo + password: carddemo + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +server: + port: 8082 + servlet: + context-path: / diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/RefDataApplicationTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/RefDataApplicationTest.java new file mode 100644 index 00000000..4a706544 --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/RefDataApplicationTest.java @@ -0,0 +1,14 @@ +package com.carddemo.refdata; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class RefDataApplicationTest { + + @Test + void contextLoads() { + } +} diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/DisclosureGroupControllerTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/DisclosureGroupControllerTest.java new file mode 100644 index 00000000..68274e81 --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/DisclosureGroupControllerTest.java @@ -0,0 +1,148 @@ +package com.carddemo.refdata.controller; + +import java.math.BigDecimal; + +import com.carddemo.refdata.entity.DisclosureGroup; +import com.carddemo.refdata.repository.DisclosureGroupRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class DisclosureGroupControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private DisclosureGroupRepository repository; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + repository.deleteAll(); + } + + @Test + void listGroups_empty() throws Exception { + mockMvc.perform(get("/reference/disclosure-groups")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void createAndGetGroup() throws Exception { + DisclosureGroup dg = new DisclosureGroup("GRP0000001", "SA", 1001, new BigDecimal("12.50")); + mockMvc.perform(post("/reference/disclosure-groups") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dg))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accountGroupId", is("GRP0000001"))) + .andExpect(jsonPath("$.transactionTypeCode", is("SA"))) + .andExpect(jsonPath("$.transactionCategoryCode", is(1001))) + .andExpect(jsonPath("$.interestRate", is(12.50))); + + mockMvc.perform(get("/reference/disclosure-groups/GRP0000001/SA/1001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.interestRate", is(12.50))); + } + + @Test + void createDuplicate_returns409() throws Exception { + repository.save(new DisclosureGroup("GRP0000001", "SA", 1001, new BigDecimal("12.50"))); + DisclosureGroup dup = new DisclosureGroup("GRP0000001", "SA", 1001, new BigDecimal("15.00")); + mockMvc.perform(post("/reference/disclosure-groups") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dup))) + .andExpect(status().isConflict()); + } + + @Test + void getNotFound() throws Exception { + mockMvc.perform(get("/reference/disclosure-groups/NOGROUP/ZZ/0")) + .andExpect(status().isNotFound()); + } + + @Test + void updateGroup() throws Exception { + repository.save(new DisclosureGroup("GRP0000001", "SA", 1001, new BigDecimal("12.50"))); + DisclosureGroup update = new DisclosureGroup("GRP0000001", "SA", 1001, new BigDecimal("18.75")); + mockMvc.perform(put("/reference/disclosure-groups/GRP0000001/SA/1001") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.interestRate", is(18.75))); + } + + @Test + void updateNotFound() throws Exception { + DisclosureGroup update = new DisclosureGroup("NOGROUP", "ZZ", 0, new BigDecimal("1.00")); + mockMvc.perform(put("/reference/disclosure-groups/NOGROUP/ZZ/0") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isNotFound()); + } + + @Test + void deleteGroup() throws Exception { + repository.save(new DisclosureGroup("GRP0000001", "SA", 1001, new BigDecimal("12.50"))); + mockMvc.perform(delete("/reference/disclosure-groups/GRP0000001/SA/1001")) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/reference/disclosure-groups/GRP0000001/SA/1001")) + .andExpect(status().isNotFound()); + } + + @Test + void deleteNotFound() throws Exception { + mockMvc.perform(delete("/reference/disclosure-groups/NOGROUP/ZZ/0")) + .andExpect(status().isNotFound()); + } + + @Test + void createInvalid_nullRate() throws Exception { + String json = "{\"accountGroupId\":\"GRP0000001\",\"transactionTypeCode\":\"SA\"," + + "\"transactionCategoryCode\":1001}"; + mockMvc.perform(post("/reference/disclosure-groups") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + void createInvalid_groupIdTooLong() throws Exception { + DisclosureGroup dg = new DisclosureGroup("TOOLONGGROUP", "SA", 1001, new BigDecimal("12.50")); + mockMvc.perform(post("/reference/disclosure-groups") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dg))) + .andExpect(status().isBadRequest()); + } + + @Test + void createInvalid_rateTooManyDecimals() throws Exception { + DisclosureGroup dg = new DisclosureGroup("GRP0000001", "SA", 1001, new BigDecimal("12.501")); + mockMvc.perform(post("/reference/disclosure-groups") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dg))) + .andExpect(status().isBadRequest()); + } +} diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionCategoryControllerTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionCategoryControllerTest.java new file mode 100644 index 00000000..7cdb0448 --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionCategoryControllerTest.java @@ -0,0 +1,135 @@ +package com.carddemo.refdata.controller; + +import com.carddemo.refdata.entity.TransactionCategory; +import com.carddemo.refdata.repository.TransactionCategoryRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TransactionCategoryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TransactionCategoryRepository repository; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + repository.deleteAll(); + } + + @Test + void listCategories_empty() throws Exception { + mockMvc.perform(get("/reference/categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void createAndGetCategory() throws Exception { + TransactionCategory cat = new TransactionCategory("SA", 1001, "Online Sale"); + mockMvc.perform(post("/reference/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cat))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.typeCode", is("SA"))) + .andExpect(jsonPath("$.categoryCode", is(1001))) + .andExpect(jsonPath("$.description", is("Online Sale"))); + + mockMvc.perform(get("/reference/categories/SA/1001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.description", is("Online Sale"))); + } + + @Test + void createDuplicate_returns409() throws Exception { + repository.save(new TransactionCategory("SA", 1001, "Online Sale")); + TransactionCategory dup = new TransactionCategory("SA", 1001, "Duplicate"); + mockMvc.perform(post("/reference/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dup))) + .andExpect(status().isConflict()); + } + + @Test + void getNotFound() throws Exception { + mockMvc.perform(get("/reference/categories/ZZ/9999")) + .andExpect(status().isNotFound()); + } + + @Test + void updateCategory() throws Exception { + repository.save(new TransactionCategory("SA", 1001, "Online Sale")); + TransactionCategory update = new TransactionCategory("SA", 1001, "Updated Sale"); + mockMvc.perform(put("/reference/categories/SA/1001") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.description", is("Updated Sale"))); + } + + @Test + void updateNotFound() throws Exception { + TransactionCategory update = new TransactionCategory("ZZ", 9999, "Nope"); + mockMvc.perform(put("/reference/categories/ZZ/9999") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isNotFound()); + } + + @Test + void deleteCategory() throws Exception { + repository.save(new TransactionCategory("SA", 1001, "Online Sale")); + mockMvc.perform(delete("/reference/categories/SA/1001")) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/reference/categories/SA/1001")) + .andExpect(status().isNotFound()); + } + + @Test + void deleteNotFound() throws Exception { + mockMvc.perform(delete("/reference/categories/ZZ/9999")) + .andExpect(status().isNotFound()); + } + + @Test + void createInvalid_codeOverflow() throws Exception { + TransactionCategory cat = new TransactionCategory("SA", 10000, "Overflow"); + mockMvc.perform(post("/reference/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cat))) + .andExpect(status().isBadRequest()); + } + + @Test + void createInvalid_blankTypeCode() throws Exception { + TransactionCategory cat = new TransactionCategory("", 1001, "No type"); + mockMvc.perform(post("/reference/categories") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cat))) + .andExpect(status().isBadRequest()); + } +} diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionTypeControllerTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionTypeControllerTest.java new file mode 100644 index 00000000..2722de70 --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/controller/TransactionTypeControllerTest.java @@ -0,0 +1,143 @@ +package com.carddemo.refdata.controller; + +import com.carddemo.refdata.entity.TransactionType; +import com.carddemo.refdata.repository.TransactionTypeRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TransactionTypeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TransactionTypeRepository repository; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + repository.deleteAll(); + } + + @Test + void listTypes_empty() throws Exception { + mockMvc.perform(get("/reference/types")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void createAndGetType() throws Exception { + TransactionType tt = new TransactionType("SA", "Sale"); + mockMvc.perform(post("/reference/types") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(tt))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.typeCode", is("SA"))) + .andExpect(jsonPath("$.description", is("Sale"))); + + mockMvc.perform(get("/reference/types/SA")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.typeCode", is("SA"))); + } + + @Test + void createDuplicate_returns409() throws Exception { + repository.save(new TransactionType("SA", "Sale")); + TransactionType dup = new TransactionType("SA", "Sale Again"); + mockMvc.perform(post("/reference/types") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dup))) + .andExpect(status().isConflict()); + } + + @Test + void getNotFound() throws Exception { + mockMvc.perform(get("/reference/types/ZZ")) + .andExpect(status().isNotFound()); + } + + @Test + void updateType() throws Exception { + repository.save(new TransactionType("SA", "Sale")); + TransactionType update = new TransactionType("SA", "Updated Sale"); + mockMvc.perform(put("/reference/types/SA") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.description", is("Updated Sale"))); + } + + @Test + void updateNotFound() throws Exception { + TransactionType update = new TransactionType("ZZ", "Nope"); + mockMvc.perform(put("/reference/types/ZZ") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isNotFound()); + } + + @Test + void deleteType() throws Exception { + repository.save(new TransactionType("SA", "Sale")); + mockMvc.perform(delete("/reference/types/SA")) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/reference/types/SA")) + .andExpect(status().isNotFound()); + } + + @Test + void deleteNotFound() throws Exception { + mockMvc.perform(delete("/reference/types/ZZ")) + .andExpect(status().isNotFound()); + } + + @Test + void createInvalid_blankCode() throws Exception { + TransactionType tt = new TransactionType("", "No Code"); + mockMvc.perform(post("/reference/types") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(tt))) + .andExpect(status().isBadRequest()); + } + + @Test + void createInvalid_codeToLong() throws Exception { + TransactionType tt = new TransactionType("ABC", "Too long code"); + mockMvc.perform(post("/reference/types") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(tt))) + .andExpect(status().isBadRequest()); + } + + @Test + void listTypes_returnsAll() throws Exception { + repository.save(new TransactionType("SA", "Sale")); + repository.save(new TransactionType("RE", "Return")); + mockMvc.perform(get("/reference/types")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))); + } +} diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/repository/TransactionTypeRepositoryTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/repository/TransactionTypeRepositoryTest.java new file mode 100644 index 00000000..1fef5853 --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/repository/TransactionTypeRepositoryTest.java @@ -0,0 +1,42 @@ +package com.carddemo.refdata.repository; + +import java.util.Optional; + +import com.carddemo.refdata.entity.TransactionType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest +@ActiveProfiles("test") +class TransactionTypeRepositoryTest { + + @Autowired + private TransactionTypeRepository repository; + + @Test + void saveAndFind() { + repository.save(new TransactionType("SA", "Sale")); + Optional found = repository.findById("SA"); + assertTrue(found.isPresent()); + assertEquals("Sale", found.get().getDescription()); + } + + @Test + void findAll_afterMultipleSaves() { + repository.save(new TransactionType("SA", "Sale")); + repository.save(new TransactionType("RE", "Return")); + assertEquals(2, repository.findAll().size()); + } + + @Test + void deleteById() { + repository.save(new TransactionType("SA", "Sale")); + repository.deleteById("SA"); + assertTrue(repository.findById("SA").isEmpty()); + } +} diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java new file mode 100644 index 00000000..b63a8308 --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java @@ -0,0 +1,106 @@ +package com.carddemo.refdata.service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import com.carddemo.refdata.entity.DisclosureGroup; +import com.carddemo.refdata.entity.DisclosureGroupId; +import com.carddemo.refdata.repository.DisclosureGroupRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DisclosureGroupServiceTest { + + @Mock + private DisclosureGroupRepository repository; + + @InjectMocks + private DisclosureGroupService service; + + @Test + void findAll_returnsList() { + when(repository.findAll()).thenReturn(List.of( + new DisclosureGroup("GRP001", "SA", 1001, new BigDecimal("12.50")))); + assertEquals(1, service.findAll().size()); + } + + @Test + void findByKey_found() { + DisclosureGroupId id = new DisclosureGroupId("GRP001", "SA", 1001); + when(repository.findById(id)).thenReturn( + Optional.of(new DisclosureGroup("GRP001", "SA", 1001, new BigDecimal("12.50")))); + DisclosureGroup result = service.findByKey("GRP001", "SA", 1001); + assertNotNull(result); + assertEquals(new BigDecimal("12.50"), result.getInterestRate()); + } + + @Test + void findByKey_notFound() { + when(repository.findById(any())).thenReturn(Optional.empty()); + assertNull(service.findByKey("X", "ZZ", 0)); + } + + @Test + void create_success() { + DisclosureGroup dg = new DisclosureGroup("GRP001", "SA", 1001, new BigDecimal("12.50")); + when(repository.existsById(any())).thenReturn(false); + when(repository.save(dg)).thenReturn(dg); + DisclosureGroup result = service.create(dg); + assertEquals("GRP001", result.getAccountGroupId()); + } + + @Test + void create_duplicate_throws() { + DisclosureGroup dg = new DisclosureGroup("GRP001", "SA", 1001, new BigDecimal("12.50")); + when(repository.existsById(any())).thenReturn(true); + assertThrows(IllegalArgumentException.class, () -> service.create(dg)); + verify(repository, never()).save(any()); + } + + @Test + void update_found() { + DisclosureGroup existing = new DisclosureGroup("GRP001", "SA", 1001, new BigDecimal("12.50")); + when(repository.findById(any())).thenReturn(Optional.of(existing)); + when(repository.save(existing)).thenReturn(existing); + + DisclosureGroup update = new DisclosureGroup("GRP001", "SA", 1001, new BigDecimal("18.75")); + DisclosureGroup result = service.update("GRP001", "SA", 1001, update); + assertNotNull(result); + assertEquals(new BigDecimal("18.75"), result.getInterestRate()); + } + + @Test + void update_notFound() { + when(repository.findById(any())).thenReturn(Optional.empty()); + assertNull(service.update("X", "ZZ", 0, + new DisclosureGroup("X", "ZZ", 0, BigDecimal.ONE))); + } + + @Test + void delete_found() { + when(repository.existsById(any())).thenReturn(true); + assertTrue(service.delete("GRP001", "SA", 1001)); + } + + @Test + void delete_notFound() { + when(repository.existsById(any())).thenReturn(false); + assertFalse(service.delete("X", "ZZ", 0)); + } +} diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java new file mode 100644 index 00000000..f9cf8f05 --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java @@ -0,0 +1,104 @@ +package com.carddemo.refdata.service; + +import java.util.List; +import java.util.Optional; + +import com.carddemo.refdata.entity.TransactionCategory; +import com.carddemo.refdata.entity.TransactionCategoryId; +import com.carddemo.refdata.repository.TransactionCategoryRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionCategoryServiceTest { + + @Mock + private TransactionCategoryRepository repository; + + @InjectMocks + private TransactionCategoryService service; + + @Test + void findAll_returnsList() { + when(repository.findAll()).thenReturn(List.of( + new TransactionCategory("SA", 1001, "Online Sale"))); + assertEquals(1, service.findAll().size()); + } + + @Test + void findByKey_found() { + TransactionCategoryId id = new TransactionCategoryId("SA", 1001); + when(repository.findById(id)).thenReturn( + Optional.of(new TransactionCategory("SA", 1001, "Online Sale"))); + TransactionCategory result = service.findByKey("SA", 1001); + assertNotNull(result); + assertEquals("Online Sale", result.getDescription()); + } + + @Test + void findByKey_notFound() { + when(repository.findById(any())).thenReturn(Optional.empty()); + assertNull(service.findByKey("ZZ", 9999)); + } + + @Test + void create_success() { + TransactionCategory cat = new TransactionCategory("SA", 1001, "Online Sale"); + when(repository.existsById(any())).thenReturn(false); + when(repository.save(cat)).thenReturn(cat); + TransactionCategory result = service.create(cat); + assertEquals(1001, result.getCategoryCode()); + } + + @Test + void create_duplicate_throws() { + TransactionCategory cat = new TransactionCategory("SA", 1001, "Online Sale"); + when(repository.existsById(any())).thenReturn(true); + assertThrows(IllegalArgumentException.class, () -> service.create(cat)); + verify(repository, never()).save(any()); + } + + @Test + void update_found() { + TransactionCategory existing = new TransactionCategory("SA", 1001, "Online Sale"); + when(repository.findById(any())).thenReturn(Optional.of(existing)); + when(repository.save(existing)).thenReturn(existing); + + TransactionCategory update = new TransactionCategory("SA", 1001, "Updated"); + TransactionCategory result = service.update("SA", 1001, update); + assertNotNull(result); + assertEquals("Updated", result.getDescription()); + } + + @Test + void update_notFound() { + when(repository.findById(any())).thenReturn(Optional.empty()); + assertNull(service.update("ZZ", 9999, new TransactionCategory("ZZ", 9999, "Nope"))); + } + + @Test + void delete_found() { + when(repository.existsById(any())).thenReturn(true); + assertTrue(service.delete("SA", 1001)); + } + + @Test + void delete_notFound() { + when(repository.existsById(any())).thenReturn(false); + assertFalse(service.delete("ZZ", 9999)); + } +} diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java new file mode 100644 index 00000000..72991b5c --- /dev/null +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java @@ -0,0 +1,104 @@ +package com.carddemo.refdata.service; + +import java.util.List; +import java.util.Optional; + +import com.carddemo.refdata.entity.TransactionType; +import com.carddemo.refdata.repository.TransactionTypeRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionTypeServiceTest { + + @Mock + private TransactionTypeRepository repository; + + @InjectMocks + private TransactionTypeService service; + + @Test + void findAll_returnsList() { + when(repository.findAll()).thenReturn(List.of( + new TransactionType("SA", "Sale"), + new TransactionType("RE", "Return"))); + List result = service.findAll(); + assertEquals(2, result.size()); + } + + @Test + void findByCode_found() { + when(repository.findById("SA")).thenReturn(Optional.of(new TransactionType("SA", "Sale"))); + TransactionType result = service.findByCode("SA"); + assertNotNull(result); + assertEquals("Sale", result.getDescription()); + } + + @Test + void findByCode_notFound() { + when(repository.findById("ZZ")).thenReturn(Optional.empty()); + assertNull(service.findByCode("ZZ")); + } + + @Test + void create_success() { + TransactionType tt = new TransactionType("SA", "Sale"); + when(repository.existsById("SA")).thenReturn(false); + when(repository.save(tt)).thenReturn(tt); + TransactionType result = service.create(tt); + assertEquals("SA", result.getTypeCode()); + } + + @Test + void create_duplicate_throws() { + TransactionType tt = new TransactionType("SA", "Sale"); + when(repository.existsById("SA")).thenReturn(true); + assertThrows(IllegalArgumentException.class, () -> service.create(tt)); + verify(repository, never()).save(any()); + } + + @Test + void update_found() { + TransactionType existing = new TransactionType("SA", "Sale"); + when(repository.findById("SA")).thenReturn(Optional.of(existing)); + when(repository.save(existing)).thenReturn(existing); + + TransactionType update = new TransactionType("SA", "Updated"); + TransactionType result = service.update("SA", update); + assertNotNull(result); + assertEquals("Updated", result.getDescription()); + } + + @Test + void update_notFound() { + when(repository.findById("ZZ")).thenReturn(Optional.empty()); + assertNull(service.update("ZZ", new TransactionType("ZZ", "Nope"))); + } + + @Test + void delete_found() { + when(repository.existsById("SA")).thenReturn(true); + assertTrue(service.delete("SA")); + verify(repository).deleteById("SA"); + } + + @Test + void delete_notFound() { + when(repository.existsById("ZZ")).thenReturn(false); + assertFalse(service.delete("ZZ")); + } +} diff --git a/services/carddemo-refdata/src/test/resources/application-test.yml b/services/carddemo-refdata/src/test/resources/application-test.yml new file mode 100644 index 00000000..ce3a76e4 --- /dev/null +++ b/services/carddemo-refdata/src/test/resources/application-test.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + username: sa + password: + driver-class-name: org.h2.Driver + + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect From fd85bd40faac8b7ad38695f598b58af9d262c749 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:42:46 +0000 Subject: [PATCH 2/3] Replace IllegalArgumentException with DuplicateEntityException for 409 responses Addresses Devin Review findings: - Custom DuplicateEntityException avoids catching unrelated IllegalArgumentExceptions as 409 - Null-safe message handling in GlobalExceptionHandler --- .../refdata/controller/GlobalExceptionHandler.java | 7 ++++--- .../carddemo/refdata/service/DisclosureGroupService.java | 2 +- .../refdata/service/DuplicateEntityException.java | 8 ++++++++ .../refdata/service/TransactionCategoryService.java | 2 +- .../carddemo/refdata/service/TransactionTypeService.java | 2 +- .../refdata/service/DisclosureGroupServiceTest.java | 2 +- .../refdata/service/TransactionCategoryServiceTest.java | 2 +- .../refdata/service/TransactionTypeServiceTest.java | 3 ++- 8 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DuplicateEntityException.java diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java index cde46a38..a3c25136 100644 --- a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Map; +import com.carddemo.refdata.service.DuplicateEntityException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; @@ -22,9 +23,9 @@ public ResponseEntity> handleValidation(MethodArgumentNotVal return ResponseEntity.badRequest().body(errors); } - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleConflict(IllegalArgumentException ex) { + @ExceptionHandler(DuplicateEntityException.class) + public ResponseEntity> handleDuplicate(DuplicateEntityException ex) { return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", ex.getMessage())); + .body(Map.of("error", ex.getMessage() != null ? ex.getMessage() : "Conflict")); } } diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java index 1739806a..35807ddb 100644 --- a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DisclosureGroupService.java @@ -32,7 +32,7 @@ public DisclosureGroup create(DisclosureGroup entity) { entity.getTransactionTypeCode(), entity.getTransactionCategoryCode()); if (repository.existsById(id)) { - throw new IllegalArgumentException("Disclosure group already exists: " + throw new DuplicateEntityException("Disclosure group already exists: " + entity.getAccountGroupId() + "/" + entity.getTransactionTypeCode() + "/" + entity.getTransactionCategoryCode()); } diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DuplicateEntityException.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DuplicateEntityException.java new file mode 100644 index 00000000..b5e9d1b1 --- /dev/null +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/DuplicateEntityException.java @@ -0,0 +1,8 @@ +package com.carddemo.refdata.service; + +public class DuplicateEntityException extends RuntimeException { + + public DuplicateEntityException(String message) { + super(message); + } +} diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java index 694d96e3..b153d450 100644 --- a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionCategoryService.java @@ -29,7 +29,7 @@ public TransactionCategory findByKey(String typeCode, int categoryCode) { public TransactionCategory create(TransactionCategory entity) { TransactionCategoryId id = new TransactionCategoryId(entity.getTypeCode(), entity.getCategoryCode()); if (repository.existsById(id)) { - throw new IllegalArgumentException( + throw new DuplicateEntityException( "Transaction category already exists: " + entity.getTypeCode() + "/" + entity.getCategoryCode()); } return repository.save(entity); diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java index 3634aad2..e3459c0a 100644 --- a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/service/TransactionTypeService.java @@ -27,7 +27,7 @@ public TransactionType findByCode(String code) { public TransactionType create(TransactionType entity) { if (repository.existsById(entity.getTypeCode())) { - throw new IllegalArgumentException("Transaction type already exists: " + entity.getTypeCode()); + throw new DuplicateEntityException("Transaction type already exists: " + entity.getTypeCode()); } return repository.save(entity); } diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java index b63a8308..65389b8a 100644 --- a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/DisclosureGroupServiceTest.java @@ -69,7 +69,7 @@ void create_success() { void create_duplicate_throws() { DisclosureGroup dg = new DisclosureGroup("GRP001", "SA", 1001, new BigDecimal("12.50")); when(repository.existsById(any())).thenReturn(true); - assertThrows(IllegalArgumentException.class, () -> service.create(dg)); + assertThrows(DuplicateEntityException.class, () -> service.create(dg)); verify(repository, never()).save(any()); } diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java index f9cf8f05..f5a5125c 100644 --- a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionCategoryServiceTest.java @@ -68,7 +68,7 @@ void create_success() { void create_duplicate_throws() { TransactionCategory cat = new TransactionCategory("SA", 1001, "Online Sale"); when(repository.existsById(any())).thenReturn(true); - assertThrows(IllegalArgumentException.class, () -> service.create(cat)); + assertThrows(DuplicateEntityException.class, () -> service.create(cat)); verify(repository, never()).save(any()); } diff --git a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java index 72991b5c..247abff5 100644 --- a/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java +++ b/services/carddemo-refdata/src/test/java/com/carddemo/refdata/service/TransactionTypeServiceTest.java @@ -15,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -67,7 +68,7 @@ void create_success() { void create_duplicate_throws() { TransactionType tt = new TransactionType("SA", "Sale"); when(repository.existsById("SA")).thenReturn(true); - assertThrows(IllegalArgumentException.class, () -> service.create(tt)); + assertThrows(DuplicateEntityException.class, () -> service.create(tt)); verify(repository, never()).save(any()); } From 5423ecea28006f21ba3fc7d436ccf87725a38774 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:48:35 +0000 Subject: [PATCH 3/3] Handle DataIntegrityViolationException for TOCTOU race in concurrent creates Catches constraint violations from concurrent inserts that pass the existsById check but hit the DB unique constraint, returning 409 instead of 500. --- .../refdata/controller/GlobalExceptionHandler.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java index a3c25136..bd09c07d 100644 --- a/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java +++ b/services/carddemo-refdata/src/main/java/com/carddemo/refdata/controller/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import java.util.Map; import com.carddemo.refdata.service.DuplicateEntityException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; @@ -28,4 +29,10 @@ public ResponseEntity> handleDuplicate(DuplicateEntityExcept return ResponseEntity.status(HttpStatus.CONFLICT) .body(Map.of("error", ex.getMessage() != null ? ex.getMessage() : "Conflict")); } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleDataIntegrity(DataIntegrityViolationException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Entity already exists or constraint violation")); + } }