From 6f8484bc3a45ae8c1153b2b3e1fc8e1105311bed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:36:11 +0000 Subject: [PATCH] feat: migrate COBOL codebase to Java Spring Boot application Create java-migration/ directory with full Spring Boot project: - Entity: Account JPA entity mapping COBOL ws-sql-account-record - Repository: AccountRepository with Spring Data JPA methods replacing ACCOUNT-ALL-CUR, ACCOUNT-DISABLED-CUR, and ACCOUNT-QUERY-CUR cursors - Services: AccountService, SerializationService, ReportService - Controllers: AccountController (REST API replacing terminal menu), DemoController (JSON/XML/report/search/CLI arg demos) - DTOs: JsonRecord, XmlRecord, SearchItem - Utilities: StringUtils replacing COBOL TRIM, UNSTRING, IS NUMERIC, NUMVAL - Config: AppConfig with GlobalExceptionHandler replacing check-sql-state - Flyway migration: V1__create_accounts_table.sql from create_test_db.sql - Tests: 41 passing tests covering repository, service, controller, serialization, and string utility layers - application.properties configured for PostgreSQL localhost:5432/cobol_db_example Original COBOL code preserved for side-by-side comparison. Co-Authored-By: Jerry Oliphant --- .gitignore | 1 + java-migration/pom.xml | 103 +++++++++++++++ .../CobolMigrationApplication.java | 12 ++ .../cobolmigration/config/AppConfig.java | 62 +++++++++ .../controller/AccountController.java | 59 +++++++++ .../controller/DemoController.java | 120 +++++++++++++++++ .../cobolmigration/dto/JsonRecord.java | 73 +++++++++++ .../cobolmigration/dto/SearchItem.java | 81 ++++++++++++ .../example/cobolmigration/dto/XmlRecord.java | 74 +++++++++++ .../cobolmigration/entity/Account.java | 121 ++++++++++++++++++ .../repository/AccountRepository.java | 44 +++++++ .../service/AccountService.java | 53 ++++++++ .../cobolmigration/service/ReportService.java | 110 ++++++++++++++++ .../service/SerializationService.java | 74 +++++++++++ .../cobolmigration/util/StringUtils.java | 73 +++++++++++ .../src/main/resources/application.properties | 17 +++ .../migration/V1__create_accounts_table.sql | 50 ++++++++ .../controller/AccountControllerTest.java | 77 +++++++++++ .../repository/AccountRepositoryTest.java | 112 ++++++++++++++++ .../service/AccountServiceTest.java | 80 ++++++++++++ .../service/SerializationServiceTest.java | 83 ++++++++++++ .../cobolmigration/util/StringUtilsTest.java | 118 +++++++++++++++++ .../resources/application-test.properties | 10 ++ 23 files changed, 1607 insertions(+) create mode 100644 java-migration/pom.xml create mode 100644 java-migration/src/main/java/com/example/cobolmigration/CobolMigrationApplication.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/config/AppConfig.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/controller/AccountController.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/controller/DemoController.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/dto/JsonRecord.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/dto/SearchItem.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/dto/XmlRecord.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/entity/Account.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java create mode 100644 java-migration/src/main/java/com/example/cobolmigration/util/StringUtils.java create mode 100644 java-migration/src/main/resources/application.properties create mode 100644 java-migration/src/main/resources/db/migration/V1__create_accounts_table.sql create mode 100644 java-migration/src/test/java/com/example/cobolmigration/controller/AccountControllerTest.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryTest.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/service/AccountServiceTest.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java create mode 100644 java-migration/src/test/java/com/example/cobolmigration/util/StringUtilsTest.java create mode 100644 java-migration/src/test/resources/application-test.properties diff --git a/.gitignore b/.gitignore index cf3f6b1..8434d98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ **/bin +**/target/ diff --git a/java-migration/pom.xml b/java-migration/pom.xml new file mode 100644 index 0000000..2891bc6 --- /dev/null +++ b/java-migration/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.example + cobol-migration + 0.0.1-SNAPSHOT + cobol-migration + Java Spring Boot migration of COBOL Examples + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.postgresql + postgresql + runtime + + + + + org.flywaydb + flyway-core + + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + + + jakarta.xml.bind + jakarta.xml.bind-api + + + org.glassfish.jaxb + jaxb-runtime + + + + + org.apache.poi + poi + 5.2.5 + + + org.apache.poi + poi-ooxml + 5.2.5 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/java-migration/src/main/java/com/example/cobolmigration/CobolMigrationApplication.java b/java-migration/src/main/java/com/example/cobolmigration/CobolMigrationApplication.java new file mode 100644 index 0000000..ad68318 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/CobolMigrationApplication.java @@ -0,0 +1,12 @@ +package com.example.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/java-migration/src/main/java/com/example/cobolmigration/config/AppConfig.java b/java-migration/src/main/java/com/example/cobolmigration/config/AppConfig.java new file mode 100644 index 0000000..f82cfec --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/config/AppConfig.java @@ -0,0 +1,62 @@ +package com.example.cobolmigration.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Application configuration and global exception handling. + * + * The {@link GlobalExceptionHandler} replaces the COBOL check-sql-state + * paragraph (sql/sql_example.cbl lines 443-472) which checks SQLCODE / + * SQLSTATE and terminates on error. Instead of terminating, we return + * appropriate HTTP error responses. + */ +@Configuration +public class AppConfig { + + @RestControllerAdvice + public static class GlobalExceptionHandler { + + /** + * Handles database access errors, replacing the COBOL pattern of + * checking SQLCODE after every EXEC SQL statement. + */ + @ExceptionHandler(DataAccessException.class) + public ResponseEntity handleDataAccessException( + DataAccessException ex) { + ErrorResponse error = new ErrorResponse( + "Database error", + ex.getMostSpecificCause().getMessage()); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(error); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument( + IllegalArgumentException ex) { + ErrorResponse error = new ErrorResponse( + "Invalid request", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(error); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException( + Exception ex) { + ErrorResponse error = new ErrorResponse( + "Internal server error", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(error); + } + } + + public record ErrorResponse(String error, String detail) { + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/controller/AccountController.java b/java-migration/src/main/java/com/example/cobolmigration/controller/AccountController.java new file mode 100644 index 0000000..391c742 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/controller/AccountController.java @@ -0,0 +1,59 @@ +package com.example.cobolmigration.controller; + +import com.example.cobolmigration.entity.Account; +import com.example.cobolmigration.service.AccountService; +import java.util.List; +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; + +/** + * REST controller replacing the terminal-based menu system from + * sql/sql_example.cbl (lines 155-186). + * + * COBOL menu option mapping: + * 1) Display all accounts -> GET /api/accounts + * 2) Display disabled accounts -> GET /api/accounts/disabled + * 3) Query accounts -> GET /api/accounts/search?q={value} + * 4) Exit -> (not applicable for REST) + */ +@RestController +@RequestMapping("/api/accounts") +public class AccountController { + + private final AccountService accountService; + + public AccountController(AccountService accountService) { + this.accountService = accountService; + } + + /** + * Replaces menu option "1) Display all accounts". + */ + @GetMapping + public ResponseEntity> getAllAccounts() { + return ResponseEntity.ok(accountService.getAllAccounts()); + } + + /** + * Replaces menu option "2) Display disabled accounts". + */ + @GetMapping("/disabled") + public ResponseEntity> getDisabledAccounts() { + return ResponseEntity.ok(accountService.getDisabledAccounts()); + } + + /** + * Replaces menu option "3) Query accounts". + * + * The COBOL implementation wraps the trimmed search value with '%' + * wildcards and queries across FIRST_NAME, LAST_NAME, PHONE, and ADDRESS. + */ + @GetMapping("/search") + public ResponseEntity> searchAccounts( + @RequestParam("q") String searchValue) { + return ResponseEntity.ok(accountService.searchAccounts(searchValue)); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/controller/DemoController.java b/java-migration/src/main/java/com/example/cobolmigration/controller/DemoController.java new file mode 100644 index 0000000..342f615 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/controller/DemoController.java @@ -0,0 +1,120 @@ +package com.example.cobolmigration.controller; + +import com.example.cobolmigration.dto.JsonRecord; +import com.example.cobolmigration.dto.SearchItem; +import com.example.cobolmigration.service.ReportService; +import com.example.cobolmigration.service.ReportService.StudentRecord; +import com.example.cobolmigration.service.SerializationService; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +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; + +/** + * REST controller demonstrating the non-SQL COBOL module equivalents: + * JSON generation, XML generation, report writing, search table, and + * CLI argument handling. + */ +@RestController +@RequestMapping("/api/demo") +public class DemoController { + + private final SerializationService serializationService; + private final ReportService reportService; + + public DemoController(SerializationService serializationService, + ReportService reportService) { + this.serializationService = serializationService; + this.reportService = reportService; + } + + /** + * Demonstrates JSON generation replacing json_generate/json_generate.cbl. + */ + @GetMapping("/json") + public ResponseEntity jsonDemo() { + JsonRecord sample = serializationService.createSampleJsonRecord(); + return ResponseEntity.ok(sample); + } + + /** + * Demonstrates XML generation replacing xml_generate/xml_generate.cbl. + * Returns the raw XML string with the XML declaration. + */ + @GetMapping(value = "/xml", produces = MediaType.APPLICATION_XML_VALUE) + public ResponseEntity xmlDemo() { + String xml = serializationService.generateXml( + serializationService.createSampleXmlRecord()); + return ResponseEntity.ok(xml); + } + + /** + * Generates a text report replacing report_writer/report_test.cbl. + * Accepts an optional format parameter (text or csv). + */ + @GetMapping(value = "/report", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity reportDemo( + @RequestParam(value = "format", defaultValue = "text") String format) { + List sampleData = List.of( + new StudentRecord(334500, "Alice Johnson", "CSC", 5), + new StudentRecord(334501, "Bob Smith", "PHY", 12), + new StudentRecord(334502, "Carol White", "MAT", 8), + new StudentRecord(334503, "David Brown", "ENG", 3)); + + String report; + if ("csv".equalsIgnoreCase(format)) { + report = reportService.generateCsvReport(sampleData); + } else { + report = reportService.generateTextReport(sampleData); + } + return ResponseEntity.ok(report); + } + + /** + * Demonstrates binary search on a sorted table, replacing + * search/search.cbl SEARCH ALL behaviour. + * + * Populates a list with the same test data as the COBOL program's + * setup-test-data paragraph and performs a binary search by id1. + */ + @GetMapping("/search") + public ResponseEntity searchDemo( + @RequestParam(value = "id1", defaultValue = "0") int id1) { + List table = new ArrayList<>(); + table.add(new SearchItem(1000, 2000, 9000, "First Item", "2021/08/30")); + table.add(new SearchItem(2000, 3000, 8000, "Second Item", "2021/09/15")); + table.add(new SearchItem(3000, 4000, 7000, "Third Item", "2021/10/01")); + Collections.sort(table); + + SearchItem key = new SearchItem(id1, 0, 0, null, null); + int index = Collections.binarySearch(table, key, + (a, b) -> Integer.compare(a.getId1(), b.getId1())); + + if (index >= 0) { + return ResponseEntity.ok(table.get(index)); + } + return ResponseEntity.ok("Item not found."); + } + + /** + * Demonstrates CLI argument handling replacing read_command_args/ module. + * In a REST context the "arguments" are query parameters. + * The COBOL program checks for '--test' in the command line args. + */ + @GetMapping("/args") + public ResponseEntity argsDemo( + @RequestParam(value = "args", defaultValue = "") String args) { + StringBuilder response = new StringBuilder(); + response.append("Full command line args: ").append(args).append("\n"); + + if (args.toLowerCase().contains("--test")) { + response.append("You entered the '--test' cmd arg!\n"); + } + return ResponseEntity.ok(response.toString()); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/dto/JsonRecord.java b/java-migration/src/main/java/com/example/cobolmigration/dto/JsonRecord.java new file mode 100644 index 0000000..913c8e3 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/dto/JsonRecord.java @@ -0,0 +1,73 @@ +package com.example.cobolmigration.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO replacing the COBOL ws-record used in json_generate/json_generate.cbl. + * + * The COBOL NAME OF clause mappings (lines 45-48) are replicated with + * {@code @JsonProperty} annotations: + * ws-record-name IS "name" -> @JsonProperty("name") + * ws-record-value IS "value" -> @JsonProperty("value") + * ws-record-flag IS "enabled" -> @JsonProperty("enabled") + * + * ws-record-blank is suppressed when empty (SUPPRESS WHEN SPACES equivalent). + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class JsonRecord { + + @JsonProperty("name") + private String name; + + @JsonProperty("value") + private String value; + + @JsonProperty("blank") + private String blank; + + @JsonProperty("enabled") + private String enabled; + + public JsonRecord() { + } + + public JsonRecord(String name, String value, String blank, String enabled) { + this.name = name; + this.value = value; + this.blank = blank; + this.enabled = enabled; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getBlank() { + return blank; + } + + public void setBlank(String blank) { + this.blank = blank; + } + + public String getEnabled() { + return enabled; + } + + public void setEnabled(String enabled) { + this.enabled = enabled; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/dto/SearchItem.java b/java-migration/src/main/java/com/example/cobolmigration/dto/SearchItem.java new file mode 100644 index 0000000..20275df --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/dto/SearchItem.java @@ -0,0 +1,81 @@ +package com.example.cobolmigration.dto; + +/** + * DTO representing a search table item from search/search.cbl (lines 17-33). + * + * The COBOL OCCURS table has ascending keys (ws-item-id-1, ws-item-id-2) and + * a descending key (ws-item-id-3). In Java this is a plain POJO; sorting and + * binary search use Comparable / Collections.binarySearch(). + */ +public class SearchItem implements Comparable { + + private int id1; + private int id2; + private int id3; + private String name; + private String date; + + public SearchItem() { + } + + public SearchItem(int id1, int id2, int id3, String name, String date) { + this.id1 = id1; + this.id2 = id2; + this.id3 = id3; + this.name = name; + this.date = date; + } + + public int getId1() { + return id1; + } + + public void setId1(int id1) { + this.id1 = id1; + } + + public int getId2() { + return id2; + } + + public void setId2(int id2) { + this.id2 = id2; + } + + public int getId3() { + return id3; + } + + public void setId3(int id3) { + this.id3 = id3; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } + + /** + * Natural ordering by ascending id1, then ascending id2 — mirrors the + * COBOL ASCENDING KEY IS ws-item-id-1, ws-item-id-2 definition. + */ + @Override + public int compareTo(SearchItem other) { + int cmp = Integer.compare(this.id1, other.id1); + if (cmp != 0) { + return cmp; + } + return Integer.compare(this.id2, other.id2); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/dto/XmlRecord.java b/java-migration/src/main/java/com/example/cobolmigration/dto/XmlRecord.java new file mode 100644 index 0000000..c0054be --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/dto/XmlRecord.java @@ -0,0 +1,74 @@ +package com.example.cobolmigration.dto; + +import jakarta.xml.bind.annotation.XmlAttribute; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; + +/** + * DTO replacing the COBOL ws-record used in xml_generate/xml_generate.cbl. + * + * COBOL NAME OF mappings (lines 45-48): + * ws-record-name IS "name" -> @XmlElement(name = "name") + * ws-record-value IS "value" -> @XmlElement(name = "value") + * ws-record-flag IS "enabled" -> @XmlAttribute(name = "enabled") + * + * The COBOL TYPE OF ws-record-flag IS ATTRIBUTE (line 49) is replicated + * with @XmlAttribute. + * + * SUPPRESS WHEN SPACES (line 50) is handled by omitting null/empty values + * during marshalling. + */ +@XmlRootElement(name = "ws-record") +public class XmlRecord { + + private String name; + private String value; + private String blank; + private String enabled; + + public XmlRecord() { + } + + public XmlRecord(String name, String value, String blank, String enabled) { + this.name = name; + this.value = value; + this.blank = blank; + this.enabled = enabled; + } + + @XmlElement(name = "name") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @XmlElement(name = "value") + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @XmlElement(name = "blank", nillable = false) + public String getBlank() { + return (blank == null || blank.isBlank()) ? null : blank; + } + + public void setBlank(String blank) { + this.blank = blank; + } + + @XmlAttribute(name = "enabled") + public String getEnabled() { + return enabled; + } + + public void setEnabled(String enabled) { + this.enabled = enabled; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/entity/Account.java b/java-migration/src/main/java/com/example/cobolmigration/entity/Account.java new file mode 100644 index 0000000..1476842 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/entity/Account.java @@ -0,0 +1,121 @@ +package com.example.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 java.time.LocalDateTime; + +/** + * JPA entity mapping the COBOL ws-sql-account-record (sql/sql_example.cbl lines 44-52). + * + * COBOL field sizes are preserved as column-length constraints so that data + * written by the legacy application remains compatible. + */ +@Entity +@Table(name = "accounts") +public class Account { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "first_name", nullable = false, length = 8) + private String firstName; + + @Column(name = "last_name", nullable = false, length = 8) + private String lastName; + + @Column(name = "phone", nullable = false, length = 10) + private String phone; + + @Column(name = "address", nullable = false, length = 22) + 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; + + public Account() { + } + + public Account(String firstName, String lastName, String phone, + String address, String isEnabled) { + this.firstName = firstName; + this.lastName = lastName; + this.phone = phone; + this.address = address; + this.isEnabled = isEnabled; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getIsEnabled() { + return isEnabled; + } + + public void setIsEnabled(String isEnabled) { + this.isEnabled = isEnabled; + } + + public LocalDateTime getCreateDt() { + return createDt; + } + + public void setCreateDt(LocalDateTime createDt) { + this.createDt = createDt; + } + + public LocalDateTime getModDt() { + return modDt; + } + + public void setModDt(LocalDateTime modDt) { + this.modDt = modDt; + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java b/java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java new file mode 100644 index 0000000..62a1cd9 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/repository/AccountRepository.java @@ -0,0 +1,44 @@ +package com.example.cobolmigration.repository; + +import com.example.cobolmigration.entity.Account; +import java.util.List; +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; + +/** + * Spring Data JPA repository replacing the three COBOL SQL cursors + * defined in sql/sql_example.cbl lines 118-160. + */ +@Repository +public interface AccountRepository extends JpaRepository { + + /** + * Replaces ACCOUNT-ALL-CUR: SELECT ... FROM ACCOUNTS ORDER BY ID. + */ + List findAllByOrderByIdAsc(); + + /** + * Replaces ACCOUNT-DISABLED-CUR: SELECT ... WHERE IS_ENABLED = :isEnabled ORDER BY ID. + * Pass "N" to retrieve disabled accounts. + */ + List findByIsEnabledOrderByIdAsc(String isEnabled); + + /** + * Replaces ACCOUNT-QUERY-CUR which searches across FIRST_NAME, LAST_NAME, + * PHONE, and ADDRESS columns using LIKE '%value%'. + * + * The COBOL implementation (lines 318-394) trims the search input and wraps + * it with '%' wildcards via variable-length string handling (lines 54-67). + * In Java the caller should .trim() the input; the repository uses + * LIKE with concatenated wildcards to produce the same behaviour. + */ + @Query("SELECT a FROM Account a WHERE " + + "a.firstName LIKE %:searchValue% OR " + + "a.lastName LIKE %:searchValue% OR " + + "a.phone LIKE %:searchValue% OR " + + "a.address LIKE %:searchValue% " + + "ORDER BY a.id ASC") + List searchAccounts(@Param("searchValue") String searchValue); +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java b/java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java new file mode 100644 index 0000000..a3bfaf5 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/AccountService.java @@ -0,0 +1,53 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.entity.Account; +import com.example.cobolmigration.repository.AccountRepository; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Service encapsulating business logic from the COBOL procedure division + * (sql/sql_example.cbl lines 103-194). + */ +@Service +public class AccountService { + + private final AccountRepository accountRepository; + + public AccountService(AccountRepository accountRepository) { + this.accountRepository = accountRepository; + } + + /** + * Replaces the display-all-accounts paragraph (lines 201-246). + */ + public List getAllAccounts() { + return accountRepository.findAllByOrderByIdAsc(); + } + + /** + * Replaces the display-disabled-accounts paragraph. + * The COBOL cursor ACCOUNT-DISABLED-CUR filters WHERE IS_ENABLED = 'N'. + */ + public List getDisabledAccounts() { + return accountRepository.findByIsEnabledOrderByIdAsc("N"); + } + + /** + * Replaces the query-accounts paragraph (lines 318-394). + * + * The COBOL code trims the search value and wraps it with '%' wildcards + * for a LIKE query across FIRST_NAME, LAST_NAME, PHONE, and ADDRESS. + * Here we trim the input; the repository query handles wildcard wrapping. + */ + public List searchAccounts(String searchValue) { + if (searchValue == null) { + return List.of(); + } + String trimmed = searchValue.trim(); + if (trimmed.isEmpty()) { + return List.of(); + } + return accountRepository.searchAccounts(trimmed); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java b/java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java new file mode 100644 index 0000000..ac1dbce --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/ReportService.java @@ -0,0 +1,110 @@ +package com.example.cobolmigration.service; + +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Service replacing the COBOL Report Writer (report_writer/report_test.cbl). + * + * The COBOL report layout uses RD with PAGE LIMIT 66, HEADING 1, + * FIRST DETAIL 6, LAST DETAIL 42, FOOTING 52. The report header is + * "Customer Order Report" with a page counter, and each detail line + * contains: student ID, student name, major, and number of courses. + * + * This service generates the report as CSV text; callers can use the + * output in REST responses or write it to a file. + */ +@Service +public class ReportService { + + /** + * Simple POJO mirroring the COBOL fd-test-input-file record + * (report_test.cbl lines 24-28). + */ + public static class StudentRecord { + private final int studentId; + private final String studentName; + private final String major; + private final int numCourses; + + public StudentRecord(int studentId, String studentName, + String major, int numCourses) { + this.studentId = studentId; + this.studentName = studentName; + this.major = major; + this.numCourses = numCourses; + } + + public int getStudentId() { + return studentId; + } + + public String getStudentName() { + return studentName; + } + + public String getMajor() { + return major; + } + + public int getNumCourses() { + return numCourses; + } + } + + /** + * Generates a plain-text report matching the COBOL Report Writer output. + * + * Layout mirrors the RD declaratives: + * - Header line: "Customer Order Report" right-aligned with page counter + * - Detail lines: studentId, studentName, major, numCourses + */ + public String generateTextReport(List records) { + StringBuilder sb = new StringBuilder(); + int pageSize = 36; // LAST DETAIL (42) - FIRST DETAIL (6) + int page = 1; + int lineCount = 0; + + appendHeader(sb, page); + + for (StudentRecord record : records) { + if (lineCount >= pageSize) { + page++; + sb.append("\n"); + appendHeader(sb, page); + lineCount = 0; + } + sb.append(String.format(" %-6d %-20s %-3s %2d%n", + record.getStudentId(), + record.getStudentName(), + record.getMajor(), + record.getNumCourses())); + lineCount++; + } + + return sb.toString(); + } + + /** + * Generates a CSV report for programmatic consumption. + */ + public String generateCsvReport(List records) { + StringBuilder sb = new StringBuilder(); + sb.append("Student ID,Student Name,Major,Num Courses\n"); + for (StudentRecord record : records) { + sb.append(String.format("%d,%s,%s,%d%n", + record.getStudentId(), + record.getStudentName(), + record.getMajor(), + record.getNumCourses())); + } + return sb.toString(); + } + + private void appendHeader(StringBuilder sb, int page) { + sb.append(String.format( + " Customer Order Report" + + " PAGE %3d%n", page)); + sb.append("\n\n\n\n"); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java b/java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java new file mode 100644 index 0000000..0951b2a --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/service/SerializationService.java @@ -0,0 +1,74 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.dto.JsonRecord; +import com.example.cobolmigration.dto.XmlRecord; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Marshaller; +import java.io.StringWriter; +import org.springframework.stereotype.Service; + +/** + * Service replacing the JSON GENERATE (json_generate/) and XML GENERATE + * (xml_generate/) COBOL modules. + */ +@Service +public class SerializationService { + + private final ObjectMapper objectMapper; + + public SerializationService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Generates a JSON string from a {@link JsonRecord}, mirroring the COBOL + * JSON GENERATE statement (json_generate/json_generate.cbl lines 42-54). + */ + public String generateJson(JsonRecord record) { + try { + return objectMapper.writeValueAsString(record); + } catch (JsonProcessingException e) { + throw new RuntimeException("Error generating JSON: " + e.getMessage(), e); + } + } + + /** + * Creates a sample {@link JsonRecord} matching the COBOL test data + * (lines 38-40 of json_generate.cbl). + */ + public JsonRecord createSampleJsonRecord() { + return new JsonRecord("Test Name", "Test Value", null, "true"); + } + + /** + * Generates an XML string from an {@link XmlRecord}, mirroring the COBOL + * XML GENERATE statement (xml_generate/xml_generate.cbl lines 41-56). + * + * Includes the XML declaration (WITH XML-DECLARATION) and renders + * ws-record-flag as an attribute (TYPE OF ... IS ATTRIBUTE). + */ + public String generateXml(XmlRecord record) { + try { + JAXBContext context = JAXBContext.newInstance(XmlRecord.class); + Marshaller marshaller = context.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FRAGMENT, Boolean.FALSE); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + StringWriter writer = new StringWriter(); + marshaller.marshal(record, writer); + return writer.toString(); + } catch (JAXBException e) { + throw new RuntimeException("Error generating XML: " + e.getMessage(), e); + } + } + + /** + * Creates a sample {@link XmlRecord} matching the COBOL test data + * (lines 37-39 of xml_generate.cbl). + */ + public XmlRecord createSampleXmlRecord() { + return new XmlRecord("Test Name", "Test Value", null, "true"); + } +} diff --git a/java-migration/src/main/java/com/example/cobolmigration/util/StringUtils.java b/java-migration/src/main/java/com/example/cobolmigration/util/StringUtils.java new file mode 100644 index 0000000..fc2d5b4 --- /dev/null +++ b/java-migration/src/main/java/com/example/cobolmigration/util/StringUtils.java @@ -0,0 +1,73 @@ +package com.example.cobolmigration.util; + +import java.util.Arrays; +import java.util.List; + +/** + * Utility class replacing COBOL intrinsic string operations found across + * the example modules (trim/, unstring/, is_numeric/, numval_test/). + */ +public final class StringUtils { + + private StringUtils() { + } + + /** + * Replaces COBOL TRIM intrinsic function (trim/ module). + * Removes leading and trailing whitespace. + */ + public static String cobolTrim(String value) { + if (value == null) { + return ""; + } + return value.trim(); + } + + /** + * Replaces COBOL UNSTRING ... DELIMITED BY (unstring/ module). + * Splits the source string by the given delimiter. + */ + public static List cobolUnstring(String source, String delimiter) { + if (source == null || source.isEmpty()) { + return List.of(); + } + return Arrays.asList(source.split(delimiter, -1)); + } + + /** + * Replaces COBOL IS NUMERIC test (is_numeric/ module). + * Returns true if the trimmed string represents a valid number. + * + * The COBOL IS NUMERIC check on an alphanumeric field fails if there + * are trailing spaces; the trimmed variant (process-trim paragraph) + * works. This method mirrors the trimmed behaviour. + */ + public static boolean isNumeric(String value) { + if (value == null) { + return false; + } + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + return false; + } + try { + Double.parseDouble(trimmed); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Replaces COBOL NUMVAL intrinsic function (numval_test/ module). + * Converts a string representation of a number to a double. + * + * @throws NumberFormatException if the value cannot be parsed + */ + public static double numval(String value) { + if (value == null) { + throw new NumberFormatException("null"); + } + return Double.parseDouble(value.trim()); + } +} diff --git a/java-migration/src/main/resources/application.properties b/java-migration/src/main/resources/application.properties new file mode 100644 index 0000000..86463ac --- /dev/null +++ b/java-migration/src/main/resources/application.properties @@ -0,0 +1,17 @@ +# PostgreSQL connection details matching COBOL sql_example.cbl lines 35-41 +spring.datasource.url=jdbc:postgresql://localhost:5432/cobol_db_example +spring.datasource.username=postgres +spring.datasource.password=password +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA / Hibernate +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.show-sql=false + +# Flyway +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + +# Server +server.port=8080 diff --git a/java-migration/src/main/resources/db/migration/V1__create_accounts_table.sql b/java-migration/src/main/resources/db/migration/V1__create_accounts_table.sql new file mode 100644 index 0000000..bfede49 --- /dev/null +++ b/java-migration/src/main/resources/db/migration/V1__create_accounts_table.sql @@ -0,0 +1,50 @@ +-- Flyway migration based on sql/create_test_db.sql from the COBOL project. +-- The original script creates the database and populates it; this migration +-- handles only the schema creation within the existing database. + +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) +); + +-- Populate fake account data matching the original COBOL test data. + +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/java-migration/src/test/java/com/example/cobolmigration/controller/AccountControllerTest.java b/java-migration/src/test/java/com/example/cobolmigration/controller/AccountControllerTest.java new file mode 100644 index 0000000..150f3bb --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/controller/AccountControllerTest.java @@ -0,0 +1,77 @@ +package com.example.cobolmigration.controller; + +import com.example.cobolmigration.entity.Account; +import com.example.cobolmigration.service.AccountService; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web-layer tests for {@link AccountController}. + */ +@WebMvcTest(AccountController.class) +class AccountControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountService accountService; + + @Test + void getAllAccounts_returnsOkWithList() throws Exception { + Account account = new Account("John", "Tester", "1555550100", + "123 Fake St", "Y"); + when(accountService.getAllAccounts()).thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName").value("John")); + } + + @Test + void getDisabledAccounts_returnsOnlyDisabled() throws Exception { + Account disabled = new Account("Bob", "Tester4", "1555550154", + "119 Truck St", "N"); + when(accountService.getDisabledAccounts()) + .thenReturn(List.of(disabled)); + + mockMvc.perform(get("/api/accounts/disabled")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].isEnabled").value("N")); + } + + @Test + void searchAccounts_returnsMatchingResults() throws Exception { + Account account = new Account("John", "Tester", "1555550100", + "123 Fake St", "Y"); + when(accountService.searchAccounts("John")) + .thenReturn(List.of(account)); + + mockMvc.perform(get("/api/accounts/search").param("q", "John")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].firstName").value("John")); + } + + @Test + void searchAccounts_returnsEmptyListWhenNoMatch() throws Exception { + when(accountService.searchAccounts("ZZZZZ")) + .thenReturn(List.of()); + + mockMvc.perform(get("/api/accounts/search").param("q", "ZZZZZ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(0))); + } +} diff --git a/java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryTest.java b/java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryTest.java new file mode 100644 index 0000000..8e48793 --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/repository/AccountRepositoryTest.java @@ -0,0 +1,112 @@ +package com.example.cobolmigration.repository; + +import com.example.cobolmigration.entity.Account; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for {@link AccountRepository} verifying that the Spring + * Data JPA queries produce results matching the original COBOL cursor behaviour. + */ +@DataJpaTest +@ActiveProfiles("test") +class AccountRepositoryTest { + + @Autowired + private AccountRepository accountRepository; + + @BeforeEach + void setUp() { + accountRepository.deleteAll(); + LocalDateTime now = LocalDateTime.now(); + + accountRepository.save(createAccount("John", "Tester", "1555550100", + "123 Fake St, Nowhere", "Y", now)); + accountRepository.save(createAccount("Mike", "Tester1", "1555550121", + "122 Real St, Nowhere", "Y", now)); + accountRepository.save(createAccount("Bob", "Tester4", "1555550154", + "119 Truck St, Nowhere", "N", now)); + accountRepository.save(createAccount("Paula", "Tester5", "1555550165", + "118 Car St, Nowhere", "N", now)); + } + + @Test + void findAllByOrderByIdAsc_returnsAllAccountsOrdered() { + List accounts = accountRepository.findAllByOrderByIdAsc(); + assertEquals(4, accounts.size()); + assertTrue(accounts.get(0).getId() < accounts.get(1).getId()); + } + + @Test + void findByIsEnabledOrderByIdAsc_returnsOnlyDisabledAccounts() { + List disabled = accountRepository.findByIsEnabledOrderByIdAsc("N"); + assertEquals(2, disabled.size()); + disabled.forEach(a -> assertEquals("N", a.getIsEnabled())); + } + + @Test + void findByIsEnabledOrderByIdAsc_returnsOnlyEnabledAccounts() { + List enabled = accountRepository.findByIsEnabledOrderByIdAsc("Y"); + assertEquals(2, enabled.size()); + enabled.forEach(a -> assertEquals("Y", a.getIsEnabled())); + } + + @Test + void searchAccounts_findsByFirstName() { + List results = accountRepository.searchAccounts("John"); + assertFalse(results.isEmpty()); + assertEquals("John", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_findsByLastName() { + List results = accountRepository.searchAccounts("Tester4"); + assertEquals(1, results.size()); + assertEquals("Bob", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_findsByAddress() { + List results = accountRepository.searchAccounts("Fake"); + assertEquals(1, results.size()); + assertEquals("John", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_findsByPhone() { + List results = accountRepository.searchAccounts("1555550121"); + assertEquals(1, results.size()); + assertEquals("Mike", results.get(0).getFirstName()); + } + + @Test + void searchAccounts_returnsEmptyForNoMatch() { + List results = accountRepository.searchAccounts("ZZZZZ"); + assertTrue(results.isEmpty()); + } + + @Test + void searchAccounts_trimmedInputMatchesCOBOLBehaviour() { + // COBOL trims input before wrapping with '%'; verify trimmed search works + List results = accountRepository.searchAccounts("John"); + assertFalse(results.isEmpty()); + } + + private Account createAccount(String firstName, String lastName, + String phone, String address, + String isEnabled, LocalDateTime dt) { + Account account = new Account(firstName, lastName, phone, address, isEnabled); + account.setCreateDt(dt); + account.setModDt(dt); + return account; + } +} diff --git a/java-migration/src/test/java/com/example/cobolmigration/service/AccountServiceTest.java b/java-migration/src/test/java/com/example/cobolmigration/service/AccountServiceTest.java new file mode 100644 index 0000000..f742461 --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/service/AccountServiceTest.java @@ -0,0 +1,80 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.entity.Account; +import com.example.cobolmigration.repository.AccountRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link AccountService}. + */ +@ExtendWith(MockitoExtension.class) +class AccountServiceTest { + + @Mock + private AccountRepository accountRepository; + + @InjectMocks + private AccountService accountService; + + @Test + void getAllAccounts_delegatesToRepository() { + Account account = new Account("John", "Tester", "1555550100", + "123 Fake St", "Y"); + when(accountRepository.findAllByOrderByIdAsc()) + .thenReturn(List.of(account)); + + List result = accountService.getAllAccounts(); + + assertEquals(1, result.size()); + assertEquals("John", result.get(0).getFirstName()); + verify(accountRepository).findAllByOrderByIdAsc(); + } + + @Test + void getDisabledAccounts_passesCorrectFilter() { + Account disabled = new Account("Bob", "Tester4", "1555550154", + "119 Truck St", "N"); + when(accountRepository.findByIsEnabledOrderByIdAsc("N")) + .thenReturn(List.of(disabled)); + + List result = accountService.getDisabledAccounts(); + + assertEquals(1, result.size()); + assertEquals("N", result.get(0).getIsEnabled()); + verify(accountRepository).findByIsEnabledOrderByIdAsc("N"); + } + + @Test + void searchAccounts_trimsInput() { + when(accountRepository.searchAccounts("John")) + .thenReturn(List.of(new Account("John", "Tester", + "1555550100", "123 Fake St", "Y"))); + + List result = accountService.searchAccounts(" John "); + + assertEquals(1, result.size()); + verify(accountRepository).searchAccounts("John"); + } + + @Test + void searchAccounts_returnsEmptyForNull() { + List result = accountService.searchAccounts(null); + assertTrue(result.isEmpty()); + } + + @Test + void searchAccounts_returnsEmptyForBlank() { + List result = accountService.searchAccounts(" "); + assertTrue(result.isEmpty()); + } +} diff --git a/java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java b/java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java new file mode 100644 index 0000000..6969b29 --- /dev/null +++ b/java-migration/src/test/java/com/example/cobolmigration/service/SerializationServiceTest.java @@ -0,0 +1,83 @@ +package com.example.cobolmigration.service; + +import com.example.cobolmigration.dto.JsonRecord; +import com.example.cobolmigration.dto.XmlRecord; +import com.fasterxml.jackson.databind.ObjectMapper; +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; + +/** + * Tests for {@link SerializationService} verifying JSON and XML output matches + * the COBOL-generated format. + */ +class SerializationServiceTest { + + private SerializationService serializationService; + + @BeforeEach + void setUp() { + serializationService = new SerializationService(new ObjectMapper()); + } + + @Test + void generateJson_containsCorrectFieldNames() { + JsonRecord record = new JsonRecord("Test Name", "Test Value", null, "true"); + String json = serializationService.generateJson(record); + + assertTrue(json.contains("\"name\"")); + assertTrue(json.contains("\"value\"")); + assertTrue(json.contains("\"enabled\"")); + assertTrue(json.contains("Test Name")); + assertTrue(json.contains("Test Value")); + // blank field should be omitted (SUPPRESS WHEN SPACES equivalent) + assertFalse(json.contains("\"blank\"")); + } + + @Test + void generateXml_containsDeclaration() { + XmlRecord record = new XmlRecord("Test Name", "Test Value", null, "true"); + String xml = serializationService.generateXml(record); + + assertTrue(xml.contains(" parts = StringUtils.cobolUnstring("Hello World", " "); + assertEquals(2, parts.size()); + assertEquals("Hello", parts.get(0)); + assertEquals("World", parts.get(1)); + } + + @Test + void cobolUnstring_splitsByPipe() { + List parts = StringUtils.cobolUnstring("A|B|C", "\\|"); + assertEquals(3, parts.size()); + assertEquals("A", parts.get(0)); + assertEquals("C", parts.get(2)); + } + + @Test + void cobolUnstring_returnsEmptyListForNull() { + List parts = StringUtils.cobolUnstring(null, " "); + assertTrue(parts.isEmpty()); + } + + // IS NUMERIC tests (is_numeric/ module) + + @Test + void isNumeric_returnsTrueForInteger() { + assertTrue(StringUtils.isNumeric("12345")); + } + + @Test + void isNumeric_returnsTrueForDecimal() { + assertTrue(StringUtils.isNumeric("123.45")); + } + + @Test + void isNumeric_returnsTrueWithLeadingTrailingSpaces() { + // Mirrors the COBOL process-trim behaviour + assertTrue(StringUtils.isNumeric(" 123 ")); + } + + @Test + void isNumeric_returnsFalseForAlpha() { + assertFalse(StringUtils.isNumeric("ABC")); + } + + @Test + void isNumeric_returnsFalseForNull() { + assertFalse(StringUtils.isNumeric(null)); + } + + @Test + void isNumeric_returnsFalseForEmpty() { + assertFalse(StringUtils.isNumeric("")); + } + + // NUMVAL tests (numval_test/ module) + + @Test + void numval_parsesInteger() { + assertEquals(42.0, StringUtils.numval("42")); + } + + @Test + void numval_parsesDecimal() { + assertEquals(12345.63, StringUtils.numval("12345.63"), 0.001); + } + + @Test + void numval_trimsWhitespace() { + assertEquals(99.0, StringUtils.numval(" 99 ")); + } + + @Test + void numval_throwsForInvalidInput() { + assertThrows(NumberFormatException.class, + () -> StringUtils.numval("ABC")); + } + + @Test + void numval_throwsForNull() { + assertThrows(NumberFormatException.class, + () -> StringUtils.numval(null)); + } +} diff --git a/java-migration/src/test/resources/application-test.properties b/java-migration/src/test/resources/application-test.properties new file mode 100644 index 0000000..e7c7323 --- /dev/null +++ b/java-migration/src/test/resources/application-test.properties @@ -0,0 +1,10 @@ +# H2 in-memory database for tests +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect + +spring.flyway.enabled=false