From 971b328731aa36b86ffb932ed2d5c1e17f0213de Mon Sep 17 00:00:00 2001 From: Rajdeep Roy Chowdhury Date: Mon, 9 Nov 2026 15:12:44 +0530 Subject: [PATCH] feat: implemented application config Signed-off-by: Rajdeep Roy Chowdhury --- .gitignore | 1 + README.md | 30 ++- build.gradle.kts | 239 +++++++++--------- gradle/libs.versions.toml | 2 + scripts/configure-backup.py | 0 scripts/start.sh | 0 src/main/java/openconsignment/Launcher.java | 1 + .../openconsignment/common/AppConfig.java | 31 +++ .../openconsignment/common/Constants.java | 1 - .../controller/WelcomeScreenController.java | 9 +- .../java/openconsignment/di/AppComponent.java | 5 +- .../di/ConfigurationModule.java | 104 ++++++++ .../openconsignment/service/BuyerService.java | 7 +- .../service/SupplierService.java | 8 +- .../service/TransportService.java | 7 +- src/main/resources/application.yaml | 1 + .../java/functional/WelcomeScreenTest.java | 31 +++ .../di/ConfigurationModuleTest.java | 182 +++++++++++++ .../service/BuyerServiceTest.java | 9 +- .../service/SupplierServiceTest.java | 9 +- .../service/TransportServiceTest.java | 7 +- 21 files changed, 547 insertions(+), 137 deletions(-) mode change 100644 => 100755 scripts/configure-backup.py mode change 100644 => 100755 scripts/start.sh create mode 100644 src/main/java/openconsignment/common/AppConfig.java create mode 100644 src/main/java/openconsignment/di/ConfigurationModule.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/test/java/functional/WelcomeScreenTest.java create mode 100644 src/test/java/openconsignment/di/ConfigurationModuleTest.java diff --git a/.gitignore b/.gitignore index f0fdad5..ba1097c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build .pdf .db logs/ +/application.yaml diff --git a/README.md b/README.md index c91454c..00e49ac 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,34 @@ Keep the JAR and launch script together when moving the application. commands above use `build/libs/`. Each location has its own database. - Reports are written to `report.pdf` in the working directory, replacing the previous report, and opened with the system's default PDF viewer. -- The report organization name is configured in - `src/main/java/openconsignment/common/Constants.java`. + +## Application configuration + +At startup, the application reads UTF-8 `application.yaml` beside the JAR, regardless +of the current working directory. If this file does not exist, the application copies +`src/main/resources/application.yaml` from inside the JAR to that location and loads +the new file. Existing configuration files are never overwritten. + +Edit the generated file to set your organization name: + +```yaml +organizationName: "YOUR ORGANIZATION" +``` + +This name appears on the welcome screen and in buyer, supplier, and transport +ledger reports. The bundled YAML is used only as the template for first-run creation; +properties are not merged or filled in from it when an external file already exists. +YAML is bound directly to configuration POJOs using SnakeYAML. Quote the name to +preserve its exact text; scalar values such as numbers are converted to strings. +Surrounding whitespace is trimmed. A blank or null name, malformed YAML, or an +unreadable configuration file is reported as a startup error. An empty file or missing +required property also fails validation. The application needs write access to the +JAR directory when creating the file for the first time. + +For Gradle/IDE runs, the file is created and read in the working directory (the +repository root for `./gradlew run`). The example file is included in `build/libs/` +when building. Restart the application after editing the file. Updating the JAR +does not replace an existing configuration file. ## Logging diff --git a/build.gradle.kts b/build.gradle.kts index 7a513d0..3e918a1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,136 +1,137 @@ -import org.gradle.api.tasks.Copy - -plugins { - application - java - jacoco - alias(libs.plugins.javafx) - alias(libs.plugins.spotless) - alias(libs.plugins.shadow) -} - -repositories { - mavenCentral() -} - -dependencies { - implementation(libs.controlsfx) - implementation(libs.jasperreports) - implementation(libs.sqlite) +import org.gradle.api.tasks.Copy + +plugins { + application + java + jacoco + alias(libs.plugins.javafx) + alias(libs.plugins.spotless) + alias(libs.plugins.shadow) +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.controlsfx) + implementation(libs.jasperreports) + implementation(libs.sqlite) implementation(libs.olap4j) implementation(libs.slf4j.api) + implementation(libs.snakeyaml) runtimeOnly(libs.log4j.slf4j2.impl) runtimeOnly(libs.log4j.core) -// implementation(libs.itext) - - implementation(libs.dagger) - annotationProcessor(libs.dagger.compiler) - - implementation(libs.javafx.controls) - implementation(libs.javafx.base) - implementation(libs.javafx.graphics) - implementation(libs.javafx.fxml) - - compileOnly(libs.lombok) - annotationProcessor(libs.lombok) - - testImplementation(libs.testfx.junit5) - testImplementation(libs.testfx.monocle) - testImplementation(libs.hamcrest) - - testImplementation(platform(libs.junit.bom)) - testImplementation(libs.junit.jupiter) - testRuntimeOnly(libs.junit.platform.launcher) - - testImplementation(libs.testfx.core) - - testImplementation(libs.mockito.core) - testImplementation(libs.mockito.junit.jupiter) -} - -javafx { - version = libs.versions.javafx.get() - modules = listOf("javafx.controls", "javafx.fxml", "javafx.graphics", "javafx.base") -} - +// implementation(libs.itext) + + implementation(libs.dagger) + annotationProcessor(libs.dagger.compiler) + + implementation(libs.javafx.controls) + implementation(libs.javafx.base) + implementation(libs.javafx.graphics) + implementation(libs.javafx.fxml) + + compileOnly(libs.lombok) + annotationProcessor(libs.lombok) + + testImplementation(libs.testfx.junit5) + testImplementation(libs.testfx.monocle) + testImplementation(libs.hamcrest) + + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) + + testImplementation(libs.testfx.core) + + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.junit.jupiter) +} + +javafx { + version = libs.versions.javafx.get() + modules = listOf("javafx.controls", "javafx.fxml", "javafx.graphics", "javafx.base") +} + tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) systemProperty("logging.test.classpath", sourceSets.test.get().runtimeClasspath.asPath) providers.gradleProperty("loggingTestJar").orNull?.let { systemProperty("logging.test.jar", file(it).absolutePath) } - + // Keep UI tests independent of desktop focus and native mouse permissions. // Use -PheadlessTests=false when debugging tests with visible windows. val headlessTests = providers.gradleProperty("headlessTests").map { it.toBoolean() }.getOrElse(true) if (headlessTests) { - jvmArgs( - "-Dtestfx.robot=glass", - "-Dtestfx.headless=true", - "-Dprism.order=sw", - "-Dprism.text=t2k", - "-Djava.awt.headless=true" - ) - } -} - -jacoco { - toolVersion = "0.8.12" -} - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } -} - -spotless { - java { - lineEndings = com.diffplug.spotless.LineEnding.UNIX - target("src/**/*.java") - - palantirJavaFormat() - removeUnusedImports() - endWithNewline() - trimTrailingWhitespace() - } -} - -application { - mainClass.set("openconsignment.Launcher") -} - -tasks.named("run") { - jvmArgs( - "--add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED", - "--add-exports=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED", - "--add-exports=javafx.controls/com.sun.javafx.scene.control.behavior=ALL-UNNAMED", - "--add-exports=javafx.base/com.sun.javafx.binding=ALL-UNNAMED", - "--add-exports=javafx.graphics/com.sun.javafx.stage=ALL-UNNAMED" - ) -} - + jvmArgs( + "-Dtestfx.robot=glass", + "-Dtestfx.headless=true", + "-Dprism.order=sw", + "-Dprism.text=t2k", + "-Djava.awt.headless=true" + ) + } +} + +jacoco { + toolVersion = "0.8.12" +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + } +} + +spotless { + java { + lineEndings = com.diffplug.spotless.LineEnding.UNIX + target("src/**/*.java") + + palantirJavaFormat() + removeUnusedImports() + endWithNewline() + trimTrailingWhitespace() + } +} + +application { + mainClass.set("openconsignment.Launcher") +} + +tasks.named("run") { + jvmArgs( + "--add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED", + "--add-exports=javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED", + "--add-exports=javafx.controls/com.sun.javafx.scene.control.behavior=ALL-UNNAMED", + "--add-exports=javafx.base/com.sun.javafx.binding=ALL-UNNAMED", + "--add-exports=javafx.graphics/com.sun.javafx.stage=ALL-UNNAMED" + ) +} + tasks.named("shadowJar") { archiveClassifier.set("") mergeServiceFiles() } - -tasks.named("distZip") { enabled = false } -tasks.named("distTar") { enabled = false } -tasks.named("startScripts") { enabled = false } -tasks.named("jar") { enabled = false } - -val copyScripts by tasks.registering(Copy::class) { - from("scripts") { - include("start.sh", "start.bat", "configure-backup.py", "templates/**") - } - into(layout.buildDirectory.dir("libs")) - doLast { - println("Copied scripts to build directory") - } -} - -tasks.named("shadowJar") { - dependsOn(copyScripts) -} + +tasks.named("distZip") { enabled = false } +tasks.named("distTar") { enabled = false } +tasks.named("startScripts") { enabled = false } +tasks.named("jar") { enabled = false } + +val copyScripts by tasks.registering(Copy::class) { + from("scripts") { + include("start.sh", "start.bat", "configure-backup.py", "templates/**") + } + into(layout.buildDirectory.dir("libs")) + doLast { + println("Copied scripts to build directory") + } +} + +tasks.named("shadowJar") { + dependsOn(copyScripts) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c567f9..92efa9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ junitbom = "5.10.2" mockito = "5.14.2" slf4j = "2.0.17" log4j = "2.24.3" +snakeyaml = "2.3" spotless = "7.0.2" shadow = "7.1.2" @@ -27,6 +28,7 @@ jasperreports = { module = "net.sf.jasperreports:jasperreports", version.ref = " sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } olap4j = { module = "org.olap4j:olap4j", version.ref = "olap4j" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeyaml" } log4j-slf4j2-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } #itext = { module = "com.lowagie:itext", version.ref = "itext" } diff --git a/scripts/configure-backup.py b/scripts/configure-backup.py old mode 100644 new mode 100755 diff --git a/scripts/start.sh b/scripts/start.sh old mode 100644 new mode 100755 diff --git a/src/main/java/openconsignment/Launcher.java b/src/main/java/openconsignment/Launcher.java index b2fdf73..27773ab 100644 --- a/src/main/java/openconsignment/Launcher.java +++ b/src/main/java/openconsignment/Launcher.java @@ -30,6 +30,7 @@ public void start(Stage stage) { throw new RuntimeException("Resource not found"); } AppComponent component = DaggerAppComponent.create(); + component.configuration(); FXMLLoader loader = new FXMLLoader(resource); loader.setControllerFactory(component.controllerFactory()); Parent root = loader.load(); diff --git a/src/main/java/openconsignment/common/AppConfig.java b/src/main/java/openconsignment/common/AppConfig.java new file mode 100644 index 0000000..91fce8b --- /dev/null +++ b/src/main/java/openconsignment/common/AppConfig.java @@ -0,0 +1,31 @@ +package openconsignment.common; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Application settings bound from YAML once at startup. */ +@Getter +@Setter +@NoArgsConstructor +public class AppConfig { + private String organizationName; + + public static AppConfig forOrganization(String organizationName) { + AppConfig configuration = new AppConfig(); + configuration.setOrganizationName(organizationName); + configuration.validate(); + return configuration; + } + + public String organizationName() { + return organizationName; + } + + public void validate() { + if (organizationName == null || organizationName.isBlank()) { + throw new IllegalArgumentException("organizationName must not be blank"); + } + organizationName = organizationName.strip(); + } +} diff --git a/src/main/java/openconsignment/common/Constants.java b/src/main/java/openconsignment/common/Constants.java index c1c19f8..f523b90 100644 --- a/src/main/java/openconsignment/common/Constants.java +++ b/src/main/java/openconsignment/common/Constants.java @@ -2,7 +2,6 @@ public class Constants { public static final String ORGANIZATION_NAME_KEY = "organizationName"; - public static final String ORGANIZATION_NAME = "SATYAM AGENCIES"; public static final String REPORT_FILE_NAME = "report.pdf"; public static final String DATE_TIME_FORMAT = "dd-MM-yyyy"; public static final String DATABASE_FILE_NAME = "database.db"; diff --git a/src/main/java/openconsignment/controller/WelcomeScreenController.java b/src/main/java/openconsignment/controller/WelcomeScreenController.java index aaf45cd..73e0d70 100644 --- a/src/main/java/openconsignment/controller/WelcomeScreenController.java +++ b/src/main/java/openconsignment/controller/WelcomeScreenController.java @@ -9,19 +9,22 @@ import javafx.scene.control.Hyperlink; import javafx.scene.text.Text; import javax.inject.Inject; -import openconsignment.common.Constants; +import openconsignment.common.AppConfig; import openconsignment.common.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class WelcomeScreenController implements Initializable { private static final Logger logger = LoggerFactory.getLogger(WelcomeScreenController.class); + private final AppConfig configuration; @FXML private Text organization_name; @Inject - public WelcomeScreenController() {} + public WelcomeScreenController(AppConfig configuration) { + this.configuration = configuration; + } @FXML private void handleOpenGithub(ActionEvent event) { @@ -38,6 +41,6 @@ private void handleOpenGithub(ActionEvent event) { @Override public void initialize(URL url, ResourceBundle rb) { - organization_name.setText(Constants.ORGANIZATION_NAME); + organization_name.setText(configuration.organizationName()); } } diff --git a/src/main/java/openconsignment/di/AppComponent.java b/src/main/java/openconsignment/di/AppComponent.java index 2c8fc5e..bd488e8 100644 --- a/src/main/java/openconsignment/di/AppComponent.java +++ b/src/main/java/openconsignment/di/AppComponent.java @@ -3,10 +3,13 @@ import dagger.Component; import javafx.util.Callback; import javax.inject.Singleton; +import openconsignment.common.AppConfig; @Singleton -@Component(modules = {DatabaseModule.class, ControllerModule.class}) +@Component(modules = {DatabaseModule.class, ControllerModule.class, ConfigurationModule.class}) public interface AppComponent { + AppConfig configuration(); + Callback, Object> controllerFactory(); } diff --git a/src/main/java/openconsignment/di/ConfigurationModule.java b/src/main/java/openconsignment/di/ConfigurationModule.java new file mode 100644 index 0000000..eb43f6c --- /dev/null +++ b/src/main/java/openconsignment/di/ConfigurationModule.java @@ -0,0 +1,104 @@ +package openconsignment.di; + +import dagger.Module; +import dagger.Provides; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.inject.Singleton; +import openconsignment.common.AppConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.error.YAMLException; + +/** Creates the external configuration on first startup and provides validated settings from it. */ +@Module +public final class ConfigurationModule { + + private static final Logger logger = LoggerFactory.getLogger(ConfigurationModule.class); + + private static final String CONFIGURATION_FILE = "application.yaml"; + private static final String DEFAULTS_RESOURCE = "/" + CONFIGURATION_FILE; + + private ConfigurationModule() {} + + @Provides + @Singleton + static AppConfig provideAppConfig() { + Path path = externalConfigurationPath(); + try { + return load(path); + } catch (IOException ex) { + throw new UncheckedIOException("Unable to load application configuration: " + path, ex); + } + } + + static AppConfig load(Path path) throws IOException { + initializeConfiguration(path); + return parseConfiguration(Files.readString(path, StandardCharsets.UTF_8), path); + } + + private static Yaml createYamlParser() { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + return new Yaml(new Constructor(AppConfig.class, options)); + } + + private static AppConfig parseConfiguration(String content, Path path) { + try { + AppConfig configuration = createYamlParser().load(content); + if (configuration == null) { + throw new IllegalArgumentException("Configuration must not be empty"); + } + configuration.validate(); + return configuration; + } catch (YAMLException ex) { + throw new YAMLException("Invalid configuration in " + path + ": " + ex.getMessage(), ex); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("Invalid configuration in " + path + ": " + ex.getMessage(), ex); + } + } + + private static void initializeConfiguration(Path path) throws IOException { + if (Files.exists(path)) { + return; + } + try (InputStream defaults = ConfigurationModule.class.getResourceAsStream(DEFAULTS_RESOURCE)) { + if (defaults == null) { + throw new IOException("Bundled configuration resource is missing: " + DEFAULTS_RESOURCE); + } + try { + Files.copy(defaults, path); + logger.info("Generated new application.yaml as it was missing at {}", path); + } catch (FileAlreadyExistsException ex) { + // Another startup created the file; preserve it and load its contents. + } + } + } + + private static Path externalConfigurationPath() { + try { + Path location = Path.of(ConfigurationModule.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + return externalConfigurationPath(location, Path.of("").toAbsolutePath()); + } catch (URISyntaxException ex) { + throw new IllegalStateException("Unable to resolve application location", ex); + } + } + + static Path externalConfigurationPath(Path codeLocation, Path workingDirectory) { + Path directory = Files.isRegularFile(codeLocation) ? codeLocation.getParent() : workingDirectory; + return directory.resolve(CONFIGURATION_FILE).normalize(); + } +} diff --git a/src/main/java/openconsignment/service/BuyerService.java b/src/main/java/openconsignment/service/BuyerService.java index 312ea65..823f1e6 100644 --- a/src/main/java/openconsignment/service/BuyerService.java +++ b/src/main/java/openconsignment/service/BuyerService.java @@ -9,6 +9,7 @@ import javax.inject.Inject; import javax.inject.Singleton; import net.sf.jasperreports.engine.*; +import openconsignment.common.AppConfig; import openconsignment.common.Constants; import openconsignment.common.Utils; import openconsignment.model.BuyerLedgerAgeRow; @@ -20,11 +21,13 @@ public class BuyerService { private final BuyerRepository buyerRepository; private final CollectionService collectionService; + private final AppConfig configuration; @Inject - public BuyerService(BuyerRepository buyerRepository, CollectionService collectionService) { + public BuyerService(BuyerRepository buyerRepository, CollectionService collectionService, AppConfig configuration) { this.buyerRepository = buyerRepository; this.collectionService = collectionService; + this.configuration = configuration; } public List getAllBuyers() throws SQLException { @@ -46,7 +49,7 @@ public void renameBuyer(String oldName, String newName) throws SQLException { public void generatePdf(String buyerName, boolean isAgewise) throws SQLException, JRException { Map payload = new HashMap<>(); - payload.put(Constants.ORGANIZATION_NAME_KEY, Constants.ORGANIZATION_NAME); + payload.put(Constants.ORGANIZATION_NAME_KEY, configuration.organizationName()); payload.put("buyerName", buyerName); if (isAgewise) { diff --git a/src/main/java/openconsignment/service/SupplierService.java b/src/main/java/openconsignment/service/SupplierService.java index d9cfa38..7f8eb0e 100644 --- a/src/main/java/openconsignment/service/SupplierService.java +++ b/src/main/java/openconsignment/service/SupplierService.java @@ -9,6 +9,7 @@ import javax.inject.Inject; import javax.inject.Singleton; import net.sf.jasperreports.engine.*; +import openconsignment.common.AppConfig; import openconsignment.common.Constants; import openconsignment.common.Utils; import openconsignment.model.SupplierLedgerAgeRow; @@ -20,11 +21,14 @@ public class SupplierService { private final SupplierRepository supplierRepository; private final PaymentService paymentService; + private final AppConfig configuration; @Inject - public SupplierService(SupplierRepository supplierRepository, PaymentService paymentService) { + public SupplierService( + SupplierRepository supplierRepository, PaymentService paymentService, AppConfig configuration) { this.supplierRepository = supplierRepository; this.paymentService = paymentService; + this.configuration = configuration; } public List getAllSuppliers() throws SQLException { @@ -46,7 +50,7 @@ public void renameSupplier(String oldName, String newName) throws SQLException { public void generatePdf(String supplierName, boolean isAgewise) throws SQLException, JRException { Map payload = new HashMap<>(); - payload.put(Constants.ORGANIZATION_NAME_KEY, Constants.ORGANIZATION_NAME); + payload.put(Constants.ORGANIZATION_NAME_KEY, configuration.organizationName()); payload.put("supplierName", supplierName); if (isAgewise) { diff --git a/src/main/java/openconsignment/service/TransportService.java b/src/main/java/openconsignment/service/TransportService.java index 159bf35..8007adc 100644 --- a/src/main/java/openconsignment/service/TransportService.java +++ b/src/main/java/openconsignment/service/TransportService.java @@ -7,6 +7,7 @@ import javax.inject.Inject; import javax.inject.Singleton; import net.sf.jasperreports.engine.*; +import openconsignment.common.AppConfig; import openconsignment.common.Constants; import openconsignment.common.Utils; import openconsignment.model.TransportLedgerRow; @@ -16,10 +17,12 @@ public class TransportService { private final TransportRepository transportRepository; + private final AppConfig configuration; @Inject - public TransportService(TransportRepository transportRepository) { + public TransportService(TransportRepository transportRepository, AppConfig configuration) { this.transportRepository = transportRepository; + this.configuration = configuration; } public List getAllTransports() throws SQLException { @@ -43,7 +46,7 @@ public void generatePdf(String transportName, String fromDate, String toDate) th transportRepository.getTransportReportFor(transportName, fromDate, toDate); Map payload = new HashMap<>(); - payload.put(Constants.ORGANIZATION_NAME_KEY, Constants.ORGANIZATION_NAME); + payload.put(Constants.ORGANIZATION_NAME_KEY, configuration.organizationName()); payload.put("transportName", transportName); payload.put("fromDate", fromDate); payload.put("toDate", toDate); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..aada7ee --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1 @@ +organizationName: "SATYAM AGENCIES" diff --git a/src/test/java/functional/WelcomeScreenTest.java b/src/test/java/functional/WelcomeScreenTest.java new file mode 100644 index 0000000..125cdca --- /dev/null +++ b/src/test/java/functional/WelcomeScreenTest.java @@ -0,0 +1,31 @@ +package functional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.text.Text; +import javafx.stage.Stage; +import openconsignment.common.AppConfig; +import openconsignment.controller.WelcomeScreenController; +import org.junit.jupiter.api.Test; +import org.testfx.framework.junit5.ApplicationTest; + +class WelcomeScreenTest extends ApplicationTest { + @Override + public void start(Stage stage) throws Exception { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/WelcomeScreen.fxml")); + loader.setControllerFactory( + type -> new WelcomeScreenController(AppConfig.forOrganization("Configured Organization"))); + Parent root = loader.load(); + stage.setScene(new Scene(root)); + stage.show(); + } + + @Test + void displaysConfiguredOrganizationName() { + Text name = lookup("#organization_name").query(); + assertEquals("Configured Organization", name.getText()); + } +} diff --git a/src/test/java/openconsignment/di/ConfigurationModuleTest.java b/src/test/java/openconsignment/di/ConfigurationModuleTest.java new file mode 100644 index 0000000..0a09125 --- /dev/null +++ b/src/test/java/openconsignment/di/ConfigurationModuleTest.java @@ -0,0 +1,182 @@ +package openconsignment.di; + +import static openconsignment.di.ConfigurationModule.load; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import openconsignment.common.AppConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.yaml.snakeyaml.error.YAMLException; + +class ConfigurationModuleTest { + @TempDir + Path tempDir; + + @Test + void missingFileIsCopiedFromBundledResourceAndLoaded() throws IOException { + Path file = tempDir.resolve("application.yaml"); + String bundled; + try (InputStream resource = ConfigurationModule.class.getResourceAsStream("/application.yaml")) { + bundled = new String(resource.readAllBytes(), StandardCharsets.UTF_8); + } + + AppConfig configuration = load(file); + + assertEquals(bundled, Files.readString(file)); + assertEquals(configuration.organizationName(), load(file).organizationName()); + } + + @Test + void emptyExistingFileIsRejectedAndPreserved() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "# Empty configuration\n"); + + assertThrows(IllegalArgumentException.class, () -> load(file)); + assertEquals("# Empty configuration\n", Files.readString(file)); + } + + @Test + void loadsUtf8OrganizationNameAndTrimsWhitespace() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: \" Café Agencies \"\n"); + + assertEquals("Café Agencies", load(file).organizationName()); + } + + @Test + void loadedConfigurationDoesNotChangeWhenFileIsEdited() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: First\n"); + AppConfig configuration = load(file); + + Files.writeString(file, "organizationName: Second\n"); + + assertEquals("First", configuration.organizationName()); + assertEquals("Second", load(file).organizationName()); + } + + @Test + void rejectsBlankOrganizationName() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: \" \"\n"); + + assertThrows(IllegalArgumentException.class, () -> load(file)); + } + + @Test + void rejectsMalformedYaml() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: [\n"); + + assertThrows(YAMLException.class, () -> load(file)); + } + + @Test + void missingOrganizationNameIsRejectedWithoutBundledFallback() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "{}\n"); + + assertThrows(IllegalArgumentException.class, () -> load(file)); + assertEquals("{}\n", Files.readString(file)); + } + + @Test + void existingConfigurationIsLoadedWithoutBeingOverwritten() throws IOException { + String content = "# Custom settings\norganizationName: External Organization\n"; + Path file = Files.writeString(tempDir.resolve("application.yaml"), content); + + assertEquals("External Organization", load(file).organizationName()); + assertEquals(content, Files.readString(file)); + } + + @Test + void malformedConfigurationErrorIdentifiesSourceFile() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: [\n"); + + YAMLException error = assertThrows(YAMLException.class, () -> load(file)); + + assertTrue(error.getMessage().contains(file.toString())); + } + + @Test + void missingDestinationDirectoryReportsCreationFailure() { + Path file = tempDir.resolve("missing-directory/application.yaml"); + + assertThrows(IOException.class, () -> load(file)); + } + + @Test + void rejectsUnknownProperties() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationNmae: Typo\n"); + + assertThrows(YAMLException.class, () -> load(file)); + } + + @Test + void packagedApplicationUsesConfigurationNextToJar() throws IOException { + Path jar = Files.createFile(tempDir.resolve("OpenConsignment.jar")); + + assertEquals( + tempDir.resolve("application.yaml"), + ConfigurationModule.externalConfigurationPath(jar, tempDir.resolve("other-working-directory"))); + } + + @Test + void developmentRunUsesConfigurationInWorkingDirectory() throws IOException { + Path classes = Files.createDirectory(tempDir.resolve("classes")); + Path workingDirectory = tempDir.resolve("working-directory"); + + assertEquals( + workingDirectory.resolve("application.yaml"), + ConfigurationModule.externalConfigurationPath(classes, workingDirectory)); + } + + @Test + void bindsNumericScalarToStringProperty() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: 123\n"); + + assertEquals("123", load(file).organizationName()); + } + + @Test + void rejectsMappingOrganizationName() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: {name: Example}\n"); + + assertThrows(YAMLException.class, () -> load(file)); + } + + @Test + void rejectsNonMappingDocument() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "- Example\n"); + + assertThrows(YAMLException.class, () -> load(file)); + } + + @Test + void rejectsNullOrganizationName() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: null\n"); + + assertThrows(IllegalArgumentException.class, () -> load(file)); + } + + @Test + void rejectsEmptyOrganizationName() throws IOException { + Path file = Files.writeString(tempDir.resolve("application.yaml"), "organizationName: \"\"\n"); + + assertThrows(IllegalArgumentException.class, () -> load(file)); + } + + @Test + void rejectsDuplicateKeys() throws IOException { + Path file = Files.writeString( + tempDir.resolve("application.yaml"), "organizationName: First\norganizationName: Second\n"); + + assertThrows(YAMLException.class, () -> load(file)); + } + + @Test + void reportsReadErrorsInsteadOfSilentlyUsingDefaults() { + assertThrows(IOException.class, () -> load(tempDir)); + } +} diff --git a/src/test/java/openconsignment/service/BuyerServiceTest.java b/src/test/java/openconsignment/service/BuyerServiceTest.java index 1749d71..c2dea51 100644 --- a/src/test/java/openconsignment/service/BuyerServiceTest.java +++ b/src/test/java/openconsignment/service/BuyerServiceTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import net.sf.jasperreports.engine.JRException; +import openconsignment.common.AppConfig; import openconsignment.common.Constants; import openconsignment.common.Utils; import openconsignment.entity.BillEntity; @@ -24,11 +25,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class BuyerServiceTest { + @Spy + private AppConfig configuration = AppConfig.forOrganization("Configured Organization"); + @Mock private BuyerRepository buyerRepository; @@ -80,7 +85,7 @@ void generatePdfNonAgewiseUsesCollectionDetails() throws SQLException, JRExcepti utils.verify(() -> Utils.generatePdf(eq("BuyerLedger.jrxml"), payloadCaptor.capture(), eq(rows))); Map payload = payloadCaptor.getValue(); - assertEquals(Constants.ORGANIZATION_NAME, payload.get(Constants.ORGANIZATION_NAME_KEY)); + assertEquals("Configured Organization", payload.get(Constants.ORGANIZATION_NAME_KEY)); assertEquals("Buyer A", payload.get("buyerName")); } } @@ -109,7 +114,7 @@ void generatePdfAgewiseMapsPendingBillsWithComputedDays() throws SQLException, J () -> Utils.generatePdf(eq("BuyerLedgerAge.jrxml"), payloadCaptor.capture(), rowsCaptor.capture())); Map payload = payloadCaptor.getValue(); - assertEquals(Constants.ORGANIZATION_NAME, payload.get(Constants.ORGANIZATION_NAME_KEY)); + assertEquals("Configured Organization", payload.get(Constants.ORGANIZATION_NAME_KEY)); assertEquals("Buyer A", payload.get("buyerName")); List rows = rowsCaptor.getValue(); diff --git a/src/test/java/openconsignment/service/SupplierServiceTest.java b/src/test/java/openconsignment/service/SupplierServiceTest.java index 43a7d46..d269376 100644 --- a/src/test/java/openconsignment/service/SupplierServiceTest.java +++ b/src/test/java/openconsignment/service/SupplierServiceTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import net.sf.jasperreports.engine.JRException; +import openconsignment.common.AppConfig; import openconsignment.common.Constants; import openconsignment.common.Utils; import openconsignment.entity.BillEntity; @@ -24,11 +25,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class SupplierServiceTest { + @Spy + private AppConfig configuration = AppConfig.forOrganization("Configured Organization"); + @Mock private SupplierRepository supplierRepository; @@ -80,7 +85,7 @@ void generatePdfNonAgewiseUsesPaymentDetails() throws SQLException, JRException utils.verify(() -> Utils.generatePdf(eq("SupplierLedger.jrxml"), payloadCaptor.capture(), eq(rows))); Map payload = payloadCaptor.getValue(); - assertEquals(Constants.ORGANIZATION_NAME, payload.get(Constants.ORGANIZATION_NAME_KEY)); + assertEquals("Configured Organization", payload.get(Constants.ORGANIZATION_NAME_KEY)); assertEquals("Supplier A", payload.get("supplierName")); } } @@ -109,7 +114,7 @@ void generatePdfAgewiseMapsPendingBillsWithComputedDays() throws SQLException, J Utils.generatePdf(eq("SupplierLedgerAge.jrxml"), payloadCaptor.capture(), rowsCaptor.capture())); Map payload = payloadCaptor.getValue(); - assertEquals(Constants.ORGANIZATION_NAME, payload.get(Constants.ORGANIZATION_NAME_KEY)); + assertEquals("Configured Organization", payload.get(Constants.ORGANIZATION_NAME_KEY)); assertEquals("Supplier A", payload.get("supplierName")); List rows = rowsCaptor.getValue(); diff --git a/src/test/java/openconsignment/service/TransportServiceTest.java b/src/test/java/openconsignment/service/TransportServiceTest.java index 902175e..bf93eb3 100644 --- a/src/test/java/openconsignment/service/TransportServiceTest.java +++ b/src/test/java/openconsignment/service/TransportServiceTest.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import net.sf.jasperreports.engine.JRException; +import openconsignment.common.AppConfig; import openconsignment.common.Constants; import openconsignment.common.Utils; import openconsignment.model.TransportLedgerRow; @@ -20,11 +21,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class TransportServiceTest { + @Spy + private AppConfig configuration = AppConfig.forOrganization("Configured Organization"); + @Mock private TransportRepository transportRepository; @@ -74,7 +79,7 @@ void generatePdfBuildsPayloadAndForwardsRows() throws SQLException, JRException utils.verify(() -> Utils.generatePdf(eq("TransportLedger.jrxml"), payloadCaptor.capture(), eq(rows))); Map payload = payloadCaptor.getValue(); - assertEquals(Constants.ORGANIZATION_NAME, payload.get(Constants.ORGANIZATION_NAME_KEY)); + assertEquals("Configured Organization", payload.get(Constants.ORGANIZATION_NAME_KEY)); assertEquals("Transport A", payload.get("transportName")); assertEquals("01-01-2026", payload.get("fromDate")); assertEquals("31-01-2026", payload.get("toDate"));