From 22a7abc42162ddd98f05c4fa824b9312246fe820 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:47:08 +0000 Subject: [PATCH 1/2] feat: migrate COBOL codebase to Spring Boot application (Phase 1 & 2) - Create Spring Boot project structure with Maven build (pom.xml) - Add JPA entity (Account) mapped from COBOL WORKING-STORAGE records - Add Customer POJO from merge_sort file descriptor records - Add AccountRepository with Spring Data JPA query methods replacing SQL cursors - Add AccountService replacing PROCEDURE DIVISION paragraphs - Add SerializationService replacing JSON/XML GENERATE statements - Add SubProgramService demonstrating CALL BY CONTENT/REFERENCE patterns - Add SearchService implementing binary search (SEARCH ALL equivalent) - Add REST controllers replacing terminal-based ACCEPT/DISPLAY menu - Add SerializationRecord DTO with @JsonProperty annotations for NAME OF mappings - Add comprehensive tests (repository, service, controller, search) - Add README with COBOL-to-Java migration mapping documentation - All 26 tests passing Co-Authored-By: Jerry Oliphant --- spring-boot-app/README.md | 184 ++++++++++++++++++ spring-boot-app/pom.xml | 76 ++++++++ .../CobolMigrationApplication.java | 19 ++ .../controller/AccountController.java | 65 +++++++ .../controller/SerializationController.java | 60 ++++++ .../dto/SerializationRecord.java | 74 +++++++ .../com/cobolmigration/model/Account.java | 151 ++++++++++++++ .../com/cobolmigration/model/Customer.java | 90 +++++++++ .../repository/AccountRepository.java | 65 +++++++ .../service/AccountService.java | 59 ++++++ .../cobolmigration/service/SearchService.java | 120 ++++++++++++ .../service/SerializationService.java | 69 +++++++ .../service/SubProgramService.java | 163 ++++++++++++++++ .../src/main/resources/application.properties | 24 +++ spring-boot-app/src/main/resources/schema.sql | 19 ++ .../controller/AccountControllerTest.java | 102 ++++++++++ .../repository/AccountRepositoryTest.java | 116 +++++++++++ .../service/AccountServiceTest.java | 88 +++++++++ .../service/SearchServiceTest.java | 105 ++++++++++ .../resources/application-test.properties | 16 ++ 20 files changed, 1665 insertions(+) create mode 100644 spring-boot-app/README.md create mode 100644 spring-boot-app/pom.xml create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/CobolMigrationApplication.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/controller/AccountController.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/controller/SerializationController.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/dto/SerializationRecord.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/model/Account.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/model/Customer.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/repository/AccountRepository.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/service/AccountService.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/service/SearchService.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/service/SerializationService.java create mode 100644 spring-boot-app/src/main/java/com/cobolmigration/service/SubProgramService.java create mode 100644 spring-boot-app/src/main/resources/application.properties create mode 100644 spring-boot-app/src/main/resources/schema.sql create mode 100644 spring-boot-app/src/test/java/com/cobolmigration/controller/AccountControllerTest.java create mode 100644 spring-boot-app/src/test/java/com/cobolmigration/repository/AccountRepositoryTest.java create mode 100644 spring-boot-app/src/test/java/com/cobolmigration/service/AccountServiceTest.java create mode 100644 spring-boot-app/src/test/java/com/cobolmigration/service/SearchServiceTest.java create mode 100644 spring-boot-app/src/test/resources/application-test.properties diff --git a/spring-boot-app/README.md b/spring-boot-app/README.md new file mode 100644 index 0000000..380cbb6 --- /dev/null +++ b/spring-boot-app/README.md @@ -0,0 +1,184 @@ +# COBOL-to-Spring Boot Migration + +This Spring Boot application is a Java migration of the COBOL example programs in this repository. It replaces the terminal-based, menu-driven COBOL programs with a REST API backed by Spring Data JPA and PostgreSQL. + +The original COBOL source files remain in the repository for reference. + +--- + +## Migration Mapping + +### COBOL File → Java Class + +| COBOL Source File | Java Class | Description | +|---|---|---| +| `sql/sql_example.cbl` (lines 44-52) | `model/Account.java` | WORKING-STORAGE record → JPA @Entity | +| `merge_sort/merge_sort_test.cbl` (lines 40-45) | `model/Customer.java` | File descriptor record → POJO | +| `sql/sql_example.cbl` (lines 118-153) | `repository/AccountRepository.java` | SQL CURSORs → Spring Data query methods | +| `sql/sql_example.cbl` (lines 201-394) | `service/AccountService.java` | PROCEDURE DIVISION paragraphs → service methods | +| `json_generate/json_generate.cbl` | `service/SerializationService.java` | JSON GENERATE → Jackson ObjectMapper | +| `xml_generate/xml_generate.cbl` | `service/SerializationService.java` | XML GENERATE → Jackson XmlMapper | +| `sub_program/main_app.cbl`, `sub_program/sub.cbl` | `service/SubProgramService.java` | CALL BY CONTENT/REFERENCE → Java method calls | +| `search/search.cbl` (lines 61-66) | `service/SearchService.java` | SEARCH ALL → Collections.binarySearch() | +| `sql/sql_example.cbl` (menu, lines 157-185) | `controller/AccountController.java` | ACCEPT/DISPLAY menu → REST endpoints | +| `json_generate/json_generate.cbl`, `xml_generate/xml_generate.cbl` | `controller/SerializationController.java` | Terminal output → REST endpoints | +| `json_generate/json_generate.cbl` (lines 27-33) | `dto/SerializationRecord.java` | ws-record with NAME OF → @JsonProperty DTO | +| `sql/create_test_db.sql` | `resources/schema.sql` | Database schema initialization | + +### COBOL Construct → Java Construct + +| COBOL Construct | Java Equivalent | Notes | +|---|---|---| +| `WORKING-STORAGE SECTION` | Instance fields / JPA @Entity | Persistent program state | +| `LOCAL-STORAGE SECTION` | Local method variables | Re-initialized on each call | +| `LINKAGE SECTION` | Method parameters | Data passed between programs | +| `PIC X(n)` | `String` (max length n) | Character data | +| `PIC 9(n)` | `int` / `Integer` | Numeric data | +| `PIC X VALUE 'Y'/'N'` | `boolean` | Flag fields | +| `EXEC SQL ... DECLARE CURSOR` | `JpaRepository` query methods | Database cursors → Spring Data | +| `EXEC SQL ... FETCH ... INTO` | JPA automatic mapping | Row-to-object mapping | +| `EXEC SQL CONNECT TO` | `application.properties` datasource | Connection configuration | +| `ACCEPT ... DISPLAY` | REST `@GetMapping` / `@PostMapping` | User I/O → HTTP API | +| `JSON GENERATE ... NAME OF` | `@JsonProperty` + `ObjectMapper` | Serialization with field renaming | +| `XML GENERATE ... NAME OF` | `@JacksonXmlProperty` + `XmlMapper` | XML serialization | +| `CALL BY CONTENT` | Pass primitives / immutable args | Callee cannot modify caller state | +| `CALL BY REFERENCE` | Pass mutable objects | Callee can modify shared state | +| `CANCEL` | Reset instance state | Clear working-storage equivalent | +| `SEARCH ALL` | `Collections.binarySearch()` | Binary search on sorted table | +| `SEARCH` (sequential) | `Stream.filter().findFirst()` | Linear search | +| `SORT ... MERGE` | `Collections.sort()` / `Comparator` | In-memory sorting | +| `PERFORM ... UNTIL` | `while` / `for` loops | Iteration | +| `EVALUATE ... WHEN` | `switch` / `if-else` | Conditional branching | +| `STRING ... INTO` | `String.format()` / `StringBuilder` | String concatenation | +| `FUNCTION TRIM` | `String.trim()` | Whitespace removal | +| `SQLSTATE` / `SQLCODE` | Exception handling | Error state → try/catch | + +### SQL Cursor → Repository Method + +| COBOL Cursor | Repository Method | Purpose | +|---|---|---| +| `ACCOUNT-ALL-CUR` | `findAllByOrderByIdAsc()` | All accounts, ordered by ID | +| `ACCOUNT-DISABLED-CUR` | `findByEnabledFalseOrderByIdAsc()` | Disabled accounts only | +| `ACCOUNT-QUERY-CUR` | `searchAccounts(searchValue)` | LIKE search across multiple fields | + +### Menu Option → REST Endpoint + +| Menu Option | HTTP Method | Endpoint | Description | +|---|---|---|---| +| 1) Display all accounts | `GET` | `/api/accounts` | List all accounts | +| 2) Display disabled accounts | `GET` | `/api/accounts/disabled` | List disabled accounts | +| 3) Query accounts | `GET` | `/api/accounts/search?q={query}` | Search accounts by keyword | +| — | `POST` | `/api/serialize/json` | Serialize record to JSON | +| — | `POST` | `/api/serialize/xml` | Serialize record to XML | + +--- + +## Prerequisites + +- **Java 17** or later +- **Maven 3.8+** +- **PostgreSQL** (for production; tests use an embedded H2 database) + +## Build + +```bash +cd spring-boot-app +mvn clean install +``` + +## Run + +### With PostgreSQL (production) + +Ensure PostgreSQL is running with the database configured in `application.properties`: + +``` +Database: cobol_db_example +User: postgres +Password: password +Port: 5432 +``` + +You can initialize the database with the original COBOL test data: + +```bash +psql -U postgres -f ../sql/create_test_db.sql +``` + +Then start the application: + +```bash +mvn spring-boot:run +``` + +### Run Tests Only + +Tests use an embedded H2 database and do not require PostgreSQL: + +```bash +mvn test +``` + +## API Usage Examples + +```bash +# Get all accounts +curl http://localhost:8080/api/accounts + +# Get disabled accounts +curl http://localhost:8080/api/accounts/disabled + +# Search accounts +curl "http://localhost:8080/api/accounts/search?q=John" + +# Serialize to JSON +curl -X POST http://localhost:8080/api/serialize/json \ + -H "Content-Type: application/json" \ + -d '{"name":"Test Name","value":"Test Value","enabled":"true"}' + +# Serialize to XML +curl -X POST http://localhost:8080/api/serialize/xml \ + -H "Content-Type: application/json" \ + -d '{"name":"Test Name","value":"Test Value","enabled":"true"}' +``` + +## Project Structure + +``` +spring-boot-app/ +├── pom.xml +├── README.md +└── src/ + ├── main/ + │ ├── java/com/cobolmigration/ + │ │ ├── CobolMigrationApplication.java + │ │ ├── controller/ + │ │ │ ├── AccountController.java + │ │ │ └── SerializationController.java + │ │ ├── dto/ + │ │ │ └── SerializationRecord.java + │ │ ├── model/ + │ │ │ ├── Account.java + │ │ │ └── Customer.java + │ │ ├── repository/ + │ │ │ └── AccountRepository.java + │ │ └── service/ + │ │ ├── AccountService.java + │ │ ├── SearchService.java + │ │ ├── SerializationService.java + │ │ └── SubProgramService.java + │ └── resources/ + │ ├── application.properties + │ └── schema.sql + └── test/ + ├── java/com/cobolmigration/ + │ ├── controller/ + │ │ └── AccountControllerTest.java + │ ├── repository/ + │ │ └── AccountRepositoryTest.java + │ └── service/ + │ ├── AccountServiceTest.java + │ └── SearchServiceTest.java + └── resources/ + └── application-test.properties +``` diff --git a/spring-boot-app/pom.xml b/spring-boot-app/pom.xml new file mode 100644 index 0000000..c6d83b1 --- /dev/null +++ b/spring-boot-app/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.cobolmigration + cobol-migration + 0.0.1-SNAPSHOT + COBOL Migration + Spring Boot application migrated from COBOL codebase + + + 17 + 2.17.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + ${jackson-dataformat-xml.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/spring-boot-app/src/main/java/com/cobolmigration/CobolMigrationApplication.java b/spring-boot-app/src/main/java/com/cobolmigration/CobolMigrationApplication.java new file mode 100644 index 0000000..ef49d9e --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/CobolMigrationApplication.java @@ -0,0 +1,19 @@ +package com.cobolmigration; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Main application class for the COBOL-to-Spring Boot migration. + * + *

