From 379e47f9c92ca447e01580c82b5e74fed0e90ada Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:17:34 +0000 Subject: [PATCH] Add Spring Boot migration project (Phases 1-5) - Phase 1: Data Layer - Account entity, repository, service, controller, SQL - Phase 2: Business Logic - SearchService, FileSortService, StringUtils, ReportService - Phase 3: Serialization - XmlSerializationService, JsonSerializationService - Phase 4: Architecture - SubProgramService (CALL/CANCEL pattern migration) - Phase 5: Testing - 71 unit and integration tests, all passing - README with API docs and COBOL-to-Java mapping reference Co-Authored-By: Jerry Oliphant --- spring-boot-migration/README.md | 208 ++++++++++++++++++ spring-boot-migration/pom.xml | 87 ++++++++ .../CobolMigrationApplication.java | 12 + .../controller/AccountController.java | 56 +++++ .../controller/ReportController.java | 61 +++++ .../controller/SerializationController.java | 55 +++++ .../com/cobolmigration/entity/Account.java | 54 +++++ .../exception/DatabaseException.java | 37 ++++ .../exception/GlobalExceptionHandler.java | 53 +++++ .../exception/ResourceNotFoundException.java | 11 + .../cobolmigration/model/CustomerRecord.java | 31 +++ .../cobolmigration/model/ReportRecord.java | 29 +++ .../com/cobolmigration/model/SearchItem.java | 39 ++++ .../model/SerializationRecord.java | 46 ++++ .../repository/AccountRepository.java | 41 ++++ .../service/AccountService.java | 59 +++++ .../service/FileSortService.java | 120 ++++++++++ .../service/JsonSerializationService.java | 56 +++++ .../cobolmigration/service/ReportService.java | 147 +++++++++++++ .../cobolmigration/service/SearchService.java | 87 ++++++++ .../service/SubProgramService.java | 130 +++++++++++ .../service/XmlSerializationService.java | 58 +++++ .../com/cobolmigration/util/StringUtils.java | 91 ++++++++ .../src/main/resources/application.yml | 20 ++ .../src/main/resources/data.sql | 35 +++ .../src/main/resources/schema.sql | 14 ++ .../AccountControllerIntegrationTest.java | 142 ++++++++++++ .../service/AccountServiceTest.java | 143 ++++++++++++ .../service/FileSortServiceTest.java | 168 ++++++++++++++ .../service/JsonSerializationServiceTest.java | 97 ++++++++ .../service/SearchServiceTest.java | 117 ++++++++++ .../service/XmlSerializationServiceTest.java | 97 ++++++++ .../cobolmigration/util/StringUtilsTest.java | 200 +++++++++++++++++ .../src/test/resources/application-test.yml | 20 ++ 34 files changed, 2621 insertions(+) create mode 100644 spring-boot-migration/README.md create mode 100644 spring-boot-migration/pom.xml create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/CobolMigrationApplication.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/controller/AccountController.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/controller/ReportController.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/controller/SerializationController.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/entity/Account.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/exception/DatabaseException.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/exception/GlobalExceptionHandler.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/exception/ResourceNotFoundException.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/model/CustomerRecord.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/model/ReportRecord.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/model/SearchItem.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/model/SerializationRecord.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/repository/AccountRepository.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/service/AccountService.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/service/FileSortService.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/service/JsonSerializationService.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/service/ReportService.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/service/SearchService.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/service/SubProgramService.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/service/XmlSerializationService.java create mode 100644 spring-boot-migration/src/main/java/com/cobolmigration/util/StringUtils.java create mode 100644 spring-boot-migration/src/main/resources/application.yml create mode 100644 spring-boot-migration/src/main/resources/data.sql create mode 100644 spring-boot-migration/src/main/resources/schema.sql create mode 100644 spring-boot-migration/src/test/java/com/cobolmigration/controller/AccountControllerIntegrationTest.java create mode 100644 spring-boot-migration/src/test/java/com/cobolmigration/service/AccountServiceTest.java create mode 100644 spring-boot-migration/src/test/java/com/cobolmigration/service/FileSortServiceTest.java create mode 100644 spring-boot-migration/src/test/java/com/cobolmigration/service/JsonSerializationServiceTest.java create mode 100644 spring-boot-migration/src/test/java/com/cobolmigration/service/SearchServiceTest.java create mode 100644 spring-boot-migration/src/test/java/com/cobolmigration/service/XmlSerializationServiceTest.java create mode 100644 spring-boot-migration/src/test/java/com/cobolmigration/util/StringUtilsTest.java create mode 100644 spring-boot-migration/src/test/resources/application-test.yml diff --git a/spring-boot-migration/README.md b/spring-boot-migration/README.md new file mode 100644 index 0000000..8110061 --- /dev/null +++ b/spring-boot-migration/README.md @@ -0,0 +1,208 @@ +# COBOL to Spring Boot Migration + +This project is a Java Spring Boot application that migrates the functionality of the [COBOL-Examples](../) repository. Each COBOL program has been translated into equivalent Java classes following modern Spring Boot patterns and best practices. + +## Prerequisites + +- Java 17 or later +- Maven 3.8+ +- PostgreSQL 12+ (for production) + +## Building the Application + +```bash +cd spring-boot-migration +mvn clean package +``` + +To skip tests: + +```bash +mvn clean package -DskipTests +``` + +## Running the Application + +### 1. Database Setup + +Create the PostgreSQL database using the original SQL script: + +```bash +psql -U postgres -f ../sql/create_test_db.sql +``` + +Or manually create the database and run the schema: + +```bash +psql -U postgres -c "CREATE DATABASE cobol_db_example;" +psql -U postgres -d cobol_db_example -f src/main/resources/schema.sql +psql -U postgres -d cobol_db_example -f src/main/resources/data.sql +``` + +### 2. Configure Database Connection + +Edit `src/main/resources/application.yml` to match your PostgreSQL setup: + +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/cobol_db_example + username: postgres + password: password +``` + +### 3. Start the Application + +```bash +mvn spring-boot:run +``` + +The application starts on `http://localhost:8080`. + +## Running Tests + +```bash +mvn test +``` + +Integration tests use H2 in PostgreSQL compatibility mode, so no external database is needed for testing. + +## API Endpoints + +### Accounts (from `sql/sql_example.cbl`) + +| Endpoint | Description | COBOL Equivalent | +|----------|-------------|-----------------| +| `GET /api/accounts` | List all accounts | Menu option 1 (display-all-accounts) | +| `GET /api/accounts?enabled=false` | List disabled accounts | Menu option 2 (display-disabled-accounts) | +| `GET /api/accounts?search={term}` | Search accounts | Menu option 3 (query-accounts) | + +### Reports (from `report_writer/report_test.cbl`) + +| Endpoint | Description | COBOL Equivalent | +|----------|-------------|-----------------| +| `GET /api/reports/customers` | Customer report (HTML) | Report Writer output | +| `GET /api/reports/customers?format=text` | Customer report (text) | Report Writer output | + +### Serialization (from `xml_generate/`, `json_generate/`) + +| Endpoint | Description | COBOL Equivalent | +|----------|-------------|-----------------| +| `GET /api/serialize/xml` | XML generation demo | `xml_generate/xml_generate.cbl` | +| `GET /api/serialize/json` | JSON generation demo | `json_generate/json_generate.cbl` | + +## COBOL to Java Mapping Reference + +### Phase 1: Data Layer (`sql/`) + +| COBOL Source | Java Class | Description | +|-------------|-----------|-------------| +| `sql/create_test_db.sql` (lines 8-18) | `entity/Account.java` | Table → JPA Entity | +| `sql/sql_example.cbl` (lines 44-52) | `entity/Account.java` | `ws-sql-account-record` → Account fields | +| `sql/sql_example.cbl` (lines 119-125) | `repository/AccountRepository.java` | CURSOR declarations → JPA queries | +| `sql/sql_example.cbl` (lines 65-67) | `repository/AccountRepository.java` | `ws-search-value` → `@Query` with JPQL | +| `sql/sql_example.cbl` (lines 443-472) | `exception/DatabaseException.java` | `check-sql-state` → Exception handling | +| `sql/sql_example.cbl` (lines 158-185) | `controller/AccountController.java` | Menu options → REST endpoints | + +### Phase 2: Business Logic + +| COBOL Source | Java Class | Description | +|-------------|-----------|-------------| +| `search/search.cbl` (lines 17-33) | `model/SearchItem.java` | `ws-item-table` → SearchItem model | +| `search/search.cbl` (lines 61-66) | `service/SearchService.java` | `SEARCH ALL` → `Collections.binarySearch()` | +| `merge_sort/merge_sort_test.cbl` (lines 47-53) | `model/CustomerRecord.java` | File record → CustomerRecord model | +| `merge_sort/merge_sort_test.cbl` (lines 107-110) | `service/FileSortService.java` | `MERGE` → `mergeFiles()` | +| `merge_sort/merge_sort_test.cbl` (lines 142-145) | `service/FileSortService.java` | `SORT` → `sortByContractIdDesc()` | +| `trim/trim.cbl` | `util/StringUtils.java` | `FUNCTION TRIM` → `trim()` | +| `unstring/unstring.cbl` | `util/StringUtils.java` | `UNSTRING` → `unstring()` | +| `is_numeric/is_numeric.cbl` | `util/StringUtils.java` | `IS NUMERIC` → `isNumeric()` | +| `numval_test/numval_test.cbl` | `util/StringUtils.java` | `FUNCTION NUMVAL` → `numericValue()` | +| `report_writer/report_test.cbl` (lines 41-63) | `service/ReportService.java` | Report Writer → HTML/Text report | + +### Phase 3: Serialization + +| COBOL Source | Java Class | Description | +|-------------|-----------|-------------| +| `xml_generate/xml_generate.cbl` (lines 41-56) | `service/XmlSerializationService.java` | `XML GENERATE` → Jackson XmlMapper | +| `json_generate/json_generate.cbl` (lines 42-54) | `service/JsonSerializationService.java` | `JSON GENERATE` → Jackson ObjectMapper | +| Both | `model/SerializationRecord.java` | `ws-record` → annotated model | + +### Phase 4: Architecture + +| COBOL Source | Java Class | Description | +|-------------|-----------|-------------| +| `sub_program/main_app.cbl` (lines 34-38) | `service/SubProgramService.java` | `CALL BY CONTENT` → `callByContent()` | +| `sub_program/main_app.cbl` (lines 47-49) | `service/SubProgramService.java` | `CALL BY REFERENCE` → `callByReference()` | +| `sub_program/main_app.cbl` (lines 54-55) | `service/SubProgramService.java` | `CANCEL` → `reset()` | +| `sub_program/sub.cbl` WORKING-STORAGE | `service/SubProgramService.java` | Singleton bean fields | +| `sub_program/sub.cbl` LOCAL-STORAGE | `service/SubProgramService.java` | Method-local variables | +| `sub_program/sub.cbl` LINKAGE SECTION | `service/SubProgramService.java` | Method parameters | + +### Phase 5: Testing + +| Test Class | What It Tests | +|-----------|--------------| +| `AccountServiceTest.java` | Account queries with mock repository | +| `SearchServiceTest.java` | Binary search with exact COBOL test data | +| `FileSortServiceTest.java` | Merge and sort ordering matches COBOL | +| `StringUtilsTest.java` | Trim, unstring, isNumeric, numval edge cases | +| `XmlSerializationServiceTest.java` | XML output matches COBOL XML GENERATE | +| `JsonSerializationServiceTest.java` | JSON output matches COBOL JSON GENERATE | +| `AccountControllerIntegrationTest.java` | REST endpoints with H2 (PostgreSQL mode) | + +## Project Structure + +``` +spring-boot-migration/ +├── pom.xml +├── README.md +└── src/ + ├── main/ + │ ├── java/com/cobolmigration/ + │ │ ├── CobolMigrationApplication.java + │ │ ├── controller/ + │ │ │ ├── AccountController.java + │ │ │ ├── ReportController.java + │ │ │ └── SerializationController.java + │ │ ├── entity/ + │ │ │ └── Account.java + │ │ ├── exception/ + │ │ │ ├── DatabaseException.java + │ │ │ ├── GlobalExceptionHandler.java + │ │ │ └── ResourceNotFoundException.java + │ │ ├── model/ + │ │ │ ├── CustomerRecord.java + │ │ │ ├── ReportRecord.java + │ │ │ ├── SearchItem.java + │ │ │ └── SerializationRecord.java + │ │ ├── repository/ + │ │ │ └── AccountRepository.java + │ │ ├── service/ + │ │ │ ├── AccountService.java + │ │ │ ├── FileSortService.java + │ │ │ ├── JsonSerializationService.java + │ │ │ ├── ReportService.java + │ │ │ ├── SearchService.java + │ │ │ ├── SubProgramService.java + │ │ │ └── XmlSerializationService.java + │ │ └── util/ + │ │ └── StringUtils.java + │ └── resources/ + │ ├── application.yml + │ ├── data.sql + │ └── schema.sql + └── test/ + ├── java/com/cobolmigration/ + │ ├── controller/ + │ │ └── AccountControllerIntegrationTest.java + │ ├── service/ + │ │ ├── AccountServiceTest.java + │ │ ├── FileSortServiceTest.java + │ │ ├── JsonSerializationServiceTest.java + │ │ ├── SearchServiceTest.java + │ │ └── XmlSerializationServiceTest.java + │ └── util/ + │ └── StringUtilsTest.java + └── resources/ + └── application-test.yml +``` diff --git a/spring-boot-migration/pom.xml b/spring-boot-migration/pom.xml new file mode 100644 index 0000000..b28700e --- /dev/null +++ b/spring-boot-migration/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.cobolmigration + cobol-migration + 0.0.1-SNAPSHOT + COBOL to Spring Boot Migration + Migration of COBOL-Examples to a Java Spring Boot application + + + 17 + + + + + + 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 + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/CobolMigrationApplication.java b/spring-boot-migration/src/main/java/com/cobolmigration/CobolMigrationApplication.java new file mode 100644 index 0000000..0886b0e --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/CobolMigrationApplication.java @@ -0,0 +1,12 @@ +package com.cobolmigration; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CobolMigrationApplication { + + public static void main(String[] args) { + SpringApplication.run(CobolMigrationApplication.class, args); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/controller/AccountController.java b/spring-boot-migration/src/main/java/com/cobolmigration/controller/AccountController.java new file mode 100644 index 0000000..c144abe --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/controller/AccountController.java @@ -0,0 +1,56 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.entity.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 for Account operations. + * Replaces the COBOL menu-driven interface in sql_example.cbl (lines 158-185). + * + * Endpoint mapping: + * GET /api/accounts -> menu option 1 (display all accounts) + * GET /api/accounts?enabled=false -> menu option 2 (display disabled accounts) + * GET /api/accounts?search={term} -> menu option 3 (query accounts) + */ +@RestController +@RequestMapping("/api/accounts") +public class AccountController { + + private final AccountService accountService; + + public AccountController(AccountService accountService) { + this.accountService = accountService; + } + + /** + * GET /api/accounts - Returns all accounts, with optional filtering. + * + * @param enabled if "false", returns only disabled accounts (menu option 2) + * @param search if provided, searches accounts by term (menu option 3) + * @return list of matching accounts + */ + @GetMapping + public ResponseEntity> getAccounts( + @RequestParam(required = false) String enabled, + @RequestParam(required = false) String search) { + + List accounts; + + if (search != null && !search.isBlank()) { + accounts = accountService.searchAccounts(search); + } else if ("false".equalsIgnoreCase(enabled)) { + accounts = accountService.getDisabledAccounts(); + } else { + accounts = accountService.getAllAccounts(); + } + + return ResponseEntity.ok(accounts); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/controller/ReportController.java b/spring-boot-migration/src/main/java/com/cobolmigration/controller/ReportController.java new file mode 100644 index 0000000..2888c72 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/controller/ReportController.java @@ -0,0 +1,61 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.model.ReportRecord; +import com.cobolmigration.service.ReportService; +import org.springframework.http.MediaType; +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 for report generation. + * Replaces COBOL Report Writer output (report_writer/report_test.cbl). + */ +@RestController +@RequestMapping("/api/reports") +public class ReportController { + + private final ReportService reportService; + + public ReportController(ReportService reportService) { + this.reportService = reportService; + } + + /** + * GET /api/reports/customers - Generates a customer report. + * Returns HTML by default. Use ?format=text for plain text output. + * Uses sample data mirroring the COBOL input.txt format. + */ + @GetMapping("/customers") + public ResponseEntity getCustomerReport( + @RequestParam(defaultValue = "html") String format) { + + List sampleRecords = getSampleReportData(); + + if ("text".equalsIgnoreCase(format)) { + String textReport = reportService.generateTextReport(sampleRecords); + return ResponseEntity.ok() + .contentType(MediaType.TEXT_PLAIN) + .body(textReport); + } + + String htmlReport = reportService.generateHtmlReport(sampleRecords); + return ResponseEntity.ok() + .contentType(MediaType.TEXT_HTML) + .body(htmlReport); + } + + private List getSampleReportData() { + return List.of( + ReportRecord.builder().studentId(100001).studentName("Alice Johnson").major("CSC").numCourses(5).build(), + ReportRecord.builder().studentId(100002).studentName("Bob Williams").major("MTH").numCourses(4).build(), + ReportRecord.builder().studentId(100003).studentName("Carol Davis").major("PHY").numCourses(6).build(), + ReportRecord.builder().studentId(100004).studentName("David Brown").major("ENG").numCourses(3).build(), + ReportRecord.builder().studentId(100005).studentName("Eve Martinez").major("CSC").numCourses(7).build() + ); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/controller/SerializationController.java b/spring-boot-migration/src/main/java/com/cobolmigration/controller/SerializationController.java new file mode 100644 index 0000000..616c8fc --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/controller/SerializationController.java @@ -0,0 +1,55 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.model.SerializationRecord; +import com.cobolmigration.service.JsonSerializationService; +import com.cobolmigration.service.XmlSerializationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.http.MediaType; +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.RestController; + +/** + * REST controller for serialization demonstrations. + * Replaces COBOL XML GENERATE (xml_generate/) and JSON GENERATE (json_generate/) programs. + */ +@RestController +@RequestMapping("/api/serialize") +public class SerializationController { + + private final XmlSerializationService xmlService; + private final JsonSerializationService jsonService; + + public SerializationController(XmlSerializationService xmlService, + JsonSerializationService jsonService) { + this.xmlService = xmlService; + this.jsonService = jsonService; + } + + /** + * GET /api/serialize/xml - Demonstrates XML generation. + * Replaces xml_generate/xml_generate.cbl program execution. + */ + @GetMapping("/xml") + public ResponseEntity generateXml() throws JsonProcessingException { + SerializationRecord record = xmlService.createSampleRecord(); + String xml = xmlService.toXml(record); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_XML) + .body(xml); + } + + /** + * GET /api/serialize/json - Demonstrates JSON generation. + * Replaces json_generate/json_generate.cbl program execution. + */ + @GetMapping("/json") + public ResponseEntity generateJson() throws JsonProcessingException { + SerializationRecord record = jsonService.createSampleRecord(); + String json = jsonService.toJson(record); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(json); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/entity/Account.java b/spring-boot-migration/src/main/java/com/cobolmigration/entity/Account.java new file mode 100644 index 0000000..1a40eb2 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/entity/Account.java @@ -0,0 +1,54 @@ +package com.cobolmigration.entity; + +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 lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * JPA entity mapping to the "accounts" table defined in sql/create_test_db.sql (lines 8-18). + * Replaces the COBOL ws-sql-account-record structure in sql/sql_example.cbl (lines 44-52). + */ +@Entity +@Table(name = "accounts") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Account { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "first_name", nullable = false) + private String firstName; + + @Column(name = "last_name", nullable = false) + private String lastName; + + @Column(name = "phone", nullable = false) + private String phone; + + @Column(name = "address", nullable = false) + private String address; + + @Column(name = "is_enabled", nullable = false, length = 1) + private String isEnabled; + + @Column(name = "create_dt") + private LocalDateTime createDt; + + @Column(name = "mod_dt") + private LocalDateTime modDt; +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/exception/DatabaseException.java b/spring-boot-migration/src/main/java/com/cobolmigration/exception/DatabaseException.java new file mode 100644 index 0000000..7b2f249 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/exception/DatabaseException.java @@ -0,0 +1,37 @@ +package com.cobolmigration.exception; + +/** + * Custom exception replacing the check-sql-state paragraph in sql_example.cbl (lines 443-472). + * Thrown when database operations encounter errors. + */ +public class DatabaseException extends RuntimeException { + + private final String sqlState; + private final int sqlCode; + + public DatabaseException(String message) { + super(message); + this.sqlState = null; + this.sqlCode = 0; + } + + public DatabaseException(String message, String sqlState, int sqlCode) { + super(message); + this.sqlState = sqlState; + this.sqlCode = sqlCode; + } + + public DatabaseException(String message, Throwable cause) { + super(message, cause); + this.sqlState = null; + this.sqlCode = 0; + } + + public String getSqlState() { + return sqlState; + } + + public int getSqlCode() { + return sqlCode; + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/exception/GlobalExceptionHandler.java b/spring-boot-migration/src/main/java/com/cobolmigration/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4a338f3 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/exception/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package com.cobolmigration.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Global exception handler using @ControllerAdvice. + * Replaces the COBOL check-sql-state paragraph error handling pattern + * in sql_example.cbl (lines 443-472). + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(DatabaseException.class) + public ResponseEntity> handleDatabaseException(DatabaseException ex) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + body.put("error", "Database Error"); + body.put("message", ex.getMessage()); + if (ex.getSqlState() != null) { + body.put("sqlState", ex.getSqlState()); + body.put("sqlCode", ex.getSqlCode()); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleResourceNotFound(ResourceNotFoundException ex) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", HttpStatus.NOT_FOUND.value()); + body.put("error", "Not Found"); + body.put("message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + body.put("error", "Internal Server Error"); + body.put("message", ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/exception/ResourceNotFoundException.java b/spring-boot-migration/src/main/java/com/cobolmigration/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..2a4976b --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/exception/ResourceNotFoundException.java @@ -0,0 +1,11 @@ +package com.cobolmigration.exception; + +/** + * Exception thrown when a requested resource is not found. + */ +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/model/CustomerRecord.java b/spring-boot-migration/src/main/java/com/cobolmigration/model/CustomerRecord.java new file mode 100644 index 0000000..5594230 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/model/CustomerRecord.java @@ -0,0 +1,31 @@ +package com.cobolmigration.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Model class replacing the fixed-length COBOL customer record + * in merge_sort/merge_sort_test.cbl (lines 47-53). + * Fields map to: + * f-customer-id -> customerId + * f-customer-last-name -> lastName + * f-customer-first-name -> firstName + * f-customer-contract-id -> contractId + * f-customer-comment -> comment + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CustomerRecord { + + private int customerId; + private String lastName; + private String firstName; + private int contractId; + private String comment; +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/model/ReportRecord.java b/spring-boot-migration/src/main/java/com/cobolmigration/model/ReportRecord.java new file mode 100644 index 0000000..fcb9574 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/model/ReportRecord.java @@ -0,0 +1,29 @@ +package com.cobolmigration.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Model class replacing the COBOL report input record + * in report_writer/report_test.cbl (lines 24-28). + * Fields map to: + * f-test-student-id -> studentId + * f-test-student-name -> studentName + * f-test-major -> major + * f-test-num-courses -> numCourses + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReportRecord { + + private int studentId; + private String studentName; + private String major; + private int numCourses; +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/model/SearchItem.java b/spring-boot-migration/src/main/java/com/cobolmigration/model/SearchItem.java new file mode 100644 index 0000000..a37a82d --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/model/SearchItem.java @@ -0,0 +1,39 @@ +package com.cobolmigration.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * Model class replacing the COBOL ws-item-table structure in search/search.cbl (lines 17-33). + * Fields map to: + * ws-item-id-1 -> itemId1 + * ws-item-id-2 -> itemId2 + * ws-item-id-3 -> itemId3 + * ws-item-name -> itemName + * ws-item-date -> itemDate + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SearchItem implements Comparable { + + private int itemId1; + private int itemId2; + private int itemId3; + private String itemName; + private LocalDate itemDate; + + @Override + public int compareTo(SearchItem other) { + int cmp = Integer.compare(this.itemId1, other.itemId1); + if (cmp != 0) return cmp; + return Integer.compare(this.itemId2, other.itemId2); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/model/SerializationRecord.java b/spring-boot-migration/src/main/java/com/cobolmigration/model/SerializationRecord.java new file mode 100644 index 0000000..d854280 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/model/SerializationRecord.java @@ -0,0 +1,46 @@ +package com.cobolmigration.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Model class for XML/JSON serialization. + * Maps COBOL field names from xml_generate/xml_generate.cbl (lines 41-56) + * and json_generate/json_generate.cbl (lines 42-54): + * ws-record-name -> "name" + * ws-record-value -> "value" + * ws-record-blank -> (suppressed when empty, via @JsonInclude) + * ws-record-flag -> "enabled" (XML attribute in XML output) + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JacksonXmlRootElement(localName = "ws-record") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class SerializationRecord { + + @JsonProperty("name") + @JacksonXmlProperty(localName = "name") + private String name; + + @JsonProperty("value") + @JacksonXmlProperty(localName = "value") + private String value; + + @JsonProperty("blank") + @JacksonXmlProperty(localName = "blank") + private String blank; + + @JsonProperty("enabled") + @JacksonXmlProperty(isAttribute = true, localName = "enabled") + private String enabled; +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/repository/AccountRepository.java b/spring-boot-migration/src/main/java/com/cobolmigration/repository/AccountRepository.java new file mode 100644 index 0000000..bac83d2 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/repository/AccountRepository.java @@ -0,0 +1,41 @@ +package com.cobolmigration.repository; + +import com.cobolmigration.entity.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; + +/** + * Repository for Account entity. + * Replaces the COBOL CURSOR declarations in sql/sql_example.cbl (lines 118-153). + */ +@Repository +public interface AccountRepository extends JpaRepository { + + /** + * Retrieves all accounts ordered by ID ascending. + * Replaces DECLARE ACCOUNT-ALL-CUR CURSOR in sql_example.cbl (lines 119-125). + */ + List findAllByOrderByIdAsc(); + + /** + * Retrieves accounts filtered by enabled status. + * Replaces DECLARE ACCOUNT-DISABLED-CUR CURSOR in sql_example.cbl (lines 130-137). + */ + List findByIsEnabled(String isEnabled); + + /** + * Searches accounts by term across first_name, last_name, phone, and address fields. + * Replaces the query-accounts paragraph using ws-search-value in sql_example.cbl (lines 65-67, 141-153). + */ + @Query("SELECT a FROM Account a WHERE " + + "LOWER(a.firstName) LIKE LOWER(CONCAT('%', :term, '%')) OR " + + "LOWER(a.lastName) LIKE LOWER(CONCAT('%', :term, '%')) OR " + + "LOWER(a.phone) LIKE LOWER(CONCAT('%', :term, '%')) OR " + + "LOWER(a.address) LIKE LOWER(CONCAT('%', :term, '%')) " + + "ORDER BY a.id ASC") + List searchAccounts(@Param("term") String term); +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/service/AccountService.java b/spring-boot-migration/src/main/java/com/cobolmigration/service/AccountService.java new file mode 100644 index 0000000..35e162b --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/service/AccountService.java @@ -0,0 +1,59 @@ +package com.cobolmigration.service; + +import com.cobolmigration.entity.Account; +import com.cobolmigration.exception.DatabaseException; +import com.cobolmigration.repository.AccountRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Service layer for Account operations. + * Replaces the COBOL menu-driven operations in sql_example.cbl (lines 158-185). + */ +@Service +public class AccountService { + + private final AccountRepository accountRepository; + + public AccountService(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + } + + /** + * Retrieves all accounts ordered by ID. + * Replaces menu option 1: display-all-accounts paragraph (sql_example.cbl lines 201-246). + */ + public List getAllAccounts() { + try { + return accountRepository.findAllByOrderByIdAsc(); + } catch (Exception e) { + throw new DatabaseException("Error retrieving all accounts", e); + } + } + + /** + * Retrieves all disabled accounts (is_enabled = 'N'). + * Replaces menu option 2: display-disabled-accounts paragraph (sql_example.cbl lines 258-295). + */ + public List getDisabledAccounts() { + try { + return accountRepository.findByIsEnabled("N"); + } catch (Exception e) { + throw new DatabaseException("Error retrieving disabled accounts", e); + } + } + + /** + * Searches accounts by a search term across first_name, last_name, phone, and address. + * Replaces menu option 3: query-accounts paragraph (sql_example.cbl lines 318-394). + * The COBOL version uses LIKE with '%' wildcards; the JPQL query handles this. + */ + public List searchAccounts(String searchTerm) { + try { + return accountRepository.searchAccounts(searchTerm); + } catch (Exception e) { + throw new DatabaseException("Error searching accounts with term: " + searchTerm, e); + } + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/service/FileSortService.java b/spring-boot-migration/src/main/java/com/cobolmigration/service/FileSortService.java new file mode 100644 index 0000000..8ca07ab --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/service/FileSortService.java @@ -0,0 +1,120 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.CustomerRecord; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Service replacing COBOL SORT and MERGE operations in merge_sort/merge_sort_test.cbl. + * Uses CSV files instead of COBOL fixed-length record files. + */ +@Service +public class FileSortService { + + private static final String CSV_DELIMITER = ","; + + /** + * Merges two sorted lists of CustomerRecord by customerId ascending. + * Replaces MERGE fd-sorting-file in merge_sort_test.cbl (lines 107-110). + * + * @param file1 first sorted list + * @param file2 second sorted list + * @return merged and sorted list + */ + public List mergeFiles(List file1, List file2) { + List merged = new ArrayList<>(file1.size() + file2.size()); + int i = 0, j = 0; + + while (i < file1.size() && j < file2.size()) { + if (file1.get(i).getCustomerId() <= file2.get(j).getCustomerId()) { + merged.add(file1.get(i++)); + } else { + merged.add(file2.get(j++)); + } + } + + while (i < file1.size()) { + merged.add(file1.get(i++)); + } + while (j < file2.size()) { + merged.add(file2.get(j++)); + } + + return merged; + } + + /** + * Sorts a list of CustomerRecord by contractId in descending order. + * Replaces SORT fd-sorting-file ON DESCENDING KEY f-customer-contract-id + * in merge_sort_test.cbl (lines 142-145). + * + * @param records list to sort + * @return sorted list (new list, original is not modified) + */ + public List sortByContractIdDesc(List records) { + List sorted = new ArrayList<>(records); + sorted.sort(Comparator.comparingInt(CustomerRecord::getContractId).reversed()); + return sorted; + } + + /** + * Reads CustomerRecord entries from a CSV file. + * Replaces COBOL file READ operations on fd-test-file-1 and fd-test-file-2. + * CSV format: customerId,lastName,firstName,contractId,comment + * + * @param filePath path to the CSV file + * @return list of CustomerRecord + * @throws IOException if file cannot be read + */ + public List readFromCsv(Path filePath) throws IOException { + List records = new ArrayList<>(); + try (BufferedReader reader = Files.newBufferedReader(filePath)) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + String[] parts = line.split(CSV_DELIMITER, -1); + if (parts.length >= 5) { + records.add(CustomerRecord.builder() + .customerId(Integer.parseInt(parts[0].trim())) + .lastName(parts[1].trim()) + .firstName(parts[2].trim()) + .contractId(Integer.parseInt(parts[3].trim())) + .comment(parts[4].trim()) + .build()); + } + } + } + return records; + } + + /** + * Writes CustomerRecord entries to a CSV file. + * Replaces COBOL file WRITE operations. + * CSV format: customerId,lastName,firstName,contractId,comment + * + * @param filePath path to the output CSV file + * @param records list of CustomerRecord to write + * @throws IOException if file cannot be written + */ + public void writeToCsv(Path filePath, List records) throws IOException { + try (BufferedWriter writer = Files.newBufferedWriter(filePath)) { + for (CustomerRecord record : records) { + writer.write(String.format("%d,%s,%s,%d,%s", + record.getCustomerId(), + record.getLastName(), + record.getFirstName(), + record.getContractId(), + record.getComment())); + writer.newLine(); + } + } + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/service/JsonSerializationService.java b/spring-boot-migration/src/main/java/com/cobolmigration/service/JsonSerializationService.java new file mode 100644 index 0000000..b84df4d --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/service/JsonSerializationService.java @@ -0,0 +1,56 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.SerializationRecord; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.stereotype.Service; + +/** + * Service replacing COBOL JSON GENERATE in json_generate/json_generate.cbl. + * Uses Jackson ObjectMapper for serialization. + * + * COBOL mapping (lines 42-54): + * JSON GENERATE ws-json-output FROM ws-record + * NAME OF ws-record-name IS "name" + * NAME OF ws-record-value IS "value" + * NAME OF ws-record-flag IS "enabled" + */ +@Service +public class JsonSerializationService { + + private final ObjectMapper objectMapper; + + public JsonSerializationService() { + this.objectMapper = new ObjectMapper(); + this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + } + + /** + * Serializes a SerializationRecord to JSON string. + * Replaces JSON GENERATE statement in json_generate.cbl (lines 42-54). + * + * @param record the record to serialize + * @return JSON string representation + * @throws JsonProcessingException if serialization fails + */ + public String toJson(SerializationRecord record) throws JsonProcessingException { + return objectMapper.writeValueAsString(record); + } + + /** + * Creates a sample record with test data matching json_generate.cbl (lines 37-40). + * COBOL sets: ws-record-name = "Test Name", ws-record-value = "Test Value", + * ws-record-flag = "true" (enabled), ws-record-blank = spaces. + * + * @return sample SerializationRecord + */ + public SerializationRecord createSampleRecord() { + return SerializationRecord.builder() + .name("Test Name") + .value("Test Value") + .blank(null) + .enabled("true") + .build(); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/service/ReportService.java b/spring-boot-migration/src/main/java/com/cobolmigration/service/ReportService.java new file mode 100644 index 0000000..953fc87 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/service/ReportService.java @@ -0,0 +1,147 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.ReportRecord; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Service replacing the COBOL Report Writer in report_writer/report_test.cbl (lines 41-63). + * Generates formatted text reports using a simple template approach. + * + * COBOL RD configuration mapped: + * page limit 66 -> maxLinesPerPage = 66 + * heading is 1 -> header starts at line 1 + * first detail 6 -> detail records start at line 6 + * last detail 42 -> last detail line on a page + * footing 52 -> footer area starts at line 52 + */ +@Service +public class ReportService { + + private static final int PAGE_LIMIT = 66; + private static final int FIRST_DETAIL_LINE = 6; + private static final int LAST_DETAIL_LINE = 42; + private static final String LINE_SEPARATOR = System.lineSeparator(); + + /** + * Generates an HTML report from a list of ReportRecord entries. + * Replaces the COBOL INITIATE/GENERATE/TERMINATE report writer pattern + * in report_test.cbl (lines 73-88). + * + * @param records list of report records (replaces reading from fd-test-input-file) + * @return HTML string of the formatted report + */ + public String generateHtmlReport(List records) { + StringBuilder html = new StringBuilder(); + html.append("").append(LINE_SEPARATOR); + html.append("Customer Order Report").append(LINE_SEPARATOR); + html.append("").append(LINE_SEPARATOR); + + int totalPages = calculateTotalPages(records.size()); + int recordIndex = 0; + + for (int page = 1; page <= totalPages; page++) { + html.append("
").append(LINE_SEPARATOR); + html.append(" ") + .append(LINE_SEPARATOR); + html.append("
PAGE ").append(page).append("
") + .append(LINE_SEPARATOR); + html.append(" ").append(LINE_SEPARATOR); + html.append(" ") + .append(LINE_SEPARATOR); + + int detailsOnPage = LAST_DETAIL_LINE - FIRST_DETAIL_LINE + 1; + for (int i = 0; i < detailsOnPage && recordIndex < records.size(); i++) { + ReportRecord record = records.get(recordIndex++); + html.append(" "); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + html.append("").append(LINE_SEPARATOR); + } + + html.append("
Student IDStudent NameMajorCourses
").append(String.format("%06d", record.getStudentId())).append("").append(escapeHtml(record.getStudentName())).append("").append(escapeHtml(record.getMajor())).append("").append(String.format("%02d", record.getNumCourses())).append("
").append(LINE_SEPARATOR); + html.append("
").append(LINE_SEPARATOR); + + if (page < totalPages) { + html.append("
").append(LINE_SEPARATOR); + } + } + + html.append(""); + return html.toString(); + } + + /** + * Generates a plain text report in a format similar to the COBOL report writer output. + * + * @param records list of report records + * @return plain text report string + */ + public String generateTextReport(List records) { + StringBuilder report = new StringBuilder(); + int totalPages = calculateTotalPages(records.size()); + int recordIndex = 0; + + for (int page = 1; page <= totalPages; page++) { + // Header (line 1) - matches report-header in report_test.cbl lines 48-57 + report.append(padRight("", 43)).append("Customer Order Report").append(LINE_SEPARATOR); + report.append(padRight("", 99)).append("PAGE").append(String.format("%4d", page)) + .append(LINE_SEPARATOR); + + // Blank lines before first detail (lines 3-5) + for (int line = 3; line < FIRST_DETAIL_LINE; line++) { + report.append(LINE_SEPARATOR); + } + + // Detail lines (starting at line 6) - matches report-line in report_test.cbl lines 59-63 + int detailsOnPage = LAST_DETAIL_LINE - FIRST_DETAIL_LINE + 1; + for (int i = 0; i < detailsOnPage && recordIndex < records.size(); i++) { + ReportRecord record = records.get(recordIndex++); + report.append(" ") + .append(String.format("%06d", record.getStudentId())) + .append(" ") + .append(padRight(record.getStudentName(), 20)) + .append(" ") + .append(padRight(record.getMajor(), 3)) + .append(" ") + .append(String.format("%02d", record.getNumCourses())) + .append(LINE_SEPARATOR); + } + + // Fill remaining lines to reach page limit + if (page < totalPages) { + report.append(LINE_SEPARATOR); + } + } + + return report.toString(); + } + + private int calculateTotalPages(int totalRecords) { + int detailsPerPage = LAST_DETAIL_LINE - FIRST_DETAIL_LINE + 1; + return Math.max(1, (int) Math.ceil((double) totalRecords / detailsPerPage)); + } + + private static String padRight(String value, int length) { + if (value == null) value = ""; + return String.format("%-" + length + "s", value); + } + + private static String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/service/SearchService.java b/spring-boot-migration/src/main/java/com/cobolmigration/service/SearchService.java new file mode 100644 index 0000000..4dc2fe4 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/service/SearchService.java @@ -0,0 +1,87 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.SearchItem; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Service replacing the COBOL SEARCH/SEARCH ALL operations in search/search.cbl. + * Implements binary search (SEARCH ALL) and sequential search (SEARCH) patterns. + */ +@Service +public class SearchService { + + /** + * Performs a binary search for an item by itemId1. + * Replaces SEARCH ALL ws-item-table in search.cbl (lines 61-66). + * The list must be sorted by itemId1 in ascending order (matching the + * COBOL ascending key specification on lines 18-19). + * + * @param items sorted list of SearchItem + * @param id the itemId1 to search for + * @return Optional containing the found item, or empty if not found + */ + public Optional searchById(List items, int id) { + int index = Collections.binarySearch( + items, + SearchItem.builder().itemId1(id).itemId2(0).build(), + (a, b) -> Integer.compare(a.getItemId1(), b.getItemId1()) + ); + if (index >= 0) { + return Optional.of(items.get(index)); + } + return Optional.empty(); + } + + /** + * Performs a binary search matching all three IDs. + * Replaces SEARCH ALL with compound WHEN condition in search.cbl (lines 82-89). + * + * @param items sorted list of SearchItem + * @param id1 itemId1 to match + * @param id2 itemId2 to match + * @param id3 itemId3 to match + * @return Optional containing the found item, or empty if not found + */ + public Optional searchByAllIds(List items, int id1, int id2, int id3) { + return items.stream() + .filter(item -> item.getItemId1() == id1 + && item.getItemId2() == id2 + && item.getItemId3() == id3) + .findFirst(); + } + + /** + * Sets up test data mirroring the setup-test-data paragraph in search.cbl (lines 127-156). + * + * @return list of SearchItem with test data + */ + public List setupTestData() { + List items = new ArrayList<>(); + + items.add(SearchItem.builder() + .itemId1(1).itemId2(101).itemId3(500) + .itemName("test item 1") + .itemDate(LocalDate.of(2021, 1, 1)) + .build()); + + items.add(SearchItem.builder() + .itemId1(2).itemId2(102).itemId3(499) + .itemName("test item 2") + .itemDate(LocalDate.of(2021, 2, 2)) + .build()); + + items.add(SearchItem.builder() + .itemId1(3).itemId2(103).itemId3(498) + .itemName("test item 3") + .itemDate(LocalDate.of(2021, 3, 3)) + .build()); + + return items; + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/service/SubProgramService.java b/spring-boot-migration/src/main/java/com/cobolmigration/service/SubProgramService.java new file mode 100644 index 0000000..a153210 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/service/SubProgramService.java @@ -0,0 +1,130 @@ +package com.cobolmigration.service; + +import org.springframework.stereotype.Service; + +/** + * Service replacing the COBOL subprogram call pattern in sub_program/. + * Demonstrates the migration of COBOL CALL BY CONTENT, CALL BY REFERENCE, + * and CANCEL patterns to Spring service method calls. + * + * COBOL concepts mapped: + * WORKING-STORAGE (persistent state) -> Spring @Service singleton bean fields + * LOCAL-STORAGE (per-call fresh state) -> method-local variables + * LINKAGE SECTION (parameters) -> method parameters + * CALL BY CONTENT (immutable) -> parameters passed as copies/immutable + * CALL BY REFERENCE (mutable) -> mutable wrapper objects or return values + * CANCEL (reset state) -> reset() method + * + * @see sub_program/main_app.cbl (lines 34-38, 47-49, 54-55) + * @see sub_program/sub.cbl + */ +@Service +public class SubProgramService { + + // Replaces WORKING-STORAGE variables in sub.cbl (lines 18-19). + // These persist across method calls until reset() is called. + private String wsTestItem1 = ""; + private String wsTestItem2 = ""; + + /** + * Processes items by content (immutable parameters). + * Replaces CALL "sub-app" USING BY CONTENT in main_app.cbl (lines 35-37). + * The original parameters are NOT modified - copies are used internally. + * + * @param item1 first item value (treated as immutable copy) + * @param item2 second item value (treated as immutable copy) + * @return result containing the working-storage state + */ + public SubProgramResult callByContent(String item1, String item2) { + // LOCAL-STORAGE equivalent: fresh variables per call (sub.cbl lines 24-25) + String lsTestItem1 = ""; + String lsTestItem2 = ""; + + // Move linkage values to working-storage and local-storage (sub.cbl lines 45-48) + wsTestItem1 = item1; + wsTestItem2 = item2; + lsTestItem1 = item1; + lsTestItem2 = item2; + + return SubProgramResult.builder() + .wsItem1(wsTestItem1) + .wsItem2(wsTestItem2) + .lsItem1(lsTestItem1) + .lsItem2(lsTestItem2) + .outputItem1(item1) + .outputItem2(item2) + .build(); + } + + /** + * Processes items by reference (mutable parameters). + * Replaces CALL "sub-app" USING (by reference) in main_app.cbl (lines 47-48). + * The parameters can be modified and the caller sees the changes via the return value. + * + * @param item1 first item value (will be replaced in output) + * @param item2 second item value (will be replaced in output) + * @return result with modified output values (simulating by-reference modification) + */ + public SubProgramResult callByReference(String item1, String item2) { + // LOCAL-STORAGE equivalent: fresh per call + String lsTestItem1 = ""; + String lsTestItem2 = ""; + + // Move linkage values to working-storage and local-storage + wsTestItem1 = item1; + wsTestItem2 = item2; + lsTestItem1 = item1; + lsTestItem2 = item2; + + // Simulate sub.cbl lines 52-53: setting input variables to new values + String modifiedItem1 = "replace1"; + String modifiedItem2 = "replace2"; + + return SubProgramResult.builder() + .wsItem1(wsTestItem1) + .wsItem2(wsTestItem2) + .lsItem1(lsTestItem1) + .lsItem2(lsTestItem2) + .outputItem1(modifiedItem1) + .outputItem2(modifiedItem2) + .build(); + } + + /** + * Resets the service's persistent state. + * Replaces CANCEL "sub-app" in main_app.cbl (lines 54-55). + * After CANCEL in COBOL, the next call to the subprogram starts with + * fresh WORKING-STORAGE values. + */ + public void reset() { + wsTestItem1 = ""; + wsTestItem2 = ""; + } + + /** + * Returns current working-storage state for inspection. + * + * @return current values of persistent state fields + */ + public String getWsTestItem1() { + return wsTestItem1; + } + + public String getWsTestItem2() { + return wsTestItem2; + } + + /** + * Result object replacing COBOL linkage section output and working-storage inspection. + */ + @lombok.Builder + @lombok.Getter + public static class SubProgramResult { + private final String wsItem1; + private final String wsItem2; + private final String lsItem1; + private final String lsItem2; + private final String outputItem1; + private final String outputItem2; + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/service/XmlSerializationService.java b/spring-boot-migration/src/main/java/com/cobolmigration/service/XmlSerializationService.java new file mode 100644 index 0000000..d97c2c7 --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/service/XmlSerializationService.java @@ -0,0 +1,58 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.SerializationRecord; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; +import org.springframework.stereotype.Service; + +/** + * Service replacing COBOL XML GENERATE in xml_generate/xml_generate.cbl. + * Uses Jackson XmlMapper for serialization. + * + * COBOL mapping (lines 41-56): + * XML GENERATE ws-xml-output FROM ws-record + * NAME OF ws-record-name IS "name" + * NAME OF ws-record-value IS "value" + * NAME OF ws-record-flag IS "enabled" + * TYPE OF ws-record-flag IS ATTRIBUTE + * SUPPRESS WHEN SPACES + */ +@Service +public class XmlSerializationService { + + private final XmlMapper xmlMapper; + + public XmlSerializationService() { + this.xmlMapper = new XmlMapper(); + this.xmlMapper.configure(ToXmlGenerator.Feature.WRITE_XML_DECLARATION, true); + } + + /** + * Serializes a SerializationRecord to XML string. + * Replaces XML GENERATE statement in xml_generate.cbl (lines 41-56). + * + * @param record the record to serialize + * @return XML string representation + * @throws JsonProcessingException if serialization fails + */ + public String toXml(SerializationRecord record) throws JsonProcessingException { + return xmlMapper.writeValueAsString(record); + } + + /** + * Creates a sample record with test data matching xml_generate.cbl (lines 37-39). + * COBOL sets: ws-record-name = "Test Name", ws-record-value = "Test Value", + * ws-record-flag = "true" (enabled), ws-record-blank = spaces (suppressed). + * + * @return sample SerializationRecord + */ + public SerializationRecord createSampleRecord() { + return SerializationRecord.builder() + .name("Test Name") + .value("Test Value") + .blank(null) + .enabled("true") + .build(); + } +} diff --git a/spring-boot-migration/src/main/java/com/cobolmigration/util/StringUtils.java b/spring-boot-migration/src/main/java/com/cobolmigration/util/StringUtils.java new file mode 100644 index 0000000..508ae0b --- /dev/null +++ b/spring-boot-migration/src/main/java/com/cobolmigration/util/StringUtils.java @@ -0,0 +1,91 @@ +package com.cobolmigration.util; + +import java.math.BigDecimal; + +/** + * String utility methods replacing COBOL intrinsic functions and statements. + * Covers: TRIM (trim/), UNSTRING (unstring/), IS NUMERIC (is_numeric/), NUMVAL (numval_test/). + */ +public final class StringUtils { + + private StringUtils() { + // Utility class - prevent instantiation + } + + /** + * Trims leading and trailing whitespace from a string. + * Replaces the COBOL TRIM function demonstrated in trim/trim.cbl. + * COBOL TRIM removes spaces; Java's strip() removes all Unicode whitespace. + * + * @param value the string to trim + * @return trimmed string, or empty string if input is null + */ + public static String trim(String value) { + if (value == null) { + return ""; + } + return value.strip(); + } + + /** + * Splits a source string by a delimiter, similar to COBOL UNSTRING. + * Replaces the COBOL UNSTRING statement demonstrated in unstring/unstring.cbl. + * + * In COBOL, UNSTRING splits a source field into multiple destination fields + * based on a delimiter. This method provides the same capability using + * Java's String.split(). + * + * @param source the source string to split + * @param delimiter the delimiter to split on (supports regex) + * @return array of split parts, or empty array if source is null + */ + public static String[] unstring(String source, String delimiter) { + if (source == null || delimiter == null) { + return new String[0]; + } + return source.split(java.util.regex.Pattern.quote(delimiter), -1); + } + + /** + * Checks if a string value represents a numeric value. + * Replaces the COBOL IS NUMERIC test demonstrated in is_numeric/is_numeric.cbl. + * + * In COBOL, IS NUMERIC checks if an alphanumeric field contains only digits. + * Spaces cause the test to fail in COBOL (lines 32-36). After TRIM, contiguous + * digits pass the test (lines 71-75). This implementation trims before checking. + * + * @param value the string to check + * @return true if the trimmed value is numeric, false otherwise + */ + public static boolean isNumeric(String value) { + if (value == null || value.isBlank()) { + return false; + } + String trimmed = value.strip(); + try { + new BigDecimal(trimmed); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Converts a string representation of a number to a BigDecimal. + * Replaces the COBOL NUMVAL function demonstrated in numval_test/numval_test.cbl. + * + * COBOL NUMVAL converts a PIC X field to a numeric value for arithmetic + * (numval_test.cbl line 28: compute ws-total = function numval(ws-x-val) + ws-9-val). + * + * @param value the string to convert + * @return BigDecimal representation of the value + * @throws NumberFormatException if the value cannot be parsed as a number + */ + public static BigDecimal numericValue(String value) { + if (value == null || value.isBlank()) { + throw new NumberFormatException("Cannot convert null or blank value to numeric"); + } + String cleaned = value.strip().replaceAll("[,$]", ""); + return new BigDecimal(cleaned); + } +} diff --git a/spring-boot-migration/src/main/resources/application.yml b/spring-boot-migration/src/main/resources/application.yml new file mode 100644 index 0000000..ecc7bf4 --- /dev/null +++ b/spring-boot-migration/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/cobol_db_example + username: postgres + password: password + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + sql: + init: + mode: never + +server: + port: 8080 diff --git a/spring-boot-migration/src/main/resources/data.sql b/spring-boot-migration/src/main/resources/data.sql new file mode 100644 index 0000000..eecaf11 --- /dev/null +++ b/spring-boot-migration/src/main/resources/data.sql @@ -0,0 +1,35 @@ +-- Sample data for the accounts table. +-- Derived from sql/create_test_db.sql (lines 22-53). + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('John', 'Tester', '15555550100', '123 Fake St, Nowhere', 'Y', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Mike', 'Tester1', '15555550121', '122 Real St, Nowhere', 'Y', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Mary', 'Tester2', '15555550132', '121 ABC St, Nowhere', 'Y', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Jack', 'Tester3', '15555550143', '120 Rock St, Nowhere', 'Y', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Bob', 'Tester4', '15555550154', '119 Truck St, Nowhere', 'N', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Paula', 'Tester5', '1555550165', '118 Car St, Nowhere', 'N', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('James', 'Tester6', '1555550176', '117 Land St, Nowhere', 'Y', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Jane', 'Tester7', '1555550187', '116 Sea St, Nowhere', 'Y', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Bill', 'Tester8', '1555550198', '115 Dock St, Nowhere', 'N', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Lucy', 'Tester9', '1555550209', '114 Beach St, Nowhere', 'Y', NOW(), NOW()); + +INSERT INTO accounts (first_name, last_name, phone, address, is_enabled, create_dt, mod_dt) +VALUES ('Richard', 'Tester10', '1555550210', '113 Water St, Nowhere', 'Y', NOW(), NOW()); diff --git a/spring-boot-migration/src/main/resources/schema.sql b/spring-boot-migration/src/main/resources/schema.sql new file mode 100644 index 0000000..534aa40 --- /dev/null +++ b/spring-boot-migration/src/main/resources/schema.sql @@ -0,0 +1,14 @@ +-- Schema initialization for the COBOL migration Spring Boot application. +-- Derived from sql/create_test_db.sql (lines 8-18). + +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-migration/src/test/java/com/cobolmigration/controller/AccountControllerIntegrationTest.java b/spring-boot-migration/src/test/java/com/cobolmigration/controller/AccountControllerIntegrationTest.java new file mode 100644 index 0000000..208d703 --- /dev/null +++ b/spring-boot-migration/src/test/java/com/cobolmigration/controller/AccountControllerIntegrationTest.java @@ -0,0 +1,142 @@ +package com.cobolmigration.controller; + +import com.cobolmigration.entity.Account; +import com.cobolmigration.repository.AccountRepository; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +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; + +/** + * Integration tests for AccountController. + * Uses @SpringBootTest with H2 in PostgreSQL compatibility mode. + * Tests all REST endpoints return correct data, verifying the same + * SQL queries produce identical results to the COBOL embedded SQL. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AccountControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AccountRepository accountRepository; + + @BeforeEach + void setUp() { + accountRepository.deleteAll(); + + LocalDateTime now = LocalDateTime.now(); + + // Insert test data matching sql/create_test_db.sql (lines 22-53) + accountRepository.save(Account.builder() + .firstName("John").lastName("Tester") + .phone("15555550100").address("123 Fake St, Nowhere") + .isEnabled("Y").createDt(now).modDt(now).build()); + + accountRepository.save(Account.builder() + .firstName("Mike").lastName("Tester1") + .phone("15555550121").address("122 Real St, Nowhere") + .isEnabled("Y").createDt(now).modDt(now).build()); + + accountRepository.save(Account.builder() + .firstName("Bob").lastName("Tester4") + .phone("15555550154").address("119 Truck St, Nowhere") + .isEnabled("N").createDt(now).modDt(now).build()); + + accountRepository.save(Account.builder() + .firstName("Paula").lastName("Tester5") + .phone("1555550165").address("118 Car St, Nowhere") + .isEnabled("N").createDt(now).modDt(now).build()); + + accountRepository.save(Account.builder() + .firstName("Lucy").lastName("Tester9") + .phone("1555550209").address("114 Beach St, Nowhere") + .isEnabled("Y").createDt(now).modDt(now).build()); + } + + @Test + void getAllAccounts_returnsAllOrderedById() throws Exception { + // Replaces menu option 1 (sql_example.cbl lines 158-185) + mockMvc.perform(get("/api/accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(5))) + .andExpect(jsonPath("$[0].firstName", is("John"))) + .andExpect(jsonPath("$[1].firstName", is("Mike"))) + .andExpect(jsonPath("$[4].firstName", is("Lucy"))); + } + + @Test + void getDisabledAccounts_returnsOnlyDisabled() throws Exception { + // Replaces menu option 2 (sql_example.cbl lines 258-295) + mockMvc.perform(get("/api/accounts").param("enabled", "false")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].firstName", is("Bob"))) + .andExpect(jsonPath("$[0].isEnabled", is("N"))) + .andExpect(jsonPath("$[1].firstName", is("Paula"))) + .andExpect(jsonPath("$[1].isEnabled", is("N"))); + } + + @Test + void searchAccounts_byFirstName() throws Exception { + // Replaces menu option 3 (sql_example.cbl lines 318-394) + // COBOL uses LIKE '%search_term%' across first_name, last_name, phone, address + mockMvc.perform(get("/api/accounts").param("search", "John")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("John"))); + } + + @Test + void searchAccounts_byLastName() throws Exception { + mockMvc.perform(get("/api/accounts").param("search", "Tester4")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].lastName", is("Tester4"))); + } + + @Test + void searchAccounts_byAddress() throws Exception { + mockMvc.perform(get("/api/accounts").param("search", "Beach")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("Lucy"))); + } + + @Test + void searchAccounts_byPhone() throws Exception { + mockMvc.perform(get("/api/accounts").param("search", "15555550100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("John"))); + } + + @Test + void searchAccounts_noResults() throws Exception { + mockMvc.perform(get("/api/accounts").param("search", "NonExistent")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } + + @Test + void searchAccounts_caseInsensitive() throws Exception { + mockMvc.perform(get("/api/accounts").param("search", "john")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName", is("John"))); + } +} diff --git a/spring-boot-migration/src/test/java/com/cobolmigration/service/AccountServiceTest.java b/spring-boot-migration/src/test/java/com/cobolmigration/service/AccountServiceTest.java new file mode 100644 index 0000000..c2c626d --- /dev/null +++ b/spring-boot-migration/src/test/java/com/cobolmigration/service/AccountServiceTest.java @@ -0,0 +1,143 @@ +package com.cobolmigration.service; + +import com.cobolmigration.entity.Account; +import com.cobolmigration.exception.DatabaseException; +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.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * Unit tests for AccountService. + * Tests all account queries with mock repository. + */ +@ExtendWith(MockitoExtension.class) +class AccountServiceTest { + + @Mock + private AccountRepository accountRepository; + + @InjectMocks + private AccountService accountService; + + private List allAccounts; + private List disabledAccounts; + + @BeforeEach + void setUp() { + LocalDateTime now = LocalDateTime.now(); + + allAccounts = Arrays.asList( + Account.builder().id(1L).firstName("John").lastName("Tester") + .phone("15555550100").address("123 Fake St, Nowhere") + .isEnabled("Y").createDt(now).modDt(now).build(), + Account.builder().id(2L).firstName("Mike").lastName("Tester1") + .phone("15555550121").address("122 Real St, Nowhere") + .isEnabled("Y").createDt(now).modDt(now).build(), + Account.builder().id(5L).firstName("Bob").lastName("Tester4") + .phone("15555550154").address("119 Truck St, Nowhere") + .isEnabled("N").createDt(now).modDt(now).build() + ); + + disabledAccounts = Collections.singletonList( + Account.builder().id(5L).firstName("Bob").lastName("Tester4") + .phone("15555550154").address("119 Truck St, Nowhere") + .isEnabled("N").createDt(now).modDt(now).build() + ); + } + + @Test + void getAllAccounts_returnsAllAccounts() { + when(accountRepository.findAllByOrderByIdAsc()).thenReturn(allAccounts); + + List result = accountService.getAllAccounts(); + + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("John", result.get(0).getFirstName()); + assertEquals("Mike", result.get(1).getFirstName()); + assertEquals("Bob", result.get(2).getFirstName()); + } + + @Test + void getAllAccounts_returnsEmptyList() { + when(accountRepository.findAllByOrderByIdAsc()).thenReturn(Collections.emptyList()); + + List result = accountService.getAllAccounts(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void getDisabledAccounts_returnsOnlyDisabled() { + when(accountRepository.findByIsEnabled("N")).thenReturn(disabledAccounts); + + List result = accountService.getDisabledAccounts(); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("Bob", result.get(0).getFirstName()); + assertEquals("N", result.get(0).getIsEnabled()); + } + + @Test + void searchAccounts_returnMatchingAccounts() { + List searchResults = Collections.singletonList(allAccounts.get(0)); + when(accountRepository.searchAccounts("John")).thenReturn(searchResults); + + List result = accountService.searchAccounts("John"); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("John", result.get(0).getFirstName()); + } + + @Test + void searchAccounts_returnsEmptyForNoMatch() { + when(accountRepository.searchAccounts("NonExistent")).thenReturn(Collections.emptyList()); + + List result = accountService.searchAccounts("NonExistent"); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void getAllAccounts_throwsDatabaseExceptionOnError() { + when(accountRepository.findAllByOrderByIdAsc()) + .thenThrow(new RuntimeException("DB connection failed")); + + assertThrows(DatabaseException.class, () -> accountService.getAllAccounts()); + } + + @Test + void getDisabledAccounts_throwsDatabaseExceptionOnError() { + when(accountRepository.findByIsEnabled("N")) + .thenThrow(new RuntimeException("DB error")); + + assertThrows(DatabaseException.class, () -> accountService.getDisabledAccounts()); + } + + @Test + void searchAccounts_throwsDatabaseExceptionOnError() { + when(accountRepository.searchAccounts("test")) + .thenThrow(new RuntimeException("Query failed")); + + assertThrows(DatabaseException.class, () -> accountService.searchAccounts("test")); + } +} diff --git a/spring-boot-migration/src/test/java/com/cobolmigration/service/FileSortServiceTest.java b/spring-boot-migration/src/test/java/com/cobolmigration/service/FileSortServiceTest.java new file mode 100644 index 0000000..5ff73d9 --- /dev/null +++ b/spring-boot-migration/src/test/java/com/cobolmigration/service/FileSortServiceTest.java @@ -0,0 +1,168 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.CustomerRecord; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for FileSortService. + * Verifies merge and sort operations produce the same ordering as COBOL SORT/MERGE + * in merge_sort/merge_sort_test.cbl. + */ +class FileSortServiceTest { + + private FileSortService fileSortService; + + // Test data matching merge_sort_test.cbl create-test-data paragraph (lines 173-338) + private List file1Records; // "East" file + private List file2Records; // "West" file + + @BeforeEach + void setUp() { + fileSortService = new FileSortService(); + + // fd-test-file-1 records (lines 185-259) + file1Records = Arrays.asList( + CustomerRecord.builder().customerId(1).lastName("last-1").firstName("first-1") + .contractId(5423).comment("comment-1").build(), + CustomerRecord.builder().customerId(5).lastName("last-5").firstName("first-5") + .contractId(12323).comment("comment-5").build(), + CustomerRecord.builder().customerId(10).lastName("last-10").firstName("first-10") + .contractId(653).comment("comment-10").build(), + CustomerRecord.builder().customerId(50).lastName("last-50").firstName("first-50") + .contractId(5050).comment("comment-50").build(), + CustomerRecord.builder().customerId(25).lastName("last-25").firstName("first-25") + .contractId(7725).comment("comment-25").build(), + CustomerRecord.builder().customerId(75).lastName("last-75").firstName("first-75") + .contractId(1175).comment("comment-75").build() + ); + + // fd-test-file-2 records (lines 272-334) + file2Records = Arrays.asList( + CustomerRecord.builder().customerId(999).lastName("last-999").firstName("first-999") + .contractId(1610).comment("comment-99").build(), + CustomerRecord.builder().customerId(3).lastName("last-03").firstName("first-03") + .contractId(3331).comment("comment-03").build(), + CustomerRecord.builder().customerId(30).lastName("last-30").firstName("first-30") + .contractId(8765).comment("comment-30").build(), + CustomerRecord.builder().customerId(85).lastName("last-85").firstName("first-85") + .contractId(4567).comment("comment-85").build(), + CustomerRecord.builder().customerId(24).lastName("last-24").firstName("first-24") + .contractId(247).comment("comment-24").build() + ); + } + + @Test + void mergeFiles_mergesByCustomerIdAscending() { + // Pre-sort each file by customerId (COBOL MERGE does this) + List sorted1 = file1Records.stream() + .sorted((a, b) -> Integer.compare(a.getCustomerId(), b.getCustomerId())) + .toList(); + List sorted2 = file2Records.stream() + .sorted((a, b) -> Integer.compare(a.getCustomerId(), b.getCustomerId())) + .toList(); + + List merged = fileSortService.mergeFiles(sorted1, sorted2); + + assertEquals(11, merged.size()); + + // Verify ascending order by customerId + for (int i = 1; i < merged.size(); i++) { + assertTrue(merged.get(i).getCustomerId() >= merged.get(i - 1).getCustomerId(), + "Records should be in ascending customerId order"); + } + + // Verify expected order: 1, 3, 5, 10, 24, 25, 30, 50, 75, 85, 999 + assertEquals(1, merged.get(0).getCustomerId()); + assertEquals(3, merged.get(1).getCustomerId()); + assertEquals(5, merged.get(2).getCustomerId()); + assertEquals(10, merged.get(3).getCustomerId()); + assertEquals(24, merged.get(4).getCustomerId()); + assertEquals(25, merged.get(5).getCustomerId()); + assertEquals(30, merged.get(6).getCustomerId()); + assertEquals(50, merged.get(7).getCustomerId()); + assertEquals(75, merged.get(8).getCustomerId()); + assertEquals(85, merged.get(9).getCustomerId()); + assertEquals(999, merged.get(10).getCustomerId()); + } + + @Test + void sortByContractIdDesc_sortsDescending() { + // Combine all records (simulates merged output) + List allRecords = new java.util.ArrayList<>(file1Records); + allRecords.addAll(file2Records); + + List sorted = fileSortService.sortByContractIdDesc(allRecords); + + assertEquals(11, sorted.size()); + + // Verify descending order by contractId + for (int i = 1; i < sorted.size(); i++) { + assertTrue(sorted.get(i).getContractId() <= sorted.get(i - 1).getContractId(), + "Records should be in descending contractId order"); + } + + // Highest contractId should be first: 12323 (customer 5) + assertEquals(12323, sorted.get(0).getContractId()); + // Lowest contractId should be last: 247 (customer 24) + assertEquals(247, sorted.get(sorted.size() - 1).getContractId()); + } + + @Test + void mergeFiles_handlesEmptyFirstList() { + List result = fileSortService.mergeFiles( + List.of(), + List.of(CustomerRecord.builder().customerId(1).lastName("a").firstName("b") + .contractId(1).comment("c").build()) + ); + + assertEquals(1, result.size()); + } + + @Test + void mergeFiles_handlesEmptySecondList() { + List result = fileSortService.mergeFiles( + List.of(CustomerRecord.builder().customerId(1).lastName("a").firstName("b") + .contractId(1).comment("c").build()), + List.of() + ); + + assertEquals(1, result.size()); + } + + @Test + void mergeFiles_handlesBothEmpty() { + List result = fileSortService.mergeFiles(List.of(), List.of()); + + assertTrue(result.isEmpty()); + } + + @Test + void csvReadWrite_roundTrip(@TempDir Path tempDir) throws IOException { + Path csvFile = tempDir.resolve("test.csv"); + List records = List.of( + CustomerRecord.builder().customerId(1).lastName("last-1").firstName("first-1") + .contractId(100).comment("test").build(), + CustomerRecord.builder().customerId(2).lastName("last-2").firstName("first-2") + .contractId(200).comment("test2").build() + ); + + fileSortService.writeToCsv(csvFile, records); + List readBack = fileSortService.readFromCsv(csvFile); + + assertEquals(2, readBack.size()); + assertEquals(1, readBack.get(0).getCustomerId()); + assertEquals("last-1", readBack.get(0).getLastName()); + assertEquals(2, readBack.get(1).getCustomerId()); + assertEquals(200, readBack.get(1).getContractId()); + } +} diff --git a/spring-boot-migration/src/test/java/com/cobolmigration/service/JsonSerializationServiceTest.java b/spring-boot-migration/src/test/java/com/cobolmigration/service/JsonSerializationServiceTest.java new file mode 100644 index 0000000..d6eefba --- /dev/null +++ b/spring-boot-migration/src/test/java/com/cobolmigration/service/JsonSerializationServiceTest.java @@ -0,0 +1,97 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.SerializationRecord; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for JsonSerializationService. + * Verifies output matches the expected JSON structure from json_generate/json_generate.cbl. + */ +class JsonSerializationServiceTest { + + private JsonSerializationService jsonService; + + @BeforeEach + void setUp() { + jsonService = new JsonSerializationService(); + } + + @Test + void toJson_generatesValidJson() throws JsonProcessingException { + SerializationRecord record = jsonService.createSampleRecord(); + + String json = jsonService.toJson(record); + + assertNotNull(json); + assertTrue(json.contains("{")); + assertTrue(json.contains("}")); + } + + @Test + void toJson_containsNamedFields() throws JsonProcessingException { + SerializationRecord record = jsonService.createSampleRecord(); + + String json = jsonService.toJson(record); + + // Verify field name mappings match json_generate.cbl lines 46-48 + assertTrue(json.contains("\"name\""), "Should contain 'name' field"); + assertTrue(json.contains("\"value\""), "Should contain 'value' field"); + assertTrue(json.contains("\"enabled\""), "Should contain 'enabled' field"); + } + + @Test + void toJson_containsCorrectValues() throws JsonProcessingException { + SerializationRecord record = jsonService.createSampleRecord(); + + String json = jsonService.toJson(record); + + // Values should match json_generate.cbl lines 37-40 + assertTrue(json.contains("Test Name")); + assertTrue(json.contains("Test Value")); + assertTrue(json.contains("true")); + } + + @Test + void toJson_omitsNullBlankField() throws JsonProcessingException { + // Empty/null fields should be omitted per @JsonInclude(NON_EMPTY) + SerializationRecord record = jsonService.createSampleRecord(); + + String json = jsonService.toJson(record); + + assertFalse(json.contains("\"blank\""), + "Null/empty fields should be omitted"); + } + + @Test + void toJson_includesNonEmptyBlankField() throws JsonProcessingException { + SerializationRecord record = SerializationRecord.builder() + .name("Test") + .value("Val") + .blank("Has Content") + .enabled("false") + .build(); + + String json = jsonService.toJson(record); + + assertTrue(json.contains("\"blank\"")); + assertTrue(json.contains("Has Content")); + } + + @Test + void createSampleRecord_matchesCobolTestData() { + // Verify sample data matches json_generate.cbl lines 37-40 + SerializationRecord record = jsonService.createSampleRecord(); + + assertNotNull(record); + assertTrue("Test Name".equals(record.getName())); + assertTrue("Test Value".equals(record.getValue())); + assertTrue(record.getBlank() == null || record.getBlank().isEmpty()); + assertTrue("true".equals(record.getEnabled())); + } +} diff --git a/spring-boot-migration/src/test/java/com/cobolmigration/service/SearchServiceTest.java b/spring-boot-migration/src/test/java/com/cobolmigration/service/SearchServiceTest.java new file mode 100644 index 0000000..a430b36 --- /dev/null +++ b/spring-boot-migration/src/test/java/com/cobolmigration/service/SearchServiceTest.java @@ -0,0 +1,117 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.SearchItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for SearchService. + * Uses the exact test data from search.cbl (lines 127-156) to verify + * binary search behavior. + */ +class SearchServiceTest { + + private SearchService searchService; + private List testData; + + @BeforeEach + void setUp() { + searchService = new SearchService(); + testData = searchService.setupTestData(); + } + + @Test + void setupTestData_createsCorrectItems() { + // Verify test data matches search.cbl lines 127-156 + assertEquals(3, testData.size()); + + // Item 1: id1=0001, id2=0101, id3=0500, name="test item 1", date=2021/01/01 + SearchItem item1 = testData.get(0); + assertEquals(1, item1.getItemId1()); + assertEquals(101, item1.getItemId2()); + assertEquals(500, item1.getItemId3()); + assertEquals("test item 1", item1.getItemName()); + assertEquals(LocalDate.of(2021, 1, 1), item1.getItemDate()); + + // Item 2: id1=0002, id2=0102, id3=0499, name="test item 2", date=2021/02/02 + SearchItem item2 = testData.get(1); + assertEquals(2, item2.getItemId1()); + assertEquals(102, item2.getItemId2()); + assertEquals(499, item2.getItemId3()); + assertEquals("test item 2", item2.getItemName()); + assertEquals(LocalDate.of(2021, 2, 2), item2.getItemDate()); + + // Item 3: id1=0003, id2=0103, id3=0498, name="test item 3", date=2021/03/03 + SearchItem item3 = testData.get(2); + assertEquals(3, item3.getItemId1()); + assertEquals(103, item3.getItemId2()); + assertEquals(498, item3.getItemId3()); + assertEquals("test item 3", item3.getItemName()); + assertEquals(LocalDate.of(2021, 3, 3), item3.getItemDate()); + } + + @Test + void searchById_findsExistingItem() { + Optional result = searchService.searchById(testData, 2); + + assertTrue(result.isPresent()); + assertEquals(2, result.get().getItemId1()); + assertEquals("test item 2", result.get().getItemName()); + } + + @Test + void searchById_findsFirstItem() { + Optional result = searchService.searchById(testData, 1); + + assertTrue(result.isPresent()); + assertEquals(1, result.get().getItemId1()); + } + + @Test + void searchById_findsLastItem() { + Optional result = searchService.searchById(testData, 3); + + assertTrue(result.isPresent()); + assertEquals(3, result.get().getItemId1()); + } + + @Test + void searchById_returnsEmptyForNonExistent() { + // Matches "at end" display "Item not found." in search.cbl line 63 + Optional result = searchService.searchById(testData, 999); + + assertFalse(result.isPresent()); + } + + @Test + void searchByAllIds_findsMatchingItem() { + // Matches compound WHEN in search.cbl lines 85-88 + Optional result = searchService.searchByAllIds(testData, 2, 102, 499); + + assertTrue(result.isPresent()); + assertEquals("test item 2", result.get().getItemName()); + } + + @Test + void searchByAllIds_returnsEmptyWhenPartialMatch() { + // id1 matches but id2 and id3 don't + Optional result = searchService.searchByAllIds(testData, 1, 999, 999); + + assertFalse(result.isPresent()); + } + + @Test + void searchByAllIds_returnsEmptyForNoMatch() { + Optional result = searchService.searchByAllIds(testData, 999, 999, 999); + + assertFalse(result.isPresent()); + } +} diff --git a/spring-boot-migration/src/test/java/com/cobolmigration/service/XmlSerializationServiceTest.java b/spring-boot-migration/src/test/java/com/cobolmigration/service/XmlSerializationServiceTest.java new file mode 100644 index 0000000..7640920 --- /dev/null +++ b/spring-boot-migration/src/test/java/com/cobolmigration/service/XmlSerializationServiceTest.java @@ -0,0 +1,97 @@ +package com.cobolmigration.service; + +import com.cobolmigration.model.SerializationRecord; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for XmlSerializationService. + * Verifies output matches the expected XML structure from xml_generate/xml_generate.cbl. + */ +class XmlSerializationServiceTest { + + private XmlSerializationService xmlService; + + @BeforeEach + void setUp() { + xmlService = new XmlSerializationService(); + } + + @Test + void toXml_generatesValidXml() throws JsonProcessingException { + SerializationRecord record = xmlService.createSampleRecord(); + + String xml = xmlService.toXml(record); + + assertNotNull(xml); + assertTrue(xml.contains(" StringUtils.numericValue("abc")); + } + + @Test + void numericValue_throwsForNull() { + assertThrows(NumberFormatException.class, () -> StringUtils.numericValue(null)); + } + + @Test + void numericValue_throwsForBlank() { + assertThrows(NumberFormatException.class, () -> StringUtils.numericValue(" ")); + } +} diff --git a/spring-boot-migration/src/test/resources/application-test.yml b/spring-boot-migration/src/test/resources/application-test.yml new file mode 100644 index 0000000..6caae5f --- /dev/null +++ b/spring-boot-migration/src/test/resources/application-test.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + format_sql: true + sql: + init: + mode: never + +server: + port: 8080