AppConfigValidator and AppConfigValidatorTest added and integrated into ApplicationInitializer

This commit is contained in:
Niklas Aumueller 2026-03-06 16:24:43 +01:00
parent 9ae52a9785
commit 26a253abf6
3 changed files with 749 additions and 0 deletions

View File

@ -3,6 +3,7 @@ package vassistent.bootstrap;
import com.formdev.flatlaf.FlatDarkLaf; import com.formdev.flatlaf.FlatDarkLaf;
import vassistent.model.AppState; import vassistent.model.AppState;
import vassistent.service.*; import vassistent.service.*;
import vassistent.util.AppConfigValidator;
import vassistent.util.ConfigLoader; import vassistent.util.ConfigLoader;
import javax.swing.*; import javax.swing.*;
@ -25,6 +26,8 @@ public class ApplicationInitializer {
*/ */
public static ApplicationContext initialize() { public static ApplicationContext initialize() {
AppConfigValidator.validate(config);
ApplicationContext context = new ApplicationContext(); ApplicationContext context = new ApplicationContext();
// ===== Model State ===== // ===== Model State =====

View File

@ -0,0 +1,591 @@
package vassistent.util;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
/**
* Validates startup configuration and fails fast on invalid values.
*/
public final class AppConfigValidator {
private static final Set<String> SUPPORTED_MQTT_SCHEMES =
Set.of("tcp", "ssl", "ws", "wss");
/**
* Utility class constructor.
*/
private AppConfigValidator() {}
/**
* Validates all required startup configuration values.
*
* @param config loaded application properties
*/
public static void validate(Properties config) {
Objects.requireNonNull(config, "config must not be null");
List<String> errors = new ArrayList<>();
validateRequiredMqttSettings(config, errors);
validateCoreUiAndOutputSettings(config, errors);
boolean mqttSimulatorEnabled =
readBoolean(config, errors, "mqtt_sim.enabled", false);
boolean unrealEnabled =
readBoolean(config, errors, "unreal.enabled", false);
if (mqttSimulatorEnabled) {
validateMqttSimulatorSettings(config, errors);
}
if (unrealEnabled) {
validateUnrealSettings(config, errors);
}
if (!errors.isEmpty()) {
throw new IllegalStateException(buildValidationMessage(errors));
}
}
/**
* Validates mandatory MQTT runtime keys.
*
* @param config loaded properties
* @param errors collected validation errors
*/
private static void validateRequiredMqttSettings(
Properties config,
List<String> errors
) {
validateRequiredNonBlank(config, errors, "mqtt.topic");
validateRequiredNonBlank(config, errors, "mqtt.client.id");
String brokerUrl =
validateRequiredNonBlank(config, errors, "mqtt.broker.url");
if (brokerUrl == null) {
return;
}
try {
URI uri = URI.create(brokerUrl);
String scheme = uri.getScheme();
if (scheme == null
|| !SUPPORTED_MQTT_SCHEMES.contains(
scheme.toLowerCase(Locale.ROOT))) {
errors.add(
"Property 'mqtt.broker.url' must use one of "
+ SUPPORTED_MQTT_SCHEMES + ": "
+ brokerUrl
);
}
if (uri.getHost() == null || uri.getHost().isBlank()) {
errors.add(
"Property 'mqtt.broker.url' must include a host: "
+ brokerUrl
);
}
int port = uri.getPort();
if (port != -1 && (port < 1 || port > 65535)) {
errors.add(
"Property 'mqtt.broker.url' contains invalid port "
+ port + ": " + brokerUrl
);
}
} catch (Exception e) {
errors.add(
"Property 'mqtt.broker.url' is not a valid URI: " + brokerUrl
);
}
}
/**
* Validates UI and animation output settings.
*
* @param config loaded properties
* @param errors collected validation errors
*/
private static void validateCoreUiAndOutputSettings(
Properties config,
List<String> errors
) {
String streamingUrl =
validateRequiredNonBlank(config, errors, "streaming.url");
if (streamingUrl != null) {
try {
URI uri = URI.create(streamingUrl);
String scheme = uri.getScheme();
if (scheme == null
|| (!"http".equalsIgnoreCase(scheme)
&& !"https".equalsIgnoreCase(scheme))) {
errors.add(
"Property 'streaming.url' must start with http:// or https://: "
+ streamingUrl
);
}
} catch (Exception e) {
errors.add(
"Property 'streaming.url' is not a valid URI: " + streamingUrl
);
}
}
validateRequiredNonBlank(config, errors, "animation.output.path");
}
/**
* Validates simulator settings that are required only when simulator startup is enabled.
*
* @param config loaded properties
* @param errors collected validation errors
*/
private static void validateMqttSimulatorSettings(
Properties config,
List<String> errors
) {
validateExistingFile(config, errors, "python.path");
validateExistingFile(config, errors, "mqtt_sim.script");
validateRequiredNonBlank(config, errors, "mqtt_sim.broker");
validateRequiredIntegerInRange(config, errors, "mqtt_sim.port", 1, 65535);
validateOptionalIntegerInRange(config, errors, "mqtt_sim.qos", 0, 2);
validateOptionalPositiveDouble(config, errors, "mqtt_sim.interval_seconds");
}
/**
* Validates Unreal-related settings that are required only when Unreal startup is enabled.
*
* @param config loaded properties
* @param errors collected validation errors
*/
private static void validateUnrealSettings(
Properties config,
List<String> errors
) {
validateExistingFile(config, errors, "unreal.executable");
validateExistingFile(config, errors, "unreal.signalling_server.script");
validateExistingFile(config, errors, "unreal.target.executable");
validateExistingDirectory(config, errors, "unreal.target.working_dir");
validateOptionalExistingDirectory(config, errors, "unreal.pid.dir");
validateOptionalNonNegativeInteger(config, errors, "unreal.startup.delay.seconds");
validateOptionalPidFileParent(config, errors, "unreal.pid.file");
validateOptionalPidFileParent(config, errors, "unreal.signalling.pid.file");
}
/**
* Validates that a required property exists and is not blank.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
* @return normalized value or {@code null} when missing/blank
*/
private static String validateRequiredNonBlank(
Properties config,
List<String> errors,
String key
) {
String value = normalizeConfigValue(config.getProperty(key));
if (value == null || value.isBlank()) {
errors.add("Missing required property '" + key + "'.");
return null;
}
return value;
}
/**
* Validates that a property points to an existing regular file.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
*/
private static void validateExistingFile(
Properties config,
List<String> errors,
String key
) {
String rawValue = config.getProperty(key);
String normalized = validateRequiredNonBlank(config, errors, key);
if (normalized == null) {
return;
}
Path path = parsePath(rawValue, normalized, key, errors);
if (path == null) {
return;
}
if (!Files.isRegularFile(path)) {
errors.add(
"Property '" + key + "' must reference an existing file: " + normalized
);
}
}
/**
* Validates that a property points to an existing directory.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
*/
private static void validateExistingDirectory(
Properties config,
List<String> errors,
String key
) {
String rawValue = config.getProperty(key);
String normalized = validateRequiredNonBlank(config, errors, key);
if (normalized == null) {
return;
}
Path path = parsePath(rawValue, normalized, key, errors);
if (path == null) {
return;
}
if (!Files.isDirectory(path)) {
errors.add(
"Property '" + key + "' must reference an existing directory: " + normalized
);
}
}
/**
* Validates an optional directory property when a value is provided.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
*/
private static void validateOptionalExistingDirectory(
Properties config,
List<String> errors,
String key
) {
String rawValue = config.getProperty(key);
String normalized = normalizeConfigValue(rawValue);
if (normalized == null || normalized.isBlank()) {
return;
}
Path path = parsePath(rawValue, normalized, key, errors);
if (path == null) {
return;
}
if (!Files.isDirectory(path)) {
errors.add(
"Property '" + key + "' must reference an existing directory: " + normalized
);
}
}
/**
* Validates an optional integer property with a minimum/maximum range.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
* @param min minimum valid value
* @param max maximum valid value
*/
private static void validateOptionalIntegerInRange(
Properties config,
List<String> errors,
String key,
int min,
int max
) {
String value = normalizeConfigValue(config.getProperty(key));
if (value == null || value.isBlank()) {
return;
}
try {
int parsed = Integer.parseInt(value);
if (parsed < min || parsed > max) {
errors.add(
"Property '" + key + "' must be between "
+ min + " and " + max + ", but was " + parsed + "."
);
}
} catch (NumberFormatException e) {
errors.add(
"Property '" + key + "' must be an integer, but was '" + value + "'."
);
}
}
/**
* Validates a required integer property with a minimum/maximum range.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
* @param min minimum valid value
* @param max maximum valid value
*/
private static void validateRequiredIntegerInRange(
Properties config,
List<String> errors,
String key,
int min,
int max
) {
String value = validateRequiredNonBlank(config, errors, key);
if (value == null) {
return;
}
try {
int parsed = Integer.parseInt(value);
if (parsed < min || parsed > max) {
errors.add(
"Property '" + key + "' must be between "
+ min + " and " + max + ", but was " + parsed + "."
);
}
} catch (NumberFormatException e) {
errors.add(
"Property '" + key + "' must be an integer, but was '" + value + "'."
);
}
}
/**
* Validates an optional numeric property that must be greater than zero.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
*/
private static void validateOptionalPositiveDouble(
Properties config,
List<String> errors,
String key
) {
String value = normalizeConfigValue(config.getProperty(key));
if (value == null || value.isBlank()) {
return;
}
try {
double parsed = Double.parseDouble(value);
if (parsed <= 0.0) {
errors.add(
"Property '" + key + "' must be greater than 0, but was " + parsed + "."
);
}
} catch (NumberFormatException e) {
errors.add(
"Property '" + key + "' must be numeric, but was '" + value + "'."
);
}
}
/**
* Validates an optional integer that must be non-negative.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
*/
private static void validateOptionalNonNegativeInteger(
Properties config,
List<String> errors,
String key
) {
String value = normalizeConfigValue(config.getProperty(key));
if (value == null || value.isBlank()) {
return;
}
try {
int parsed = Integer.parseInt(value);
if (parsed < 0) {
errors.add(
"Property '" + key + "' must be >= 0, but was " + parsed + "."
);
}
} catch (NumberFormatException e) {
errors.add(
"Property '" + key + "' must be an integer, but was '" + value + "'."
);
}
}
/**
* Validates that an optional PID file path has an existing parent directory.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
*/
private static void validateOptionalPidFileParent(
Properties config,
List<String> errors,
String key
) {
String rawValue = config.getProperty(key);
String normalized = normalizeConfigValue(rawValue);
if (normalized == null || normalized.isBlank()) {
return;
}
Path path = parsePath(rawValue, normalized, key, errors);
if (path == null) {
return;
}
Path parent = path.getParent();
if (parent != null && !Files.isDirectory(parent)) {
errors.add(
"Parent directory for '" + key + "' does not exist: " + parent
);
}
}
/**
* Validates a boolean property using strict true/false values.
*
* @param config loaded properties
* @param errors collected validation errors
* @param key property key
* @param defaultValue fallback when value is absent/blank
* @return parsed boolean value or default value
*/
private static boolean readBoolean(
Properties config,
List<String> errors,
String key,
boolean defaultValue
) {
String value = normalizeConfigValue(config.getProperty(key));
if (value == null || value.isBlank()) {
return defaultValue;
}
if ("true".equalsIgnoreCase(value)) {
return true;
}
if ("false".equalsIgnoreCase(value)) {
return false;
}
errors.add(
"Property '" + key + "' must be 'true' or 'false', but was '" + value + "'."
);
return defaultValue;
}
/**
* Parses a path value and reports invalid quoting/path syntax.
*
* @param rawValue raw property value
* @param normalized normalized property value
* @param key property key
* @param errors collected validation errors
* @return parsed path or {@code null} when invalid
*/
private static Path parsePath(
String rawValue,
String normalized,
String key,
List<String> errors
) {
if (hasMismatchedWrappingQuote(rawValue)) {
errors.add(
"Property '" + key + "' has mismatched wrapping quotes: " + rawValue
);
}
try {
return Path.of(normalized);
} catch (InvalidPathException e) {
errors.add(
"Property '" + key + "' contains an invalid path: " + normalized
);
return null;
}
}
/**
* Detects mismatched leading/trailing single or double quote wrappers.
*
* @param rawValue raw property value
* @return {@code true} when wrapper quotes are mismatched
*/
private static boolean hasMismatchedWrappingQuote(String rawValue) {
if (rawValue == null) {
return false;
}
String trimmed = rawValue.trim();
if (trimmed.isEmpty()) {
return false;
}
boolean startsDouble = trimmed.startsWith("\"");
boolean endsDouble = trimmed.endsWith("\"");
boolean startsSingle = trimmed.startsWith("'");
boolean endsSingle = trimmed.endsWith("'");
return startsDouble != endsDouble || startsSingle != endsSingle;
}
/**
* Trims config values and removes matching single/double quote wrappers.
*
* @param value raw config value
* @return normalized value or {@code null}
*/
private static String normalizeConfigValue(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.substring(1, trimmed.length() - 1);
}
return trimmed;
}
/**
* Builds a single human-readable validation exception message.
*
* @param errors collected validation errors
* @return formatted message
*/
private static String buildValidationMessage(List<String> errors) {
StringBuilder message = new StringBuilder(
"Configuration validation failed (" + errors.size() + " issue(s)):"
);
for (int i = 0; i < errors.size(); i++) {
message.append(System.lineSeparator())
.append(i + 1)
.append(". ")
.append(errors.get(i));
}
return message.toString();
}
}

View File

@ -0,0 +1,155 @@
package vassistent.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for {@link AppConfigValidator}.
*/
class AppConfigValidatorTest {
@TempDir
Path tempDir;
/**
* Verifies that a minimal core configuration passes validation.
*/
@Test
void validate_withCoreSettingsOnly_doesNotThrow() {
Properties config = baseConfig();
assertDoesNotThrow(() -> AppConfigValidator.validate(config));
}
/**
* Verifies strict boolean validation for feature toggles.
*/
@Test
void validate_withInvalidBooleanToggle_throws() {
Properties config = baseConfig();
config.setProperty("mqtt_sim.enabled", "yes");
IllegalStateException exception =
assertThrows(
IllegalStateException.class,
() -> AppConfigValidator.validate(config)
);
assertTrue(exception.getMessage().contains("mqtt_sim.enabled"));
}
/**
* Verifies simulator-specific numeric validation when simulator is enabled.
*
* @throws Exception if test files cannot be created
*/
@Test
void validate_withMqttSimulatorInvalidPort_throws() throws Exception {
Properties config = baseConfig();
Path pythonExe = Files.createFile(tempDir.resolve("python.exe"));
Path simulatorScript = Files.createFile(tempDir.resolve("mqtt_simulator.py"));
config.setProperty("mqtt_sim.enabled", "true");
config.setProperty("python.path", pythonExe.toString());
config.setProperty("mqtt_sim.script", simulatorScript.toString());
config.setProperty("mqtt_sim.broker", "localhost");
config.setProperty("mqtt_sim.port", "99999");
IllegalStateException exception =
assertThrows(
IllegalStateException.class,
() -> AppConfigValidator.validate(config)
);
assertTrue(exception.getMessage().contains("mqtt_sim.port"));
}
/**
* Verifies Unreal validation detects paths with mismatched quote wrappers.
*
* @throws Exception if test files cannot be created
*/
@Test
void validate_withUnrealSignallingPathTrailingQuote_throws() throws Exception {
Properties config = baseConfig();
Path launcherScript = Files.createFile(tempDir.resolve("start_avatar.ps1"));
Path signallingScript = Files.createFile(tempDir.resolve("start_with_stun.bat"));
Path unrealExe = Files.createFile(tempDir.resolve("Prototyp1.exe"));
Path unrealWorkDir = Files.createDirectory(tempDir.resolve("Windows"));
Path pidDir = Files.createDirectory(tempDir.resolve("pids"));
config.setProperty("unreal.enabled", "true");
config.setProperty("unreal.executable", launcherScript.toString());
config.setProperty(
"unreal.signalling_server.script",
signallingScript.toString() + "\""
);
config.setProperty("unreal.target.executable", unrealExe.toString());
config.setProperty("unreal.target.working_dir", unrealWorkDir.toString());
config.setProperty("unreal.pid.dir", pidDir.toString());
IllegalStateException exception =
assertThrows(
IllegalStateException.class,
() -> AppConfigValidator.validate(config)
);
assertTrue(exception.getMessage().contains("unreal.signalling_server.script"));
assertTrue(exception.getMessage().toLowerCase().contains("quote"));
}
/**
* Verifies Unreal validation accepts a complete and valid local setup.
*
* @throws Exception if test files cannot be created
*/
@Test
void validate_withUnrealEnabledAndValidPaths_doesNotThrow() throws Exception {
Properties config = baseConfig();
Path launcherScript = Files.createFile(tempDir.resolve("start_avatar.ps1"));
Path signallingScript = Files.createFile(tempDir.resolve("start_with_stun.bat"));
Path unrealExe = Files.createFile(tempDir.resolve("Prototyp1.exe"));
Path unrealWorkDir = Files.createDirectory(tempDir.resolve("Windows"));
Path pidDir = Files.createDirectory(tempDir.resolve("pids"));
config.setProperty("unreal.enabled", "true");
config.setProperty("unreal.executable", launcherScript.toString());
config.setProperty("unreal.signalling_server.script", signallingScript.toString());
config.setProperty("unreal.target.executable", unrealExe.toString());
config.setProperty("unreal.target.working_dir", unrealWorkDir.toString());
config.setProperty("unreal.pid.dir", pidDir.toString());
config.setProperty("unreal.startup.delay.seconds", "5");
config.setProperty("unreal.pid.file", pidDir.resolve("unreal.pid").toString());
config.setProperty(
"unreal.signalling.pid.file",
pidDir.resolve("signalling.pid").toString()
);
assertDoesNotThrow(() -> AppConfigValidator.validate(config));
}
/**
* Creates a baseline config with optional integrations disabled.
*
* @return baseline application configuration
*/
private Properties baseConfig() {
Properties config = new Properties();
config.setProperty("mqtt.topic", "PREDICTION");
config.setProperty("mqtt.broker.url", "tcp://localhost:1883");
config.setProperty("mqtt.client.id", "JavaClientPublisherSubscriber");
config.setProperty("streaming.url", "http://localhost");
config.setProperty("animation.output.path", "data/animation.json");
config.setProperty("mqtt_sim.enabled", "false");
config.setProperty("unreal.enabled", "false");
return config;
}
}