This application replaces the terminal-based COBOL programs with a + * REST API backed by Spring Boot and JPA. The original COBOL source files + * remain in the repository for reference.

+ */ +@SpringBootApplication +public class CobolMigrationApplication { + + public static void main(String[] args) { + SpringApplication.run(CobolMigrationApplication.class, args); + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/controller/AccountController.java b/spring-boot-app/src/main/java/com/cobolmigration/controller/AccountController.java new file mode 100644 index 0000000..016edd5 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/controller/AccountController.java @@ -0,0 +1,65 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.model.Account; +import com.cobolmigration.service.AccountService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * REST controller replacing the terminal-based menu from + * {@code sql/sql_example.cbl} and the screen I/O from + * {@code accept/accept.cbl}. + * + *

The original COBOL menu: + *

+ *   1) Display all accounts      → GET /api/accounts
+ *   2) Display disabled accounts → GET /api/accounts/disabled
+ *   3) Query accounts            → GET /api/accounts/search?q={query}
+ *   4) Exit                      → (not applicable in REST API)
+ * 
+ */ +@RestController +@RequestMapping("/api/accounts") +public class AccountController { + + private final AccountService accountService; + + public AccountController(AccountService accountService) { + this.accountService = accountService; + } + + /** + * Returns all accounts (replaces menu option 1: display-all-accounts). + */ + @GetMapping + public ResponseEntity> getAllAccounts() { + List accounts = accountService.getAllAccounts(); + return ResponseEntity.ok(accounts); + } + + /** + * Returns disabled accounts (replaces menu option 2: display-disabled-accounts). + */ + @GetMapping("/disabled") + public ResponseEntity> getDisabledAccounts() { + List accounts = accountService.getDisabledAccounts(); + return ResponseEntity.ok(accounts); + } + + /** + * Searches accounts by query string (replaces menu option 3: query-accounts). + * + * @param query the search term to match against account fields + */ + @GetMapping("/search") + public ResponseEntity> searchAccounts( + @RequestParam("q") String query) { + List accounts = accountService.searchAccounts(query); + return ResponseEntity.ok(accounts); + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/controller/SerializationController.java b/spring-boot-app/src/main/java/com/cobolmigration/controller/SerializationController.java new file mode 100644 index 0000000..7ed2143 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/controller/SerializationController.java @@ -0,0 +1,60 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.dto.SerializationRecord; +import com.cobolmigration.service.SerializationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller for serialization operations. + * + *

Replaces the COBOL JSON GENERATE functionality from + * {@code json_generate/json_generate.cbl} and XML GENERATE from + * {@code xml_generate/xml_generate.cbl}.

+ */ +@RestController +@RequestMapping("/api/serialize") +public class SerializationController { + + private final SerializationService serializationService; + + public SerializationController(SerializationService serializationService) { + this.serializationService = serializationService; + } + + /** + * Accepts a record and returns its JSON representation. + * + *

Replaces the JSON GENERATE statement with NAME OF mappings.

+ * + * @param record the record to serialize + * @return JSON string + */ + @PostMapping(value = "/json", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity serializeToJson( + @RequestBody SerializationRecord record) throws JsonProcessingException { + String json = serializationService.toJson(record); + return ResponseEntity.ok(json); + } + + /** + * Accepts a record and returns its XML representation. + * + *

Replaces the XML GENERATE statement with NAME OF mappings + * and TYPE OF attribute handling.

+ * + * @param record the record to serialize + * @return XML string + */ + @PostMapping(value = "/xml", produces = MediaType.APPLICATION_XML_VALUE) + public ResponseEntity serializeToXml( + @RequestBody SerializationRecord record) throws JsonProcessingException { + String xml = serializationService.toXml(record); + return ResponseEntity.ok(xml); + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/dto/SerializationRecord.java b/spring-boot-app/src/main/java/com/cobolmigration/dto/SerializationRecord.java new file mode 100644 index 0000000..cfdd234 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/dto/SerializationRecord.java @@ -0,0 +1,74 @@ +package com.cobolmigration.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +/** + * DTO for serialization operations. + * + *

Maps to the COBOL record in {@code json_generate/json_generate.cbl} + * (lines 27-33) and {@code xml_generate/xml_generate.cbl} (lines 26-32):

+ *
+ *   01  ws-record.
+ *       05  ws-record-name    pic x(10).
+ *       05  ws-record-value   pic x(10).
+ *       05  ws-record-blank   pic x(10).
+ *       05  ws-record-flag    pic x(5) value "false".
+ * 
+ * + *

The COBOL {@code NAME OF} mappings are handled via Jackson annotations:

+ *
    + *
  • ws-record-name → "name"
  • + *
  • ws-record-value → "value"
  • + *
  • ws-record-flag → "enabled"
  • + *
+ */ +@JacksonXmlRootElement(localName = "ws-record") +public class SerializationRecord { + + @JsonProperty("name") + @JacksonXmlProperty(localName = "name") + private String name; + + @JsonProperty("value") + @JacksonXmlProperty(localName = "value") + private String value; + + @JsonProperty("enabled") + @JacksonXmlProperty(isAttribute = true, localName = "enabled") + private String enabled; + + public SerializationRecord() { + } + + public SerializationRecord(String name, String value, String enabled) { + this.name = name; + this.value = value; + this.enabled = enabled; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getEnabled() { + return enabled; + } + + public void setEnabled(String enabled) { + this.enabled = enabled; + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/model/Account.java b/spring-boot-app/src/main/java/com/cobolmigration/model/Account.java new file mode 100644 index 0000000..1aa0d4d --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/model/Account.java @@ -0,0 +1,151 @@ +package com.cobolmigration.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +/** + * JPA entity mapped to the ACCOUNTS table. + * + *

Migrated from the COBOL WORKING-STORAGE record defined in + * {@code sql/sql_example.cbl} (lines 44-52):

+ *
+ *   01  ws-sql-account-record.
+ *       05  ws-sql-account-id            pic 9(5).
+ *       05  ws-sql-account-first-name    pic x(8).
+ *       05  ws-sql-account-last-name     pic x(8).
+ *       05  ws-sql-account-phone         pic x(10).
+ *       05  ws-sql-account-address       pic x(22).
+ *       05  ws-sql-account-is-enabled    pic x.
+ *       05  ws-sql-account-create-dt     pic x(20).
+ *       05  ws-sql-account-mod-dt        pic x(20).
+ * 
+ */ +@Entity +@Table(name = "accounts") +public class Account { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Integer id; + + @Column(name = "FIRST_NAME", length = 8, nullable = false) + private String firstName; + + @Column(name = "LAST_NAME", length = 8, nullable = false) + private String lastName; + + @Column(name = "PHONE", length = 10, nullable = false) + private String phone; + + @Column(name = "ADDRESS", length = 22, nullable = false) + private String address; + + /** + * Maps to the IS_ENABLED column which stores 'Y' or 'N' in the COBOL version. + * Converted to a boolean for idiomatic Java usage. + */ + @Column(name = "IS_ENABLED", nullable = false) + private boolean enabled; + + @Column(name = "CREATE_DT") + private LocalDateTime createDt; + + @Column(name = "MOD_DT") + private LocalDateTime modDt; + + public Account() { + } + + public Account(String firstName, String lastName, String phone, + String address, boolean enabled) { + this.firstName = firstName; + this.lastName = lastName; + this.phone = phone; + this.address = address; + this.enabled = enabled; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public LocalDateTime getCreateDt() { + return createDt; + } + + public void setCreateDt(LocalDateTime createDt) { + this.createDt = createDt; + } + + public LocalDateTime getModDt() { + return modDt; + } + + public void setModDt(LocalDateTime modDt) { + this.modDt = modDt; + } + + @Override + public String toString() { + return "Account{" + + "id=" + id + + ", firstName='" + firstName + '\'' + + ", lastName='" + lastName + '\'' + + ", phone='" + phone + '\'' + + ", address='" + address + '\'' + + ", enabled=" + enabled + + ", createDt=" + createDt + + ", modDt=" + modDt + + '}'; + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/model/Customer.java b/spring-boot-app/src/main/java/com/cobolmigration/model/Customer.java new file mode 100644 index 0000000..4ea632f --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/model/Customer.java @@ -0,0 +1,90 @@ +package com.cobolmigration.model; + +/** + * POJO representing a customer record. + * + *

Migrated from the COBOL file-descriptor record in + * {@code merge_sort/merge_sort_test.cbl} (lines 40-45):

+ *
+ *   01  f-customer-record-sort.
+ *       05  f-customer-id            pic 9(5).
+ *       05  f-customer-last-name     pic x(50).
+ *       05  f-customer-first-name    pic x(50).
+ *       05  f-customer-contract-id   pic 9(5).
+ *       05  f-customer-comment       pic x(25).
+ * 
+ * + *

This is a plain POJO (not JPA-managed) since the original COBOL + * program uses file-based SORT/MERGE rather than database storage.

+ */ +public class Customer { + + private int customerId; + private String lastName; + private String firstName; + private int contractId; + private String comment; + + public Customer() { + } + + public Customer(int customerId, String lastName, String firstName, + int contractId, String comment) { + this.customerId = customerId; + this.lastName = lastName; + this.firstName = firstName; + this.contractId = contractId; + this.comment = comment; + } + + public int getCustomerId() { + return customerId; + } + + public void setCustomerId(int customerId) { + this.customerId = customerId; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public int getContractId() { + return contractId; + } + + public void setContractId(int contractId) { + this.contractId = contractId; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + @Override + public String toString() { + return "Customer{" + + "customerId=" + customerId + + ", lastName='" + lastName + '\'' + + ", firstName='" + firstName + '\'' + + ", contractId=" + contractId + + ", comment='" + comment + '\'' + + '}'; + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/repository/AccountRepository.java b/spring-boot-app/src/main/java/com/cobolmigration/repository/AccountRepository.java new file mode 100644 index 0000000..f5d2cc6 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/repository/AccountRepository.java @@ -0,0 +1,65 @@ +package com.cobolmigration.repository; + +import com.cobolmigration.model.Account; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA repository for the {@link Account} entity. + * + *

Replaces the SQL cursors declared in {@code sql/sql_example.cbl} + * (lines 118-153).

+ */ +@Repository +public interface AccountRepository extends JpaRepository { + + /** + * Retrieves all accounts ordered by ID ascending. + * + *

Replaces ACCOUNT-ALL-CUR: + *

+     *   DECLARE ACCOUNT-ALL-CUR CURSOR FOR
+     *   SELECT ID, FIRST_NAME, LAST_NAME, PHONE,
+     *          ADDRESS, IS_ENABLED, CREATE_DT, MOD_DT
+     *   FROM ACCOUNTS ORDER BY ID;
+     * 
+ */ + List findAllByOrderByIdAsc(); + + /** + * Retrieves all disabled accounts ordered by ID ascending. + * + *

Replaces ACCOUNT-DISABLED-CUR: + *

+     *   DECLARE ACCOUNT-DISABLED-CUR CURSOR FOR
+     *   SELECT ... FROM ACCOUNTS WHERE IS_ENABLED = 'N' ORDER BY ID;
+     * 
+ */ + List findByEnabledFalseOrderByIdAsc(); + + /** + * Searches accounts by matching the search value against first name, + * last name, phone, or address using LIKE. + * + *

Replaces ACCOUNT-QUERY-CUR: + *

+     *   DECLARE ACCOUNT-QUERY-CUR CURSOR FOR
+     *   SELECT ... FROM ACCOUNTS
+     *   WHERE FIRST_NAME LIKE :ws-search-value
+     *      OR LAST_NAME LIKE :ws-search-value
+     *      OR PHONE LIKE :ws-search-value
+     *      OR ADDRESS LIKE :ws-search-value
+     *   ORDER BY ID;
+     * 
+ */ + @Query("SELECT a FROM Account a WHERE a.firstName LIKE :searchValue " + + "OR a.lastName LIKE :searchValue " + + "OR a.phone LIKE :searchValue " + + "OR a.address LIKE :searchValue " + + "ORDER BY a.id") + List searchAccounts(@Param("searchValue") String searchValue); +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/service/AccountService.java b/spring-boot-app/src/main/java/com/cobolmigration/service/AccountService.java new file mode 100644 index 0000000..3226eed --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/service/AccountService.java @@ -0,0 +1,59 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.Account; +import com.cobolmigration.repository.AccountRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Service layer for Account operations. + * + *

Replaces the PROCEDURE DIVISION paragraphs in {@code sql/sql_example.cbl} + * (lines 103-185) which implement a menu-driven flow for account queries.

+ */ +@Service +public class AccountService { + + private final AccountRepository accountRepository; + + public AccountService(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + } + + /** + * Retrieves all accounts ordered by ID. + * + *

Replaces the {@code display-all-accounts} paragraph (lines 201-246) + * which opens ACCOUNT-ALL-CUR, fetches all rows, and displays them.

+ */ + public List getAllAccounts() { + return accountRepository.findAllByOrderByIdAsc(); + } + + /** + * Retrieves all disabled accounts ordered by ID. + * + *

Replaces the {@code display-disabled-accounts} paragraph (lines 258-295) + * which opens ACCOUNT-DISABLED-CUR and fetches rows where IS_ENABLED = 'N'.

+ */ + public List getDisabledAccounts() { + return accountRepository.findByEnabledFalseOrderByIdAsc(); + } + + /** + * Searches accounts by matching the query against first name, last name, + * phone, or address. + * + *

Replaces the {@code query-accounts} paragraph (lines 318-394) which + * trims the user input, wraps it with '%' wildcards, and opens + * ACCOUNT-QUERY-CUR.

+ * + * @param query the search term to match against account fields + * @return list of matching accounts + */ + public List searchAccounts(String query) { + String searchValue = "%" + query + "%"; + return accountRepository.searchAccounts(searchValue); + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/service/SearchService.java b/spring-boot-app/src/main/java/com/cobolmigration/service/SearchService.java new file mode 100644 index 0000000..b9d2064 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/service/SearchService.java @@ -0,0 +1,120 @@ +package com.cobolmigration.service; + +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Service implementing binary search equivalent of COBOL SEARCH ALL. + * + *

Migrated from {@code search/search.cbl} (lines 61-66) which uses + * SEARCH ALL on an indexed table with ascending/descending keys:

+ *
+ *   SEARCH ALL ws-item-table
+ *       AT END DISPLAY "Item not found."
+ *       WHEN ws-item-id-1(idx) = ws-accept-id-1
+ *           PERFORM display-found-item
+ *   END-SEARCH
+ * 
+ * + *

In COBOL, SEARCH ALL requires the table to be sorted by its key and + * performs a binary search. In Java, we use {@link Collections#binarySearch} + * to achieve the same O(log n) lookup.

+ */ +@Service +public class SearchService { + + /** + * Performs a binary search for an item by its ID in a pre-sorted list. + * + *

The list must be sorted in ascending order by the item's ID + * (matching the COBOL requirement for SEARCH ALL with ascending key).

+ * + * @param items a list of {@link SearchableItem} sorted by ID ascending + * @param searchId the ID to search for + * @return an Optional containing the found item, or empty if not found + */ + public Optional binarySearchById( + List items, int searchId) { + + int index = Collections.binarySearch( + items, + new SearchableItem(searchId, "", ""), + Comparator.comparingInt(SearchableItem::getId) + ); + + if (index >= 0) { + return Optional.of(items.get(index)); + } + return Optional.empty(); + } + + /** + * Performs a sequential search (linear scan) for an item by its ID. + * + *

Equivalent to the COBOL sequential SEARCH (without ALL), which + * does not require sorting:

+ *
+     *   SEARCH ws-no-key-item-table
+     *       AT END DISPLAY "Item not found."
+     *       WHEN ws-no-key-id(idx-2) = ws-accept-id-1
+     *           ...
+     *   END-SEARCH
+     * 
+ * + * @param items a list of items (need not be sorted) + * @param searchId the ID to search for + * @return an Optional containing the found item, or empty if not found + */ + public Optional sequentialSearchById( + List items, int searchId) { + + return items.stream() + .filter(item -> item.getId() == searchId) + .findFirst(); + } + + /** + * Represents a searchable item, modeled after the COBOL indexed table: + *
+     *   01  ws-item-table occurs 3 times
+     *           ascending key is ws-item-id-1 ...
+     *       05  ws-item-id-1   pic 9(4).
+     *       05  ws-item-name   pic x(16).
+     *       05  ws-item-date   ...
+     * 
+ */ + public static class SearchableItem { + private final int id; + private final String name; + private final String date; + + public SearchableItem(int id, String name, String date) { + this.id = id; + this.name = name; + this.date = date; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDate() { + return date; + } + + @Override + public String toString() { + return "SearchableItem{id=" + id + + ", name='" + name + '\'' + + ", date='" + date + '\'' + '}'; + } + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/service/SerializationService.java b/spring-boot-app/src/main/java/com/cobolmigration/service/SerializationService.java new file mode 100644 index 0000000..60430b0 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/service/SerializationService.java @@ -0,0 +1,69 @@ +package com.cobolmigration.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import org.springframework.stereotype.Service; + +/** + * Service for serializing objects to JSON and XML. + * + *

Replaces the COBOL JSON GENERATE statement in + * {@code json_generate/json_generate.cbl} (lines 42-54) and the + * XML GENERATE statement in {@code xml_generate/xml_generate.cbl} + * (lines 41-56).

+ * + *

In COBOL, the {@code NAME OF} clause maps working-storage field + * names to custom output names. In Java, this is handled via + * {@code @JsonProperty} annotations on the DTO classes.

+ */ +@Service +public class SerializationService { + + private final ObjectMapper jsonMapper; + private final XmlMapper xmlMapper; + + public SerializationService() { + this.jsonMapper = new ObjectMapper(); + this.xmlMapper = new XmlMapper(); + } + + /** + * Serializes the given object to a JSON string. + * + *

Replaces: + *

+     *   JSON GENERATE ws-json-output
+     *       FROM ws-record
+     *       COUNT IN ws-json-char-count
+     *       NAME OF ws-record-name IS "name", ...
+     * 
+ * + * @param object the object to serialize + * @return JSON string representation + * @throws JsonProcessingException if serialization fails + */ + public String toJson(Object object) throws JsonProcessingException { + return jsonMapper.writeValueAsString(object); + } + + /** + * Serializes the given object to an XML string. + * + *

Replaces: + *

+     *   XML GENERATE ws-xml-output
+     *       FROM ws-record
+     *       COUNT IN ws-xml-char-count
+     *       WITH XML-DECLARATION
+     *       NAME OF ws-record-name IS "name", ...
+     * 
+ * + * @param object the object to serialize + * @return XML string representation + * @throws JsonProcessingException if serialization fails + */ + public String toXml(Object object) throws JsonProcessingException { + return xmlMapper.writeValueAsString(object); + } +} diff --git a/spring-boot-app/src/main/java/com/cobolmigration/service/SubProgramService.java b/spring-boot-app/src/main/java/com/cobolmigration/service/SubProgramService.java new file mode 100644 index 0000000..cd505f4 --- /dev/null +++ b/spring-boot-app/src/main/java/com/cobolmigration/service/SubProgramService.java @@ -0,0 +1,163 @@ +package com.cobolmigration.service; + +import org.springframework.stereotype.Service; + +/** + * Demonstrates the Java equivalent of COBOL subprogram CALL patterns. + * + *

Migrated from {@code sub_program/main_app.cbl} and + * {@code sub_program/sub.cbl} which demonstrate CALL BY CONTENT + * and CALL BY REFERENCE.

+ * + *

COBOL-to-Java Mapping

+ *
    + *
  • CALL BY CONTENT → Pass primitives or defensive copies + * (callee cannot modify caller's data)
  • + *
  • CALL BY REFERENCE → Pass mutable objects + * (callee can modify the shared state)
  • + *
  • CANCEL → Reset internal state to initial values
  • + *
+ * + *

In COBOL, subprogram WORKING-STORAGE variables persist between calls + * until a CANCEL is issued. In Java, this is modeled using instance fields + * that retain state between method calls.

+ */ +@Service +public class SubProgramService { + + private String workingStorageItem1 = ""; + private String workingStorageItem2 = ""; + + /** + * Simulates CALL BY CONTENT: accepts immutable values. + * The caller's variables are not modified because Java passes + * String values (immutable) rather than references to mutable storage. + * + *

Equivalent to: + *

+     *   CALL "sub-app" USING BY CONTENT ws-item-1 BY CONTENT ws-item-2
+     * 
+ * + * @param item1 first value (not modified by this method) + * @param item2 second value (not modified by this method) + * @return result containing the processed values + */ + public SubProgramResult callByContent(String item1, String item2) { + this.workingStorageItem1 = item1; + this.workingStorageItem2 = item2; + + return new SubProgramResult(item1, item2, + workingStorageItem1, workingStorageItem2); + } + + /** + * Simulates CALL BY REFERENCE: accepts a mutable holder that can be + * modified by this method, just as the COBOL subprogram modifies + * linkage-section variables. + * + *

Equivalent to: + *

+     *   CALL "sub-app" USING ws-item-1 ws-item-2
+     * 
+ * + * @param holder mutable container whose values may be changed + * @return result containing the processed values + */ + public SubProgramResult callByReference(MutableHolder holder) { + this.workingStorageItem1 = holder.getItem1(); + this.workingStorageItem2 = holder.getItem2(); + + holder.setItem1("replace1"); + holder.setItem2("replace2"); + + return new SubProgramResult(holder.getItem1(), holder.getItem2(), + workingStorageItem1, workingStorageItem2); + } + + /** + * Resets internal working-storage state, equivalent to COBOL CANCEL. + * + *

Equivalent to: + *

+     *   CANCEL "sub-app"
+     * 
+ */ + public void cancel() { + this.workingStorageItem1 = ""; + this.workingStorageItem2 = ""; + } + + public String getWorkingStorageItem1() { + return workingStorageItem1; + } + + public String getWorkingStorageItem2() { + return workingStorageItem2; + } + + /** + * Mutable holder to simulate CALL BY REFERENCE parameter passing. + */ + public static class MutableHolder { + private String item1; + private String item2; + + public MutableHolder() { + } + + public MutableHolder(String item1, String item2) { + this.item1 = item1; + this.item2 = item2; + } + + public String getItem1() { + return item1; + } + + public void setItem1(String item1) { + this.item1 = item1; + } + + public String getItem2() { + return item2; + } + + public void setItem2(String item2) { + this.item2 = item2; + } + } + + /** + * Immutable result returned from subprogram calls. + */ + public static class SubProgramResult { + private final String linkageItem1; + private final String linkageItem2; + private final String workingStorageItem1; + private final String workingStorageItem2; + + public SubProgramResult(String linkageItem1, String linkageItem2, + String workingStorageItem1, String workingStorageItem2) { + this.linkageItem1 = linkageItem1; + this.linkageItem2 = linkageItem2; + this.workingStorageItem1 = workingStorageItem1; + this.workingStorageItem2 = workingStorageItem2; + } + + public String getLinkageItem1() { + return linkageItem1; + } + + public String getLinkageItem2() { + return linkageItem2; + } + + public String getWorkingStorageItem1() { + return workingStorageItem1; + } + + public String getWorkingStorageItem2() { + return workingStorageItem2; + } + } +} diff --git a/spring-boot-app/src/main/resources/application.properties b/spring-boot-app/src/main/resources/application.properties new file mode 100644 index 0000000..3ce6b1c --- /dev/null +++ b/spring-boot-app/src/main/resources/application.properties @@ -0,0 +1,24 @@ +# ============================================================================= +# PostgreSQL Connection Configuration +# Mirrors the connection string from sql/sql_example.cbl: +# DRIVER={PostgreSQL Unicode};SERVER=localhost;PORT=5432; +# DATABASE=cobol_db_example;UID=postgres;PWD=password; +# ============================================================================= + +spring.datasource.url=jdbc:postgresql://localhost:5432/cobol_db_example +spring.datasource.username=postgres +spring.datasource.password=password +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA / Hibernate settings +spring.jpa.hibernate.ddl-auto=none +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# Initialize schema from schema.sql on startup +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:schema.sql + +# Server configuration +server.port=8080 diff --git a/spring-boot-app/src/main/resources/schema.sql b/spring-boot-app/src/main/resources/schema.sql new file mode 100644 index 0000000..e7db924 --- /dev/null +++ b/spring-boot-app/src/main/resources/schema.sql @@ -0,0 +1,19 @@ +-- ============================================================================= +-- Schema initialization script +-- Adapted from: sql/create_test_db.sql +-- +-- Creates the ACCOUNTS table used by the migrated COBOL SQL application. +-- This script is executed on application startup via spring.sql.init. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL NOT NULL, + first_name VARCHAR NOT NULL, + last_name VARCHAR NOT NULL, + phone VARCHAR NOT NULL, + address VARCHAR NOT NULL, + is_enabled VARCHAR(1) NOT NULL DEFAULT 'N', + create_dt TIMESTAMP DEFAULT NOW(), + mod_dt TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (id) +); diff --git a/spring-boot-app/src/test/java/com/cobolmigration/controller/AccountControllerTest.java b/spring-boot-app/src/test/java/com/cobolmigration/controller/AccountControllerTest.java new file mode 100644 index 0000000..fec3fb3 --- /dev/null +++ b/spring-boot-app/src/test/java/com/cobolmigration/controller/AccountControllerTest.java @@ -0,0 +1,102 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.model.Account; +import com.cobolmigration.service.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web layer tests for {@link AccountController}. + */ +@WebMvcTest(AccountController.class) +class AccountControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountService accountService; + + @Test + void getAllAccounts_returnsOkWithAccounts() throws Exception { + Account account = createTestAccount(1, "John", "Tester", true); + when(accountService.getAllAccounts()).thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("John"))) + .andExpect(jsonPath("$[0].lastName", is("Tester"))) + .andExpect(jsonPath("$[0].enabled", is(true))); + } + + @Test + void getAllAccounts_emptyList_returnsOk() throws Exception { + when(accountService.getAllAccounts()).thenReturn(List.of()); + + mockMvc.perform(get("/api/accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void getDisabledAccounts_returnsOnlyDisabled() throws Exception { + Account account = createTestAccount(2, "Bob", "Tester4", false); + when(accountService.getDisabledAccounts()).thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts/disabled")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("Bob"))) + .andExpect(jsonPath("$[0].enabled", is(false))); + } + + @Test + void searchAccounts_returnsMatchingAccounts() throws Exception { + Account account = createTestAccount(1, "John", "Tester", true); + when(accountService.searchAccounts("John")).thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts/search").param("q", "John")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("John"))); + } + + @Test + void searchAccounts_noResults_returnsEmptyList() throws Exception { + when(accountService.searchAccounts("NONEXISTENT")).thenReturn(List.of()); + + mockMvc.perform(get("/api/accounts/search").param("q", "NONEXISTENT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void searchAccounts_missingQueryParam_returnsBadRequest() throws Exception { + mockMvc.perform(get("/api/accounts/search")) + .andExpect(status().isBadRequest()); + } + + private Account createTestAccount(int id, String firstName, String lastName, + boolean enabled) { + Account account = new Account(firstName, lastName, "1555550100", + "123 Fake St", enabled); + account.setId(id); + account.setCreateDt(LocalDateTime.now()); + account.setModDt(LocalDateTime.now()); + return account; + } +} diff --git a/spring-boot-app/src/test/java/com/cobolmigration/repository/AccountRepositoryTest.java b/spring-boot-app/src/test/java/com/cobolmigration/repository/AccountRepositoryTest.java new file mode 100644 index 0000000..1a0e11c --- /dev/null +++ b/spring-boot-app/src/test/java/com/cobolmigration/repository/AccountRepositoryTest.java @@ -0,0 +1,116 @@ +package com.cobolmigration.repository; + +import com.cobolmigration.model.Account; +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.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link AccountRepository} using an embedded H2 database. + * + *

Test data mirrors the ACCOUNTS table structure from + * {@code sql/create_test_db.sql}.

+ */ +@DataJpaTest +@ActiveProfiles("test") +class AccountRepositoryTest { + + @Autowired + private AccountRepository accountRepository; + + @BeforeEach + void setUp() { + accountRepository.deleteAll(); + + LocalDateTime now = LocalDateTime.now(); + + Account account1 = new Account("John", "Tester", "1555550100", "123 Fake St, Nowhere", true); + account1.setCreateDt(now); + account1.setModDt(now); + + Account account2 = new Account("Bob", "Tester4", "1555550154", "119 Truck St, Nowhere", false); + account2.setCreateDt(now); + account2.setModDt(now); + + Account account3 = new Account("Paula", "Tester5", "1555550165", "118 Car St, Nowhere", false); + account3.setCreateDt(now); + account3.setModDt(now); + + Account account4 = new Account("Jane", "Tester7", "1555550187", "116 Sea St, Nowhere", true); + account4.setCreateDt(now); + account4.setModDt(now); + + accountRepository.saveAll(List.of(account1, account2, account3, account4)); + } + + @Test + void findAllByOrderByIdAsc_returnsAllAccountsOrdered() { + List accounts = accountRepository.findAllByOrderByIdAsc(); + + assertThat(accounts).hasSize(4); + assertThat(accounts.get(0).getId()).isLessThan(accounts.get(1).getId()); + } + + @Test + void findByEnabledFalseOrderByIdAsc_returnsOnlyDisabledAccounts() { + List accounts = accountRepository.findByEnabledFalseOrderByIdAsc(); + + assertThat(accounts).hasSize(2); + assertThat(accounts).allMatch(a -> !a.isEnabled()); + assertThat(accounts.get(0).getFirstName()).isEqualTo("Bob"); + assertThat(accounts.get(1).getFirstName()).isEqualTo("Paula"); + } + + @Test + void searchAccounts_byFirstName_returnsMatchingAccounts() { + List accounts = accountRepository.searchAccounts("%John%"); + + assertThat(accounts).hasSize(1); + assertThat(accounts.get(0).getFirstName()).isEqualTo("John"); + } + + @Test + void searchAccounts_byLastName_returnsMatchingAccounts() { + List accounts = accountRepository.searchAccounts("%Tester5%"); + + assertThat(accounts).hasSize(1); + assertThat(accounts.get(0).getFirstName()).isEqualTo("Paula"); + } + + @Test + void searchAccounts_byAddress_returnsMatchingAccounts() { + List accounts = accountRepository.searchAccounts("%Truck%"); + + assertThat(accounts).hasSize(1); + assertThat(accounts.get(0).getFirstName()).isEqualTo("Bob"); + } + + @Test + void searchAccounts_byPhone_returnsMatchingAccounts() { + List accounts = accountRepository.searchAccounts("%0187%"); + + assertThat(accounts).hasSize(1); + assertThat(accounts.get(0).getFirstName()).isEqualTo("Jane"); + } + + @Test + void searchAccounts_partialMatch_returnsMultipleAccounts() { + List accounts = accountRepository.searchAccounts("%Tester%"); + + assertThat(accounts).hasSize(4); + } + + @Test + void searchAccounts_noMatch_returnsEmptyList() { + List accounts = accountRepository.searchAccounts("%NONEXISTENT%"); + + assertThat(accounts).isEmpty(); + } +} diff --git a/spring-boot-app/src/test/java/com/cobolmigration/service/AccountServiceTest.java b/spring-boot-app/src/test/java/com/cobolmigration/service/AccountServiceTest.java new file mode 100644 index 0000000..e6f9681 --- /dev/null +++ b/spring-boot-app/src/test/java/com/cobolmigration/service/AccountServiceTest.java @@ -0,0 +1,88 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.Account; +import com.cobolmigration.repository.AccountRepository; +import org.junit.jupiter.api.BeforeEach; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link AccountService} with mocked repository. + */ +@ExtendWith(MockitoExtension.class) +class AccountServiceTest { + + @Mock + private AccountRepository accountRepository; + + @InjectMocks + private AccountService accountService; + + private Account enabledAccount; + private Account disabledAccount; + + @BeforeEach + void setUp() { + enabledAccount = new Account("John", "Tester", "1555550100", + "123 Fake St", true); + enabledAccount.setId(1); + + disabledAccount = new Account("Bob", "Tester4", "1555550154", + "119 Truck St", false); + disabledAccount.setId(2); + } + + @Test + void getAllAccounts_delegatesToRepository() { + when(accountRepository.findAllByOrderByIdAsc()) + .thenReturn(List.of(enabledAccount, disabledAccount)); + + List result = accountService.getAllAccounts(); + + assertThat(result).hasSize(2); + verify(accountRepository).findAllByOrderByIdAsc(); + } + + @Test + void getDisabledAccounts_delegatesToRepository() { + when(accountRepository.findByEnabledFalseOrderByIdAsc()) + .thenReturn(List.of(disabledAccount)); + + List result = accountService.getDisabledAccounts(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).isEnabled()).isFalse(); + verify(accountRepository).findByEnabledFalseOrderByIdAsc(); + } + + @Test + void searchAccounts_wrapsQueryWithWildcards() { + when(accountRepository.searchAccounts("%John%")) + .thenReturn(List.of(enabledAccount)); + + List result = accountService.searchAccounts("John"); + + assertThat(result).hasSize(1); + verify(accountRepository).searchAccounts("%John%"); + } + + @Test + void searchAccounts_emptyQuery_wrapsWithWildcards() { + when(accountRepository.searchAccounts("%%")) + .thenReturn(List.of(enabledAccount, disabledAccount)); + + List result = accountService.searchAccounts(""); + + assertThat(result).hasSize(2); + verify(accountRepository).searchAccounts("%%"); + } +} diff --git a/spring-boot-app/src/test/java/com/cobolmigration/service/SearchServiceTest.java b/spring-boot-app/src/test/java/com/cobolmigration/service/SearchServiceTest.java new file mode 100644 index 0000000..7e7b3d7 --- /dev/null +++ b/spring-boot-app/src/test/java/com/cobolmigration/service/SearchServiceTest.java @@ -0,0 +1,105 @@ +package com.cobolmigration.service; + +import com.cobolmigration.service.SearchService.SearchableItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SearchService} validating binary search behavior + * that matches the COBOL SEARCH ALL functionality from + * {@code search/search.cbl}. + */ +class SearchServiceTest { + + private SearchService searchService; + private List sortedItems; + + @BeforeEach + void setUp() { + searchService = new SearchService(); + + // Mirrors the test data from search.cbl (lines 129-145): + // ws-item-id-1(1) = 0001, ws-item-name(1) = "test item 1" + // ws-item-id-1(2) = 0002, ws-item-name(2) = "test item 2" + // ws-item-id-1(3) = 0003, ws-item-name(3) = "test item 3" + sortedItems = new ArrayList<>(); + sortedItems.add(new SearchableItem(1, "test item 1", "2021/01/01")); + sortedItems.add(new SearchableItem(2, "test item 2", "2021/02/02")); + sortedItems.add(new SearchableItem(3, "test item 3", "2021/03/03")); + } + + @Test + void binarySearchById_findsExistingItem() { + Optional result = searchService.binarySearchById(sortedItems, 2); + + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(2); + assertThat(result.get().getName()).isEqualTo("test item 2"); + } + + @Test + void binarySearchById_findsFirstItem() { + Optional result = searchService.binarySearchById(sortedItems, 1); + + assertThat(result).isPresent(); + assertThat(result.get().getName()).isEqualTo("test item 1"); + } + + @Test + void binarySearchById_findsLastItem() { + Optional result = searchService.binarySearchById(sortedItems, 3); + + assertThat(result).isPresent(); + assertThat(result.get().getName()).isEqualTo("test item 3"); + } + + @Test + void binarySearchById_returnsEmptyForNonExistentItem() { + // Equivalent to SEARCH ALL ... AT END DISPLAY "Item not found." + Optional result = searchService.binarySearchById(sortedItems, 999); + + assertThat(result).isEmpty(); + } + + @Test + void binarySearchById_emptyList_returnsEmpty() { + Optional result = searchService.binarySearchById(List.of(), 1); + + assertThat(result).isEmpty(); + } + + @Test + void sequentialSearchById_findsExistingItem() { + // Mirrors the sequential SEARCH (without ALL) from search.cbl (lines 100-109) + // Sequential search does not require sorted data + List unsortedItems = new ArrayList<>(); + unsortedItems.add(new SearchableItem(2, "Value of id 2.", "")); + unsortedItems.add(new SearchableItem(3, "Value of id 3.", "")); + unsortedItems.add(new SearchableItem(1, "Value of id 1.", "")); + + Optional result = searchService.sequentialSearchById(unsortedItems, 3); + + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(3); + } + + @Test + void sequentialSearchById_returnsEmptyForNonExistentItem() { + Optional result = searchService.sequentialSearchById(sortedItems, 999); + + assertThat(result).isEmpty(); + } + + @Test + void sequentialSearchById_emptyList_returnsEmpty() { + Optional result = searchService.sequentialSearchById(List.of(), 1); + + assertThat(result).isEmpty(); + } +} diff --git a/spring-boot-app/src/test/resources/application-test.properties b/spring-boot-app/src/test/resources/application-test.properties new file mode 100644 index 0000000..670d570 --- /dev/null +++ b/spring-boot-app/src/test/resources/application-test.properties @@ -0,0 +1,16 @@ +# ============================================================================= +# Test Configuration — H2 In-Memory Database +# Used by @DataJpaTest and @SpringBootTest for integration testing +# without requiring a running PostgreSQL instance. +# ============================================================================= + +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect + +spring.sql.init.mode=never From 5f68f008dba8c3512d95f26a8fa1b3c82a965928 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:07:10 +0000 Subject: [PATCH 2/2] fix: add YesNoConverter for Account.enabled to handle VARCHAR('Y'/'N') column Addresses Devin Review finding: boolean field was incompatible with the production PostgreSQL IS_ENABLED VARCHAR(1) column storing 'Y'/'N'. Uses Hibernate's built-in YesNoConverter to properly convert between Java boolean and the database character values. Co-Authored-By: Jerry Oliphant --- .../src/main/java/com/cobolmigration/model/Account.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spring-boot-app/src/main/java/com/cobolmigration/model/Account.java b/spring-boot-app/src/main/java/com/cobolmigration/model/Account.java index 1aa0d4d..01c7639 100644 --- a/spring-boot-app/src/main/java/com/cobolmigration/model/Account.java +++ b/spring-boot-app/src/main/java/com/cobolmigration/model/Account.java @@ -1,12 +1,14 @@ package com.cobolmigration.model; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.LocalDateTime; +import org.hibernate.type.YesNoConverter; /** * JPA entity mapped to the ACCOUNTS table. @@ -48,8 +50,9 @@ public class Account { /** * Maps to the IS_ENABLED column which stores 'Y' or 'N' in the COBOL version. - * Converted to a boolean for idiomatic Java usage. + * Uses Hibernate's YesNoConverter to translate between boolean and VARCHAR(1). */ + @Convert(converter = YesNoConverter.class) @Column(name = "IS_ENABLED", nullable = false) private boolean enabled;