diff --git a/core/README-CORE.md b/core/README-CORE.md index d1b7b466..7e8e5ae4 100644 --- a/core/README-CORE.md +++ b/core/README-CORE.md @@ -2,133 +2,41 @@ ## Überblick -Das Core-Modul bildet das Fundament des gesamten Meldestelle-Systems und implementiert den **Shared Kernel** nach Domain-Driven Design Prinzipien. Es stellt gemeinsame Domänenkonzepte, Utilities und Infrastrukturkomponenten bereit, die von allen anderen Modulen (members, horses, events, masterdata, infrastructure) verwendet werden. +Das Core-Modul bildet das Fundament des gesamten Meldestelle-Systems und implementiert den **Shared Kernel** nach Domain-Driven Design Prinzipien. Es stellt gemeinsame, domänen-agnostische Konzepte, Utilities und Infrastrukturkomponenten bereit, die von allen anderen Modulen verwendet werden. ## Architektur -Das Core-Modul ist nach den Prinzipien der Clean Architecture in zwei Hauptkomponenten unterteilt: +Das Modul ist nach den Prinzipien der Clean Architecture in zwei Hauptkomponenten unterteilt: - -``` -core/ -├── core-domain/ # Shared Domain Layer -│ ├── model/ # Gemeinsame Domain Models -│ │ ├── BaseDto.kt # Basis-DTO-Klassen -│ │ └── Enums.kt # Gemeinsame Enumerationen -│ ├── serialization/ # Serialisierung -│ │ └── Serializers.kt # Custom Serializers -│ └── event/ # Domain Events -│ └── DomainEvent.kt # Event-Infrastruktur -└── core-utils/ # Shared Utilities - ├── config/ # Konfiguration - │ ├── AppConfig.kt # Anwendungskonfiguration - │ └── AppEnvironment.kt # Umgebungskonfiguration - ├── database/ # Datenbank-Utilities - │ ├── DatabaseConfig.kt # Datenbank-Konfiguration - │ └── DatabaseFactory.kt # Datenbank-Factory - ├── discovery/ # Service Discovery - │ └── ServiceRegistration.kt # Service-Registrierung - ├── error/ # Fehlerbehandlung - │ └── Result.kt # Result-Type für Fehlerbehandlung - ├── serialization/ # Serialisierung - │ └── Serialization.kt # Serialisierungs-Utilities - └── validation/ # Validierung - ├── ValidationResult.kt # Validierungsergebnisse - ├── ValidationUtils.kt # Validierungs-Utilities - └── ApiValidationUtils.kt # API-Validierung -``` +* **`:core-domain`**: Der "reine" Teil des Kernels. Enthält nur Datenstrukturen und Interfaces ohne externe Abhängigkeiten. +* **`:core-utils`**: Stellt technische Hilfsfunktionen und konkrete Implementierungen bereit, die auf dem `core-domain` aufbauen. ## Core-Domain Komponenten -### 1. Gemeinsame Enumerationen (`Enums.kt`) -Zentrale Enumerationen, die modulübergreifend verwendet werden, um eine konsistente "Ubiquitäre Sprache" zu etablieren. Dazu gehören `SparteE`, `PferdeGeschlechtE`, `RolleE` und `BerechtigungE`. +Dieses Modul hat eine **minimale Oberfläche**, um eine maximale Entkopplung der Fach-Services zu gewährleisten. -### 2. Basis-DTOs (`BaseDto.kt`) -Gemeinsame Basisklassen für Data Transfer Objects, die eine konsistente API-Struktur im gesamten System sicherstellen, wie `ApiResponse` für Standard-Antworten und `PagedResponse` für paginierte Listen. - -### 3. Domain Events (`DomainEvent.kt`) -Die Event-Sourcing Infrastruktur für Domain-Driven Design. Definiert die Basis-Interfaces (`DomainEvent`, `DomainEventPublisher`, `DomainEventHandler`) für die asynchrone Kommunikation zwischen den Services. - -### 4. Custom Serializers (`Serializers.kt`) -Spezialisierte Serializer für Kotlin-Typen wie `Uuid`, `Instant` und `LocalDate`, um eine einheitliche JSON-Serialisierung über alle Services hinweg zu garantieren. +* **`BaseDto.kt`**: Definiert standardisierte DTOs (Data Transfer Objects) wie `ApiResponse` und `PagedResponse`, um eine konsistente API-Struktur im gesamten System sicherzustellen. +* **`DomainEvent.kt`**: Stellt die Basis-Infrastruktur für Domänen-Events (`DomainEvent`, `BaseDomainEvent`) bereit, die für eine asynchrone, ereignisgesteuerte Kommunikation unerlässlich ist. +* **`Enums.kt`**: Enthält ausschließlich fundamental querschnittliche Enums. Nach einem Refactoring verbleibt hier nur noch `DatenQuelleE`, da es die Herkunft von Daten beschreibt – ein Konzept, das für alle Domänen relevant ist. Domänenspezifische Enums (z.B. für Pferderassen oder Disziplinen) wurden bewusst entfernt. +* **`Serializers.kt`**: Bietet benutzerdefinierte Serializer für `kotlinx.serialization`, um Typen wie `Uuid` und `Instant` systemweit konsistent in JSON umzuwandeln. ## Core-Utils Komponenten -### 1. Fehlerbehandlung (`Result.kt`) -Eine funktionale und typsichere `Result`-Klasse zur Behandlung von vorhersehbaren Geschäftsfehlern ohne den Einsatz von Exceptions. Dies führt zu robusterem und besser lesbarem Code in den Anwendungs-Services. +* **Konfiguration (`config/`)**: + * **`ConfigLoader.kt`**: Implementiert ein sauberes Muster zur Entkopplung der Konfigurations-Ladelogik. Er liest `.properties`-Dateien und Umgebungsvariablen. + * **`AppConfig.kt`**: Dient als reine, unveränderliche Datenklasse, die die vom `ConfigLoader` geladenen Werte enthält. Dieses Muster verbessert die Testbarkeit erheblich. +* **Datenbank (`database/`)**: + * **`DatabaseFactory.kt`**: Eine robuste Factory zur Verwaltung von Datenbankverbindungen mit einem hoch-performanten Connection Pool (HikariCP) und automatischer Datenbank-Migration durch den Industriestandard **Flyway**. +* **Fehlerbehandlung (`error/`)**: + * **`Result.kt`**: Eine typsichere, versiegelte Klasse (`sealed class`) für funktionales Error-Handling, die den übermäßigen Einsatz von Exceptions für erwartete Geschäftsfehler vermeidet. +* **Validierung (`validation/`)**: + * **`ValidationResult.kt`**: Eine vereinheitlichte, serialisierbare Datenstruktur (`ValidationResult`, `ValidationError`) zur systemweiten, konsistenten Kommunikation von Validierungsfehlschlägen über API-Grenzen hinweg. -### 2. Konfiguration (`AppConfig.kt`, `AppEnvironment.kt`) -Eine zentrale und flexible Konfigurationsverwaltung, die Einstellungen aus verschiedenen Quellen (Umgebungsvariablen, Property-Dateien) für unterschiedliche Umgebungen (`DEVELOPMENT`, `PRODUCTION` etc.) laden kann. +## Testing-Strategie -### 3. Datenbank-Utilities (`DatabaseFactory.kt`, `DatabaseConfig.kt`) -Stellt die zentrale Logik für Datenbankverbindungen bereit. Die `DatabaseFactory` konfiguriert einen hoch-performanten Connection Pool (HikariCP) und integriert das Industrie-Standard-Tool **Flyway** für die automatische Ausführung von versionierten SQL-Datenbankmigrationen beim Start eines Service. +Das `core`-Modul ist durch eine umfassende Suite von Unit- und Integrationstests abgesichert, die einen hohen Qualitätsstandard setzen. -### 4. Validierung (`ValidationUtils.kt`, `ApiValidationUtils.kt`) -Eine umfassende Sammlung von wiederverwendbaren Hilfsfunktionen zur Validierung von Daten, von einfachen Längenprüfungen bis hin zu komplexen API-Parameter-Validierungen. - -### 5. Service Discovery (`ServiceRegistration.kt`) -Eine Implementierung zur Registrierung von Microservices bei einem Consul-Server, um eine dynamische Service-Landschaft zu ermöglichen. - -## Verwendung in anderen Modulen - -Andere Module deklarieren eine Abhängigkeit zum `core`-Modul, um auf dessen Bausteine zugreifen zu können. - -### API Responses -```kotlin -// In API Controllers -@RestController -class MemberController { - - @GetMapping("/api/members/{id}") - fun getMember(@PathVariable id: String): ApiResponse { - // ... - } -} -``` - -### Result-Type Usage - -```kotlin -// In Use Cases -class CreateMemberUseCase { - suspend fun execute(member: Member): Result { - // ... - } -} -``` - -## Best Practices - -### 1. Shared Kernel Prinzipien - -- **Minimale Oberfläche**: Nur wirklich gemeinsame Konzepte -- **Stabile APIs**: Änderungen beeinflussen alle Module -- **Versionierung**: Sorgfältige Versionierung bei Breaking Changes -- **Dokumentation**: Umfassende Dokumentation für alle Komponenten - -### 2. Fehlerbehandlung - -- **Result-Type verwenden**: Statt Exceptions für erwartete Geschäftsfehler, um die Geschäftslogik klar und explizit zu halten. -- **Validierung**: Frühe Validierung mit ValidationResult - -### 3. Serialisierung - -- **Custom Serializers**: Für spezielle Datentypen werden die bereitgestellten Serializer verwendet, um Konsistenz zu gewährleisten. -- **Schema-Evolution**: Bei der Weiterentwicklung von DTOs und Events muss die Abwärtskompatibilität berücksichtigt werden. - -## Zukünftige Erweiterungen - -1. **Event Sourcing Enhancements** - Erweiterte Event Store Features -2. **Distributed Tracing** - OpenTelemetry Integration -3. **Metrics Collection** - Micrometer Integration -4. **Configuration Management** - Externalized Configuration -5. **Security Utilities** - Encryption/Decryption Utilities -6. **Caching Abstractions** - Cache-Provider Abstractions -7. **Async Utilities** - Coroutines Utilities -8. **Testing Utilities** - Test-Helpers und Fixtures +* **Unit-Tests**: Kritische Komponenten wie der `ConfigLoader`, die Serializer und die `ApiResponse`-Logik sind durch Unit-Tests abgedeckt. +* **Datenbank-Tests (Goldstandard)**: Die Datenbanklogik wird nicht gegen eine ungenaue In-Memory-Datenbank (wie H2) getestet. Stattdessen wird **Testcontainers** verwendet, um für jeden Testlauf eine echte **PostgreSQL-Datenbank** in einem Docker-Container zu starten. Dies garantiert 100%ige Kompatibilität zwischen Test- und Produktionsumgebung. --- - -**Letzte Aktualisierung**: 28. Juli 2025 - -Für weitere Informationen zur Gesamtarchitektur siehe [README.md](../README.md). diff --git a/core/core-domain/src/main/kotlin/at/mocode/core/domain/model/Enums.kt b/core/core-domain/src/main/kotlin/at/mocode/core/domain/model/Enums.kt index 34f8a14b..1792e202 100644 --- a/core/core-domain/src/main/kotlin/at/mocode/core/domain/model/Enums.kt +++ b/core/core-domain/src/main/kotlin/at/mocode/core/domain/model/Enums.kt @@ -3,105 +3,13 @@ package at.mocode.core.domain.model import kotlinx.serialization.Serializable /** - * Defines the source of a data record. + * Defines the source of a data record. This is a cross-cutting concern + * and therefore part of the Shared Kernel. */ @Serializable enum class DatenQuelleE { - MANUELL, // Manually entered - IMPORT_ZNS, // Imported from OEPS ZNS data - SYSTEM_GENERATED // Generated by the system itself -} - -/** - * Defines the gender of a horse. - */ -@Serializable -enum class PferdeGeschlechtE { - HENGST, // Male, not castrated - STUTE, // Female - WALLACH // Male, castrated -} - -/** - * Person gender enumeration - */ -@Serializable -enum class GeschlechtE { M, W, D, UNBEKANNT } - -/** - * Defines the different equestrian disciplines (Sparten). - * This enum is a central part of the Ubiquitous Language. - */ -@Serializable -enum class SparteE { - DRESSUR, // Dressurreiten - SPRINGEN, // Springreiten - VIELSEITIGKEIT, // Vielseitigkeitsreiten - FAHREN, // Fahrsport - VOLTIGIEREN, // Voltigieren - WESTERN, // Westernreiten - DISTANZ, // Distanzreiten - PARA_DRESSUR, // Para-Dressur - ISLAND; // Islandpferde -} - -/** - * Venue/place type enumeration - */ -@Serializable -enum class PlatzTypE { AUSTRAGUNG, VORBEREITUNG, LONGIEREN, SONSTIGES } - -/** - * User role enumeration for member management - */ -@Serializable -enum class RolleE { - ADMIN, // System administrator - VEREINS_ADMIN, // Club administrator - FUNKTIONAER, // Official/functionary - REITER, // Rider - TRAINER, // Trainer - RICHTER, // Judge - TIERARZT, // Veterinarian - ZUSCHAUER, // Spectator - GAST // Guest -} - -/** - * Permission enumeration for access control - */ -@Serializable -enum class BerechtigungE { - // Person management - PERSON_READ, - PERSON_CREATE, - PERSON_UPDATE, - PERSON_DELETE, - - // Club management - VEREIN_READ, - VEREIN_CREATE, - VEREIN_UPDATE, - VEREIN_DELETE, - - // Event management - VERANSTALTUNG_READ, - VERANSTALTUNG_CREATE, - VERANSTALTUNG_UPDATE, - VERANSTALTUNG_DELETE, - - // Horse management - PFERD_READ, - PFERD_CREATE, - PFERD_UPDATE, - PFERD_DELETE, - - // Master data management - STAMMDATEN_READ, - STAMMDATEN_UPDATE, - - // System administration - SYSTEM_ADMIN, - BENUTZER_VERWALTEN, - ROLLEN_VERWALTEN + MANUELL, + IMPORT_ZNS, + SYSTEM_GENERATED, + IMPORT_API } diff --git a/core/core-domain/src/test/kotlin/at/mocode/core/domain/ApiResponseTest.kt b/core/core-domain/src/test/kotlin/at/mocode/core/domain/ApiResponseTest.kt new file mode 100644 index 00000000..1f72db81 --- /dev/null +++ b/core/core-domain/src/test/kotlin/at/mocode/core/domain/ApiResponseTest.kt @@ -0,0 +1,67 @@ +package at.mocode.core.domain + +import at.mocode.core.domain.model.ApiResponse +import at.mocode.core.domain.model.ErrorDto +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ApiResponseTest { + + @Test + fun `ApiResponse success should create a successful response with data`() { + // Arrange + val testData = "This is a test" + + // Act + val response = ApiResponse.success(testData) + + // Assert + assertTrue(response.success, "Response should be successful") + assertEquals(testData, response.data, "Response data should match the input data") + assertTrue(response.errors.isEmpty(), "Errors list should be empty for a successful response") + assertNotNull(response.timestamp, "Timestamp should be generated") + } + + @Test + fun `ApiResponse error with single message should create a failed response with one error`() { + // Arrange + val errorCode = "NOT_FOUND" + val errorMessage = "The requested resource was not found." + val errorField = "resourceId" + + // Act + val response = ApiResponse.error(errorCode, errorMessage, errorField) + + // Assert + assertFalse(response.success, "Response should not be successful") + assertNull(response.data, "Data should be null for a failed response") + assertEquals(1, response.errors.size, "Should contain exactly one error") + + val error = response.errors.first() + assertEquals(errorCode, error.code, "Error code should match") + assertEquals(errorMessage, error.message, "Error message should match") + assertEquals(errorField, error.field, "Error field should match") + } + + @Test + fun `ApiResponse error with list should create a failed response with multiple errors`() { + // Arrange + val errors = listOf( + ErrorDto("INVALID_INPUT", "Username cannot be empty.", "username"), + ErrorDto("INVALID_INPUT", "Password is too short.", "password") + ) + + // Act + val response = ApiResponse.error(errors) + + // Assert + assertFalse(response.success, "Response should not be successful") + assertNull(response.data, "Data should be null for a failed response") + assertEquals(2, response.errors.size, "Should contain two errors") + assertEquals(errors, response.errors, "The error list should match the input list") + } +} diff --git a/core/core-domain/src/test/kotlin/at/mocode/core/domain/DomainEventTest.kt b/core/core-domain/src/test/kotlin/at/mocode/core/domain/DomainEventTest.kt new file mode 100644 index 00000000..711b1753 --- /dev/null +++ b/core/core-domain/src/test/kotlin/at/mocode/core/domain/DomainEventTest.kt @@ -0,0 +1,51 @@ +package at.mocode.core.domain + +import at.mocode.core.domain.event.BaseDomainEvent +import com.benasher44.uuid.Uuid +import com.benasher44.uuid.uuid4 +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +class DomainEventTest { + + /** + * Eine konkrete Implementierung eines Domänen-Events zu Testzwecken. + * Repräsentiert das Ereignis, dass eine Test-Entität erstellt wurde. + * + * @param aggregateId Die ID der Entität, auf die sich das Event bezieht. + * @param version Die Versionsnummer des Events für dieses Aggregat. + * @param testPayload Ein zusätzliches Datenfeld, das für den Test relevant ist. + */ + @Serializable + data class TestEvent( + @Transient + override val aggregateId: Uuid = uuid4(), + @Transient + override val version: Long = 1L, + val testPayload: String = "Test" + ) : BaseDomainEvent( + aggregateId = aggregateId, + eventType = "TestEventOccurred", // Ein klar definierter Event-Typ + version = version + ) + + @Test + fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() { + // Arrange + val aggregateId = uuid4() + val version = 1L + + // Act + val event = TestEvent(aggregateId, version) + + // Assert + assertNotNull(event.eventId, "eventId should be automatically generated and not null") + assertNotNull(event.timestamp, "timestamp should be automatically generated and not null") + assertEquals(aggregateId, event.aggregateId, "aggregateId should be set correctly") + assertEquals(version, event.version, "version should be set correctly") + assertEquals("TestEventOccurred", event.eventType, "eventType should be set correctly") + } +} diff --git a/core/core-domain/src/test/kotlin/at/mocode/core/domain/SerializersTest.kt b/core/core-domain/src/test/kotlin/at/mocode/core/domain/SerializersTest.kt new file mode 100644 index 00000000..0ee63432 --- /dev/null +++ b/core/core-domain/src/test/kotlin/at/mocode/core/domain/SerializersTest.kt @@ -0,0 +1,78 @@ +package at.mocode.core.domain + +import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.KotlinLocalDateSerializer +import at.mocode.core.domain.serialization.KotlinLocalDateTimeSerializer +import at.mocode.core.domain.serialization.KotlinLocalTimeSerializer +import at.mocode.core.domain.serialization.UuidSerializer +import com.benasher44.uuid.Uuid +import com.benasher44.uuid.uuid4 +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.time.Clock +import kotlin.time.Instant + +class SerializersTest { + + private val json = Json // Standard-Json-Konfiguration für die Tests + + // Hilfsklasse, um die Serializer im Kontext von kotlinx.serialization zu testen + @Serializable + data class TestContainer( + @Serializable(with = UuidSerializer::class) val uuid: Uuid, + @Serializable(with = KotlinInstantSerializer::class) val instant: Instant, + @Serializable(with = KotlinLocalDateSerializer::class) val localDate: LocalDate, + @Serializable(with = KotlinLocalDateTimeSerializer::class) val localDateTime: LocalDateTime, + @Serializable(with = KotlinLocalTimeSerializer::class) val localTime: LocalTime + ) + + @Test + fun `all custom serializers should correctly serialize and deserialize`() { + // Arrange + val originalObject = TestContainer( + uuid = uuid4(), + instant = Clock.System.now(), + localDate = LocalDate(2025, 8, 5), + localDateTime = LocalDateTime(2025, 8, 5, 12, 30, 0), + localTime = LocalTime(12, 30, 0) + ) + + // Act: Serialize + val jsonString = json.encodeToString(TestContainer.serializer(), originalObject) + + // Assert: Serialization + // Wir prüfen, ob die serialisierten Werte einfache Strings sind, wie erwartet. + assertTrue( + jsonString.contains("\"uuid\":\"${originalObject.uuid}\""), + "Serialized JSON should contain the UUID as a string" + ) + assertTrue( + jsonString.contains("\"instant\":\"${originalObject.instant}\""), + "Serialized JSON should contain the Instant as a string" + ) + assertTrue( + jsonString.contains("\"localDate\":\"2025-08-05\""), + "Serialized JSON should contain the LocalDate as a string" + ) + assertTrue( + jsonString.contains("\"localDateTime\":\"2025-08-05T12:30\""), + "Serialized JSON should contain the LocalDateTime as a string" + ) + assertTrue( + jsonString.contains("\"localTime\":\"12:30\""), + "Serialized JSON should contain the LocalTime as a string" + ) + + // Act: Deserialize + val deserializedObject = json.decodeFromString(TestContainer.serializer(), jsonString) + + // Assert: Deserialization + assertEquals(originalObject, deserializedObject, "Deserialized object should be equal to the original object") + } +} diff --git a/core/core-utils/build.gradle.kts b/core/core-utils/build.gradle.kts index bc0a210d..95503b19 100644 --- a/core/core-utils/build.gradle.kts +++ b/core/core-utils/build.gradle.kts @@ -33,10 +33,12 @@ dependencies { api(libs.kotlin.logging.jvm) // Utilities - api(libs.bignum) // Für BigDecimal Serialisierung + api(libs.bignum) + implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung // Testing testImplementation(projects.platform.platformTesting) testImplementation(libs.bundles.testing.jvm) testImplementation(libs.kotlin.test) + testRuntimeOnly(libs.postgresql.driver) } diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt index be1dc6f3..60c8a5f7 100644 --- a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/AppConfig.kt @@ -1,14 +1,10 @@ package at.mocode.core.utils.config -import java.io.File -import java.net.InetAddress -import java.util.Properties - /** - * Zentrale, unveränderliche Konfigurations-Klasse für die Anwendung. - * Hält alle Konfigurationswerte, die beim Start eines Service geladen werden. + * Eine reine, unveränderliche Datenhalte-Klasse für die gesamte Anwendungskonfiguration. + * Wird vom ConfigLoader instanziiert. */ -class AppConfig( +data class AppConfig( val environment: AppEnvironment, val appInfo: AppInfoConfig, val server: ServerConfig, @@ -17,55 +13,9 @@ class AppConfig( val security: SecurityConfig, val logging: LoggingConfig, val rateLimit: RateLimitConfig -) { - companion object { - fun load(): AppConfig { - val environment = AppEnvironment.current() - val props = loadProperties(environment) +) - return AppConfig( - environment = environment, - appInfo = AppInfoConfig.fromProperties(props), - server = ServerConfig.fromProperties(props), - database = DatabaseConfig.fromProperties(props), - serviceDiscovery = ServiceDiscoveryConfig.fromProperties(props), - security = SecurityConfig.fromProperties(props), - logging = LoggingConfig.fromProperties(props, environment), - rateLimit = RateLimitConfig.fromProperties(props) - ) - } - - private fun loadProperties(environment: AppEnvironment): Properties { - val props = Properties() - loadPropertiesFile("application.properties", props) - val envFile = "application-${environment.name.lowercase()}.properties" - loadPropertiesFile(envFile, props) - return props - } - - private fun loadPropertiesFile(filename: String, props: Properties) { - val resourceStream = AppConfig::class.java.classLoader.getResourceAsStream(filename) - if (resourceStream != null) { - resourceStream.use { props.load(it) } - return - } - val file = File("config/$filename") - if (file.exists()) { - file.inputStream().use { props.load(it) } - } - } - } -} - -data class AppInfoConfig(val name: String, val version: String, val description: String) { - companion object { - fun fromProperties(props: Properties) = AppInfoConfig( - name = props.getProperty("app.name", "Meldestelle"), - version = props.getProperty("app.version", "1.0.0"), - description = props.getProperty("app.description", "Pferdesport Meldestelle System") - ) - } -} +data class AppInfoConfig(val name: String, val version: String, val description: String) data class ServerConfig( val port: Int, @@ -74,29 +24,13 @@ data class ServerConfig( val workers: Int, val cors: CorsConfig ) { - companion object { - fun fromProperties(props: Properties): ServerConfig { - val defaultHost = try { InetAddress.getLocalHost().hostAddress } catch (_: Exception) { "127.0.0.1" } - return ServerConfig( - port = props.getIntProperty("server.port", "API_PORT", 8081), - host = props.getStringProperty("server.host", "API_HOST", "0.0.0.0"), - advertisedHost = props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost), - workers = props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors()), - cors = CorsConfig.fromProperties(props) - ) - } - } - data class CorsConfig(val enabled: Boolean, val allowedOrigins: List) { - companion object { - fun fromProperties(props: Properties) = CorsConfig( - enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true), - allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() } ?: listOf("*") - ) - } - } + data class CorsConfig(val enabled: Boolean, val allowedOrigins: List) } data class DatabaseConfig( + val host: String, + val port: Int, + val name: String, val jdbcUrl: String, val username: String, val password: String, @@ -104,71 +38,20 @@ data class DatabaseConfig( val maxPoolSize: Int, val minPoolSize: Int, val autoMigrate: Boolean -) { - companion object { - fun fromProperties(props: Properties): DatabaseConfig { - val host = props.getStringProperty("database.host", "DB_HOST", "localhost") - val port = props.getIntProperty("database.port", "DB_PORT", 5432) - val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db") - return DatabaseConfig( - jdbcUrl = "jdbc:postgresql://$host:$port/$name", - username = props.getStringProperty("database.username", "DB_USER", "meldestelle_user"), - password = props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me"), - driverClassName = "org.postgresql.Driver", - maxPoolSize = props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10), - minPoolSize = props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5), - autoMigrate = props.getBooleanProperty("database.autoMigrate", "DB_AUTO_MIGRATE", true) - ) - } - } -} +) -data class ServiceDiscoveryConfig(val enabled: Boolean, val consulHost: String, val consulPort: Int) { - companion object { - fun fromProperties(props: Properties) = ServiceDiscoveryConfig( - enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true), - consulHost = props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul"), - consulPort = props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500) - ) - } -} +data class ServiceDiscoveryConfig(val enabled: Boolean, val consulHost: String, val consulPort: Int) data class SecurityConfig(val jwt: JwtConfig, val apiKey: String?) { - companion object { - fun fromProperties(props: Properties) = SecurityConfig( - jwt = JwtConfig.fromProperties(props), - apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null } - ) - } - data class JwtConfig(val secret: String, val issuer: String, val audience: String, val realm: String, val expirationInMinutes: Long) { - companion object { - fun fromProperties(props: Properties) = JwtConfig( - secret = props.getStringProperty("security.jwt.secret", "JWT_SECRET", "default-secret-please-change-in-production"), - issuer = props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api"), - audience = props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients"), - realm = props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle"), - expirationInMinutes = props.getLongProperty("security.jwt.expirationInMinutes", "JWT_EXPIRATION_MINUTES", 60 * 24) - ) - } - } + data class JwtConfig( + val secret: String, + val issuer: String, + val audience: String, + val realm: String, + val expirationInMinutes: Long + ) } -data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean) { - companion object { - fun fromProperties(props: Properties, env: AppEnvironment) = LoggingConfig( - level = props.getStringProperty("logging.level", "LOG_LEVEL", if (env.isProduction()) "INFO" else "DEBUG"), - logRequests = props.getBooleanProperty("logging.requests", "LOG_REQUESTS", true), - logResponses = props.getBooleanProperty("logging.responses", "LOG_RESPONSES", !env.isProduction()) - ) - } -} +data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean) -data class RateLimitConfig(val enabled: Boolean, val globalLimit: Int, val globalPeriodMinutes: Int) { - companion object { - fun fromProperties(props: Properties) = RateLimitConfig( - enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true), - globalLimit = props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100), - globalPeriodMinutes = props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1) - ) - } -} +data class RateLimitConfig(val enabled: Boolean, val globalLimit: Int, val globalPeriodMinutes: Int) diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigLoader.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigLoader.kt new file mode 100644 index 00000000..dc65c034 --- /dev/null +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/config/ConfigLoader.kt @@ -0,0 +1,136 @@ +package at.mocode.core.utils.config + +import java.io.File +import java.net.InetAddress +import java.util.Properties + +/** + * Verantwortlich für das Laden der Anwendungskonfiguration aus verschiedenen Quellen. + * Diese Klasse kapselt die "unreine" Logik des Datei- und Systemzugriffs. + */ +class ConfigLoader(private val configPath: String = "config") { + + fun load(environment: AppEnvironment = AppEnvironment.current()): AppConfig { + //val environment = AppEnvironment.current() + val props = loadProperties(environment) + + return AppConfig( + environment = environment, + appInfo = createAppInfoConfig(props), + server = createServerConfig(props), + database = createDatabaseConfig(props), + serviceDiscovery = createServiceDiscoveryConfig(props), + security = createSecurityConfig(props), + logging = createLoggingConfig(props, environment), + rateLimit = createRateLimitConfig(props) + ) + } + + private fun loadProperties(environment: AppEnvironment): Properties { + val props = Properties() + // Lade zuerst die Basis-Properties + loadPropertiesFile("application.properties", props) + // Überschreibe mit umgebungsspezifischen Properties, falls vorhanden + val envFile = "application-${environment.name.lowercase()}.properties" + loadPropertiesFile(envFile, props) + return props + } + + private fun loadPropertiesFile(filename: String, props: Properties) { + // Versuche, aus den Ressourcen (im JAR) zu laden + val resourceStream = this::class.java.classLoader.getResourceAsStream(filename) + if (resourceStream != null) { + resourceStream.use { props.load(it) } + return + } + // Fallback für lokale Entwicklung: Lade aus einem 'config'-Ordner + // HIER WIRD DER PARAMETER VERWENDET + val file = File("$configPath/$filename") + if (file.exists()) { + file.inputStream().use { props.load(it) } + } + } + + // Die Konfigurations-Erstellungslogik ist hierher verschoben + private fun createAppInfoConfig(props: Properties) = AppInfoConfig( + name = props.getProperty("app.name", "Meldestelle"), + version = props.getProperty("app.version", "1.0.0"), + description = props.getProperty("app.description", "Pferdesport Meldestelle System") + ) + + private fun createServerConfig(props: Properties): ServerConfig { + val defaultHost = try { + InetAddress.getLocalHost().hostAddress + } catch (_: Exception) { + "127.0.0.1" + } + return ServerConfig( + port = props.getIntProperty("server.port", "API_PORT", 8081), + host = props.getStringProperty("server.host", "API_HOST", "0.0.0.0"), + advertisedHost = props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost), + workers = props.getIntProperty("server.workers", "API_WORKERS", Runtime.getRuntime().availableProcessors()), + cors = ServerConfig.CorsConfig( + enabled = props.getBooleanProperty("server.cors.enabled", "API_CORS_ENABLED", true), + allowedOrigins = props.getProperty("server.cors.allowedOrigins")?.split(",")?.map { it.trim() } + ?: listOf("*") + ) + ) + } + + private fun createDatabaseConfig(props: Properties): DatabaseConfig { + val host = props.getStringProperty("database.host", "DB_HOST", "localhost") + val port = props.getIntProperty("database.port", "DB_PORT", 5432) + val name = props.getStringProperty("database.name", "DB_NAME", "meldestelle_db") + return DatabaseConfig( + host = host, + port = port, + name = name, + jdbcUrl = "jdbc:postgresql://$host:$port/$name", + username = props.getStringProperty("database.username", "DB_USER", "meldestelle_user"), + password = props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me"), + driverClassName = "org.postgresql.Driver", + maxPoolSize = props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10), + minPoolSize = props.getIntProperty("database.minPoolSize", "DB_MIN_POOL_SIZE", 5), + autoMigrate = props.getBooleanProperty("database.autoMigrate", "DB_AUTO_MIGRATE", true) + ) + } + + // ... Fügen Sie hier die verbleibenden 'create...Config' Methoden ein, + // analog zu den 'fromProperties' Methoden aus der alten AppConfig. + private fun createServiceDiscoveryConfig(props: Properties) = ServiceDiscoveryConfig( + enabled = props.getBooleanProperty("service-discovery.enabled", "CONSUL_ENABLED", true), + consulHost = props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul"), + consulPort = props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500) + ) + + private fun createSecurityConfig(props: Properties) = SecurityConfig( + jwt = SecurityConfig.JwtConfig( + secret = props.getStringProperty( + "security.jwt.secret", + "JWT_SECRET", + "default-secret-please-change-in-production" + ), + issuer = props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api"), + audience = props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients"), + realm = props.getStringProperty("security.jwt.realm", "JWT_REALM", "meldestelle"), + expirationInMinutes = props.getLongProperty( + "security.jwt.expirationInMinutes", + "JWT_EXPIRATION_MINUTES", + 60 * 24 + ) + ), + apiKey = props.getStringProperty("security.apiKey", "API_KEY", "").ifEmpty { null } + ) + + private fun createLoggingConfig(props: Properties, env: AppEnvironment) = LoggingConfig( + level = props.getStringProperty("logging.level", "LOG_LEVEL", if (env.isProduction()) "INFO" else "DEBUG"), + logRequests = props.getBooleanProperty("logging.requests", "LOG_REQUESTS", true), + logResponses = props.getBooleanProperty("logging.responses", "LOG_RESPONSES", !env.isProduction()) + ) + + private fun createRateLimitConfig(props: Properties) = RateLimitConfig( + enabled = props.getBooleanProperty("ratelimit.enabled", "RATE_LIMIT_ENABLED", true), + globalLimit = props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100), + globalPeriodMinutes = props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1) + ) +} diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ApiValidationUtils.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ApiValidationUtils.kt index 83af73d6..e52cce76 100644 --- a/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ApiValidationUtils.kt +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ApiValidationUtils.kt @@ -1,38 +1,16 @@ package at.mocode.core.utils.validation -import com.benasher44.uuid.Uuid -import com.benasher44.uuid.uuidFrom -import kotlinx.datetime.LocalDate - /** * API-specific validation utilities for all modules. - * Provides comprehensive validation for all API endpoints. */ object ApiValidationUtils { - /** - * Validates UUID string and returns UUID or null if invalid - */ - fun validateUuidString(uuidString: String?): Uuid? { - if (uuidString.isNullOrBlank()) return null - - return try { - uuidFrom(uuidString) - } catch (_: IllegalArgumentException) { - null - } - } - /** * Validates query parameters with common validation rules */ fun validateQueryParameters( limit: String? = null, offset: String? = null, - startDate: String? = null, - endDate: String? = null, - search: String? = null, - q: String? = null ): List { val errors = mutableListOf() @@ -60,36 +38,6 @@ object ApiValidationUtils { } } - // Validate date parameters - startDate?.let { dateStr -> - try { - LocalDate.parse(dateStr) - } catch (_: Exception) { - errors.add(ValidationError("startDate", "Invalid date format. Use YYYY-MM-DD", "INVALID_FORMAT")) - } - } - - endDate?.let { dateStr -> - try { - LocalDate.parse(dateStr) - } catch (_: Exception) { - errors.add(ValidationError("endDate", "Invalid date format. Use YYYY-MM-DD", "INVALID_FORMAT")) - } - } - - // Validate search term length - search?.let { searchTerm -> - ValidationUtils.validateLength(searchTerm, "search", 100, 2)?.let { error -> - errors.add(error) - } - } - - q?.let { searchTerm -> - ValidationUtils.validateLength(searchTerm, "q", 100, 2)?.let { error -> - errors.add(error) - } - } - return errors } @@ -104,7 +52,6 @@ object ApiValidationUtils { username?.let { ValidationUtils.validateLength(it, "username", 50, 3)?.let { error -> errors.add(error) } - // Check if it's an email format if (it.contains("@")) { ValidationUtils.validateEmail(it, "username")?.let { error -> errors.add(error) } } @@ -116,166 +63,4 @@ object ApiValidationUtils { return errors } - - /** - * Validates password change request data - */ - fun validateChangePasswordRequest( - currentPassword: String?, - newPassword: String?, - confirmPassword: String? - ): List { - val errors = mutableListOf() - - ValidationUtils.validateNotBlank(currentPassword, "currentPassword")?.let { errors.add(it) } - ValidationUtils.validateNotBlank(newPassword, "newPassword")?.let { errors.add(it) } - ValidationUtils.validateNotBlank(confirmPassword, "confirmPassword")?.let { errors.add(it) } - - newPassword?.let { - ValidationUtils.validateLength(it, "newPassword", 128, 8)?.let { error -> errors.add(error) } - - // Password strength validation - if (!it.any { char -> char.isUpperCase() }) { - errors.add(ValidationError("newPassword", "Password must contain at least one uppercase letter", "WEAK_PASSWORD")) - } - if (!it.any { char -> char.isLowerCase() }) { - errors.add(ValidationError("newPassword", "Password must contain at least one lowercase letter", "WEAK_PASSWORD")) - } - if (!it.any { char -> char.isDigit() }) { - errors.add(ValidationError("newPassword", "Password must contain at least one digit", "WEAK_PASSWORD")) - } - } - - if (newPassword != null && confirmPassword != null && newPassword != confirmPassword) { - errors.add(ValidationError("confirmPassword", "Password confirmation does not match", "MISMATCH")) - } - - return errors - } - - /** - * Validates country creation/update request - */ - fun validateCountryRequest( - isoAlpha2Code: String?, - isoAlpha3Code: String?, - nameDeutsch: String?, - nameEnglisch: String? - ): List { - val errors = mutableListOf() - - ValidationUtils.validateNotBlank(isoAlpha2Code, "isoAlpha2Code")?.let { errors.add(it) } - ValidationUtils.validateNotBlank(isoAlpha3Code, "isoAlpha3Code")?.let { errors.add(it) } - ValidationUtils.validateNotBlank(nameDeutsch, "nameDeutsch")?.let { errors.add(it) } - - isoAlpha2Code?.let { - if (it.length != 2 || !it.all { char -> char.isLetter() }) { - errors.add(ValidationError("isoAlpha2Code", "ISO Alpha-2 code must be exactly 2 letters", "INVALID_FORMAT")) - } - } - - isoAlpha3Code?.let { - if (it.length != 3 || !it.all { char -> char.isLetter() }) { - errors.add(ValidationError("isoAlpha3Code", "ISO Alpha-3 code must be exactly 3 letters", "INVALID_FORMAT")) - } - } - - nameDeutsch?.let { - ValidationUtils.validateLength(it, "nameDeutsch", 100, 2)?.let { error -> errors.add(error) } - } - - nameEnglisch?.let { - ValidationUtils.validateLength(it, "nameEnglisch", 100, 2)?.let { error -> errors.add(error) } - } - - return errors - } - - /** - * Validates horse creation/update request - */ - fun validateHorseRequest( - pferdeName: String?, - lebensnummer: String?, - chipNummer: String?, - oepsNummer: String?, - feiNummer: String? - ): List { - val errors = mutableListOf() - - ValidationUtils.validateNotBlank(pferdeName, "pferdeName")?.let { errors.add(it) } - - pferdeName?.let { - ValidationUtils.validateLength(it, "pferdeName", 100, 2)?.let { error -> errors.add(error) } - } - - lebensnummer?.let { - ValidationUtils.validateLength(it, "lebensnummer", 20, 5)?.let { error -> errors.add(error) } - } - - chipNummer?.let { - ValidationUtils.validateLength(it, "chipNummer", 20, 10)?.let { error -> errors.add(error) } - } - - oepsNummer?.let { - ValidationUtils.validateOepsSatzNr(it, "oepsNummer")?.let { error -> errors.add(error) } - } - - feiNummer?.let { - ValidationUtils.validateLength(it, "feiNummer", 20, 5)?.let { error -> errors.add(error) } - } - - return errors - } - - /** - * Validates event creation/update request - */ - fun validateEventRequest( - name: String?, - ort: String?, - startDatum: LocalDate?, - endDatum: LocalDate?, - maxTeilnehmer: Int? - ): List { - val errors = mutableListOf() - - ValidationUtils.validateNotBlank(name, "name")?.let { errors.add(it) } - ValidationUtils.validateNotBlank(ort, "ort")?.let { errors.add(it) } - - name?.let { - ValidationUtils.validateLength(it, "name", 200, 3)?.let { error -> errors.add(error) } - } - - ort?.let { - ValidationUtils.validateLength(it, "ort", 100, 2)?.let { error -> errors.add(error) } - } - - if (startDatum != null && endDatum != null && startDatum > endDatum) { - errors.add(ValidationError("endDatum", "End date must be after start date", "INVALID_DATE_RANGE")) - } - - maxTeilnehmer?.let { - if (it < 1 || it > 10000) { - errors.add(ValidationError("maxTeilnehmer", "Maximum participants must be between 1 and 10000", "INVALID_RANGE")) - } - } - - return errors - } - - /** - * Creates error messages from validation errors - */ - fun createErrorMessage(errors: List): String { - val errorMessages = errors.map { "${it.field}: ${it.message}" } - return "Validation failed: ${errorMessages.joinToString(", ")}" - } - - /** - * Checks if validation passed - */ - fun isValid(errors: List): Boolean { - return errors.isEmpty() - } } diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationResult.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationResult.kt index bc889c8c..12c73a07 100644 --- a/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationResult.kt +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationResult.kt @@ -3,13 +3,20 @@ package at.mocode.core.utils.validation import kotlinx.serialization.Serializable /** - * Represents the result of a validation operation + * Repräsentiert das Ergebnis einer Validierungsoperation als versiegelte Klasse. + * Stellt sicher, dass ein Ergebnis entweder 'Valid' oder 'Invalid' ist. */ @Serializable sealed class ValidationResult { + /** + * Repräsentiert eine erfolgreiche Validierung. + */ @Serializable object Valid : ValidationResult() + /** + * Repräsentiert eine fehlgeschlagene Validierung mit einer Liste von spezifischen Fehlern. + */ @Serializable data class Invalid(val errors: List) : ValidationResult() @@ -18,7 +25,11 @@ sealed class ValidationResult { } /** - * Represents a single validation error + * Repräsentiert einen einzelnen Validierungsfehler. + * + * @param field Das Feld, dessen Validierung fehlschlug. + * @param message Eine menschenlesbare Fehlermeldung. + * @param code Ein maschinenlesbarer Fehlercode für Clients. */ @Serializable data class ValidationError( @@ -28,10 +39,11 @@ data class ValidationError( ) /** - * Exception thrown when validation fails + * Eine Exception, die eine fehlgeschlagene Validierung repräsentiert. + * Kann von zentralen Fehlerbehandlungs-Mechanismen abgefangen werden. */ class ValidationException( val validationResult: ValidationResult.Invalid ) : IllegalArgumentException( - "Validation failed: ${validationResult.errors.joinToString(", ") { "${it.field}: ${it.message}" }}" + "Validation failed: ${validationResult.errors.joinToString { "${it.field}: ${it.message}" }}" ) diff --git a/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationUtils.kt b/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationUtils.kt index 39d89aec..5d16ee06 100644 --- a/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationUtils.kt +++ b/core/core-utils/src/main/kotlin/at/mocode/core/utils/validation/ValidationUtils.kt @@ -1,11 +1,5 @@ package at.mocode.core.utils.validation -import kotlinx.datetime.LocalDate -import kotlin.time.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.todayIn -import kotlin.time.ExperimentalTime - /** * Common validation utilities */ @@ -32,11 +26,13 @@ object ValidationUtils { "$fieldName must be at least $minLength characters long", "MIN_LENGTH" ) + value.length > maxLength -> ValidationError( fieldName, "$fieldName cannot exceed $maxLength characters", "MAX_LENGTH" ) + else -> null } } @@ -47,107 +43,9 @@ object ValidationUtils { fun validateEmail(email: String?, fieldName: String = "email"): ValidationError? { if (email.isNullOrBlank()) return null - val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$".toRegex() + val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\$".toRegex() return if (!emailRegex.matches(email)) { ValidationError(fieldName, "Invalid email format", "INVALID_FORMAT") } else null } - - /** - * Validates phone number format (basic validation) - */ - fun validatePhoneNumber(phone: String?, fieldName: String = "telefon"): ValidationError? { - if (phone.isNullOrBlank()) return null - - // Remove common separators and spaces - val cleanPhone = phone.replace(Regex("[\\s\\-\\(\\)\\+]"), "") - - return if (cleanPhone.length < 6 || cleanPhone.length > 20 || !cleanPhone.all { it.isDigit() }) { - ValidationError(fieldName, "Invalid phone number format", "INVALID_FORMAT") - } else null - } - - /** - * Validates postal code format (basic validation for various countries) - */ - fun validatePostalCode(postalCode: String?, fieldName: String = "plz"): ValidationError? { - if (postalCode.isNullOrBlank()) return null - - // Basic validation: 3-10 alphanumeric characters - return if (postalCode.length < 3 || postalCode.length > 10 || !postalCode.all { it.isLetterOrDigit() }) { - ValidationError(fieldName, "Invalid postal code format", "INVALID_FORMAT") - } else null - } - - /** - * Validates 3-letter country code - */ - fun validateCountryCode(countryCode: String?, fieldName: String = "nationalitaet"): ValidationError? { - if (countryCode.isNullOrBlank()) return null - - return if (countryCode.length != 3 || !countryCode.all { it.isLetter() }) { - ValidationError(fieldName, "Country code must be exactly 3 letters", "INVALID_FORMAT") - } else null - } - - /** - * Validates birth date - */ - @OptIn(ExperimentalTime::class) - fun validateBirthDate(birthDate: LocalDate?, fieldName: String = "geburtsdatum"): ValidationError? { - if (birthDate == null) return null - - val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) - val minDate = LocalDate(1900, 1, 1) - - return when { - birthDate > today -> ValidationError( - fieldName, - "Birth date cannot be in the future", - "FUTURE_DATE" - ) - birthDate < minDate -> ValidationError( - fieldName, - "Birth date cannot be before year 1900", - "INVALID_DATE" - ) - else -> null - } - } - - /** - * Validates year value - */ - @OptIn(ExperimentalTime::class) - fun validateYear(year: Int?, fieldName: String, minYear: Int = 1900): ValidationError? { - if (year == null) return null - - val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year - - return when { - year < minYear -> ValidationError( - fieldName, - "Year cannot be before $minYear", - "INVALID_YEAR" - ) - year > currentYear + 10 -> ValidationError( - fieldName, - "Year cannot be more than 10 years in the future", - "FUTURE_YEAR" - ) - else -> null - } - } - - /** - * Validates OEPS Satz number format (Austrian specific) - */ - fun validateOepsSatzNr(oepsSatzNr: String?, fieldName: String = "oepsSatzNr"): ValidationError? { - if (oepsSatzNr.isNullOrBlank()) return null - - // Basic validation: should be numeric and reasonable length - return if (oepsSatzNr.length < 3 || oepsSatzNr.length > 20 || !oepsSatzNr.all { it.isDigit() }) { - ValidationError(fieldName, "Invalid OEPS Satz number format", "INVALID_FORMAT") - } else null - } } diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/config/ConfigLoaderTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/config/ConfigLoaderTest.kt new file mode 100644 index 00000000..347648ba --- /dev/null +++ b/core/core-utils/src/test/kotlin/at/mocode/core/utils/config/ConfigLoaderTest.kt @@ -0,0 +1,90 @@ +package at.mocode.core.utils.config + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConfigLoaderTest { + + // JUnit 5 erstellt automatisch ein temporäres Verzeichnis für diesen Test + @TempDir + lateinit var tempDir: File + + private lateinit var configDir: File + + @BeforeEach + fun setup() { + // Wir erstellen unsere eigene 'config'-Verzeichnisstruktur im temporären Ordner + configDir = File(tempDir, "config") + configDir.mkdir() + } + + @Test + fun `load should use default values when no properties file is present`() { + // Arrange + // HINWEIS: Der Loader braucht den Pfad zum übergeordneten Temp-Verzeichnis. + val configLoader = ConfigLoader(tempDir.absolutePath) + + // Act + val config = configLoader.load(AppEnvironment.DEVELOPMENT) + + // Assert + assertEquals("Meldestelle", config.appInfo.name) + assertEquals(8081, config.server.port) // Standard-Port + } + + @Test + fun `load should read values from base application_properties`() { + // Arrange + // Erstelle eine Test-Konfigurationsdatei + File(tempDir, "application.properties").writeText( + """ + app.name=TestApp + server.port=9999 + """.trimIndent() + ) + + // HINWEIS: Der Loader braucht den Pfad zum übergeordneten Temp-Verzeichnis. + val configLoader = ConfigLoader(tempDir.absolutePath) + + // Act + val config = configLoader.load(AppEnvironment.DEVELOPMENT) + + // Assert + assertEquals("TestApp", config.appInfo.name) + assertEquals(9999, config.server.port) + } + + @Test + fun `load should override base properties with environment-specific properties`() { + // Arrange + File(tempDir, "application.properties").writeText( + """ + app.name=BaseApp + server.port=8000 + database.host=base-db-host + """.trimIndent() + ) + + File(tempDir, "application-test.properties").writeText( + """ + app.name=TestEnvApp + server.port=9000 + """.trimIndent() + ) + + // HINWEIS: Der Loader braucht den Pfad zum übergeordneten Temp-Verzeichnis. + val configLoader = ConfigLoader(tempDir.absolutePath) + + // Act + val config = configLoader.load(AppEnvironment.TEST) + + // Assert + assertEquals(AppEnvironment.TEST, config.environment, "Environment should be TEST") + assertEquals("TestEnvApp", config.appInfo.name, "app.name should be overridden") + assertEquals(9000, config.server.port, "server.port should be overridden") + assertEquals("base-db-host", config.database.host, "database.host should come from the base file") + } +} diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/DatabaseFactoryTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/DatabaseFactoryTest.kt new file mode 100644 index 00000000..0fbde1f2 --- /dev/null +++ b/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/DatabaseFactoryTest.kt @@ -0,0 +1,89 @@ +package at.mocode.core.utils.database + +import at.mocode.core.utils.config.DatabaseConfig +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +// 1. Aktiviert die Testcontainers-Unterstützung für diese Klasse +@Testcontainers +class DatabaseFactoryTest { + + // 2. Definiert einen PostgreSQL-Container, der vor den Tests gestartet wird + companion object { + @Container + val postgresContainer = PostgreSQLContainer("postgres:16-alpine").apply { + withDatabaseName("test-db") + withUsername("test-user") + withPassword("test-password") + } + } + + private lateinit var databaseFactory: DatabaseFactory + private lateinit var dbConfig: DatabaseConfig + + // 3. Diese Methode wird VOR jedem Test ausgeführt + @BeforeEach + fun setup() { + // Erstelle eine DB-Konfiguration mit den dynamischen Daten des gestarteten Containers + dbConfig = DatabaseConfig( + host = postgresContainer.host, + port = postgresContainer.firstMappedPort, + name = postgresContainer.databaseName, + jdbcUrl = postgresContainer.jdbcUrl, + username = postgresContainer.username, + password = postgresContainer.password, + driverClassName = "org.postgresql.Driver", + maxPoolSize = 2, + minPoolSize = 1, + autoMigrate = false // Wir steuern Migrationen im Test manuell + ) + // Erstelle eine neue Factory-Instanz und verbinde sie mit der Test-DB + databaseFactory = DatabaseFactory(dbConfig) + databaseFactory.connect() + } + + // 4. Diese Methode wird NACH jedem Test ausgeführt + @AfterEach + fun tearDown() { + databaseFactory.close() + } + + // Ein einfaches Test-Tabellen-Objekt für Exposed + private object Users : Table("test_users") { + val id = integer("id").autoIncrement() + val name = varchar("name", 50) + override val primaryKey = PrimaryKey(id) + } + + @Test + fun `dbQuery should connect and execute a transaction against a real PostgreSQL container`() { + // Act & Assert + // runBlocking wird verwendet, da dbQuery eine suspend-Funktion ist + runBlocking { + val resultName = databaseFactory.dbQuery { + // Führe Operationen in einer Transaktion aus + SchemaUtils.create(Users) + Users.insert { + it[name] = "Stefan" + } + // Lese den gerade eingefügten Wert + Users.selectAll().first()[Users.name] + } + + // Überprüfe das Ergebnis + assertNotNull(resultName) + assertEquals("Stefan", resultName) + } + } +} diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/SimpleDatabaseTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/SimpleDatabaseTest.kt deleted file mode 100644 index 3253e286..00000000 --- a/core/core-utils/src/test/kotlin/at/mocode/core/utils/database/SimpleDatabaseTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -package at.mocode.core.utils.database - -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.* -import org.junit.jupiter.api.Assertions.assertEquals - -/** - * Comprehensive database connectivity and operations test. - * - * This test suite verifies that: - * 1. Database connection can be established - * 2. Basic CRUD operations work correctly - * 3. Tables can be created and dropped - * 4. Data can be inserted and retrieved - * - * Note: This test is currently ignored as it requires the H2 database driver - * to be properly configured. To run these tests manually: - * 1. Add H2 dependency to the project if not already present - * 2. Remove the @Ignore annotation - * 3. Run the tests - */ -@Ignore -class SimpleDatabaseTest { - - // Define test table using Exposed - private object TestTable : Table("test_table") { - val id = integer("id").autoIncrement() - val name = varchar("name", 255) - val email = varchar("email", 255).nullable() - - override val primaryKey = PrimaryKey(id) - } - - @Test - fun testDatabaseOperations() { - println("[DEBUG_LOG] Starting database test...") - - try { - // Connect to H2 an in-memory database - val db = Database.connect( - url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", - driver = "org.h2.Driver", - user = "sa", - password = "" - ) - println("[DEBUG_LOG] Database connection established successfully") - - transaction { - // Create tables - SchemaUtils.create(TestTable) - println("[DEBUG_LOG] Test table created successfully") - - // Insert test data - TestTable.insert { - it[name] = "Test User" - it[email] = "test@example.com" - } - println("[DEBUG_LOG] Test data inserted successfully") - - // Verify data was inserted - val count = TestTable.selectAll().count() - assertEquals(1, count, "Should have one row in the table") - println("[DEBUG_LOG] Data count verification passed") - - // Retrieve and verify data - val user = TestTable.selectAll().where { TestTable.name eq "Test User" }.single() - assertEquals("Test User", user[TestTable.name], "Should retrieve correct name") - assertEquals("test@example.com", user[TestTable.email], "Should retrieve correct email") - println("[DEBUG_LOG] Data retrieval verification passed") - - // Clean up - SchemaUtils.drop(TestTable) - println("[DEBUG_LOG] Test table dropped successfully") - } - - println("[DEBUG_LOG] Database test completed successfully!") - } catch (e: Exception) { - println("[DEBUG_LOG] Database test failed: ${e.message}") - println("[DEBUG_LOG] Cause: ${e.cause?.message}") - // Don't fail the test if the database connection fails - // This allows the test to be run in environments without the H2 driver - } - } - - @Test - fun testMultipleOperations() { - println("[DEBUG_LOG] Starting multiple operations test...") - - try { - // Connect to H2 an in-memory database - val db = Database.connect( - url = "jdbc:h2:mem:test2;DB_CLOSE_DELAY=-1", - driver = "org.h2.Driver", - user = "sa", - password = "" - ) - println("[DEBUG_LOG] Database connection established successfully") - - transaction { - // Create tables - SchemaUtils.create(TestTable) - println("[DEBUG_LOG] Test table created successfully") - - // Insert multiple test records - val users = listOf( - Pair("User 1", "user1@example.com"), - Pair("User 2", "user2@example.com"), - Pair("User 3", "user3@example.com") - ) - - users.forEach { (name, email) -> - TestTable.insert { - it[TestTable.name] = name - it[TestTable.email] = email - } - } - println("[DEBUG_LOG] Multiple test records inserted successfully") - - // Verify data was inserted - val count = TestTable.selectAll().count() - assertEquals(3, count, "Should have three rows in the table") - println("[DEBUG_LOG] Multiple data count verification passed") - - // Retrieve and verify specific data - val user2 = TestTable.selectAll().where { TestTable.name eq "User 2" }.single() - assertEquals("User 2", user2[TestTable.name], "Should retrieve correct name") - assertEquals("user2@example.com", user2[TestTable.email], "Should retrieve correct email") - println("[DEBUG_LOG] Specific data retrieval verification passed") - - // Clean up - SchemaUtils.drop(TestTable) - println("[DEBUG_LOG] Test table dropped successfully") - } - - println("[DEBUG_LOG] Multiple operations test completed successfully!") - } catch (e: Exception) { - println("[DEBUG_LOG] Multiple operations test failed: ${e.message}") - println("[DEBUG_LOG] Cause: ${e.cause?.message}") - // Don't fail the test if the database connection fails - // This allows the test to be run in environments without the H2 driver - } - } -} diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ApiValidationUtilsTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ApiValidationUtilsTest.kt new file mode 100644 index 00000000..de56f7cd --- /dev/null +++ b/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ApiValidationUtilsTest.kt @@ -0,0 +1,46 @@ +package at.mocode.core.utils.validation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +class ApiValidationUtilsTest { + + @Test + fun `validateQueryParameters should validate limit and offset`() { + // Test valid parameters + var errors = ApiValidationUtils.validateQueryParameters(limit = "50", offset = "10") + assertTrue(errors.isEmpty(), "Valid limit and offset should produce no errors") + + // Test invalid limit + errors = ApiValidationUtils.validateQueryParameters(limit = "invalid") + assertEquals(1, errors.size) + assertEquals("limit", errors.first().field) + + // Test out of range limit + errors = ApiValidationUtils.validateQueryParameters(limit = "0") + assertEquals(1, errors.size) + assertEquals("limit", errors.first().field) + + // Test invalid offset + errors = ApiValidationUtils.validateQueryParameters(offset = "-1") + assertEquals(1, errors.size) + assertEquals("offset", errors.first().field) + } + + @Test + fun `validateLoginRequest should validate username and password`() { + // Test valid request + var errors = ApiValidationUtils.validateLoginRequest("user@example.com", "password123") + assertTrue(errors.isEmpty()) + + // Test missing username + errors = ApiValidationUtils.validateLoginRequest(null, "password123") + assertTrue(errors.any { it.field == "username" }) + + // Test password too short + errors = ApiValidationUtils.validateLoginRequest("user@example.com", "pass") + assertTrue(errors.any { it.field == "password" }) + } +} diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ValidationTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ValidationTest.kt deleted file mode 100644 index 539d611f..00000000 --- a/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ValidationTest.kt +++ /dev/null @@ -1,447 +0,0 @@ -package at.mocode.core.utils.validation - -import at.mocode.core.utils.validation.ApiValidationUtils -import at.mocode.core.utils.validation.ValidationError -import kotlin.test.* -import kotlinx.datetime.LocalDate - -/** - * Comprehensive test class for API validation utilities. - * - * This test verifies that the validation implementation works correctly - * for all API endpoints. - */ -class ValidationTest { - - /** - * Helper function to check if a validation error exists for a specific field - */ - private fun hasErrorForField(errors: List, field: String): Boolean { - return errors.any { it.field == field } - } - - /** - * Helper function to check if a validation error with specific code exists - */ - private fun hasErrorWithCode(errors: List, code: String): Boolean { - return errors.any { it.code == code } - } - - // UUID Validation Tests - - @Test - fun testValidUuid() { - // Valid UUID - val validUuid = "550e8400-e29b-41d4-a716-446655440000" - val result = ApiValidationUtils.validateUuidString(validUuid) - assertNotNull(result, "Valid UUID should be parsed correctly") - assertEquals(validUuid, result.toString(), "Parsed UUID should match original string") - } - - @Test - fun testInvalidUuid() { - // Invalid UUID - val invalidUuid = "not-a-uuid" - val result = ApiValidationUtils.validateUuidString(invalidUuid) - assertNull(result, "Invalid UUID should return null") - } - - @Test - fun testNullOrEmptyUuid() { - // Null UUID - val nullResult = ApiValidationUtils.validateUuidString(null) - assertNull(nullResult, "Null UUID should return null") - - // Empty UUID - val emptyResult = ApiValidationUtils.validateUuidString("") - assertNull(emptyResult, "Empty UUID should return null") - - // Blank UUID - val blankResult = ApiValidationUtils.validateUuidString(" ") - assertNull(blankResult, "Blank UUID should return null") - } - - // Query Parameter Validation Tests - - @Test - fun testValidQueryParameters() { - // Test valid parameters - val validErrors = ApiValidationUtils.validateQueryParameters( - limit = "50", - offset = "0", - search = "test", - startDate = "2024-07-01", - endDate = "2024-07-31", - q = "search term" - ) - assertTrue(ApiValidationUtils.isValid(validErrors), - "Valid query parameters should pass validation") - } - - @Test - fun testLimitValidation() { - // Test invalid limit format - val invalidLimitErrors = ApiValidationUtils.validateQueryParameters( - limit = "invalid" - ) - assertFalse(ApiValidationUtils.isValid(invalidLimitErrors), - "Invalid limit parameter should fail validation") - assertTrue(hasErrorForField(invalidLimitErrors, "limit"), - "Should have error for 'limit' field") - assertTrue(hasErrorWithCode(invalidLimitErrors, "INVALID_FORMAT"), - "Should have 'INVALID_FORMAT' error code") - - // Test limit out of range (too high) - val tooHighLimitErrors = ApiValidationUtils.validateQueryParameters( - limit = "2000" - ) - assertFalse(ApiValidationUtils.isValid(tooHighLimitErrors), - "Out of range limit should fail validation") - assertTrue(hasErrorForField(tooHighLimitErrors, "limit"), - "Should have error for 'limit' field") - assertTrue(hasErrorWithCode(tooHighLimitErrors, "INVALID_RANGE"), - "Should have 'INVALID_RANGE' error code") - - // Test limit out of range (too low) - val tooLowLimitErrors = ApiValidationUtils.validateQueryParameters( - limit = "0" - ) - assertFalse(ApiValidationUtils.isValid(tooLowLimitErrors), - "Out of range limit should fail validation") - assertTrue(hasErrorForField(tooLowLimitErrors, "limit"), - "Should have error for 'limit' field") - } - - @Test - fun testOffsetValidation() { - // Test invalid offset format - val invalidOffsetErrors = ApiValidationUtils.validateQueryParameters( - offset = "invalid" - ) - assertFalse(ApiValidationUtils.isValid(invalidOffsetErrors), - "Invalid offset parameter should fail validation") - assertTrue(hasErrorForField(invalidOffsetErrors, "offset"), - "Should have error for 'offset' field") - - // Test negative offset - val negativeOffsetErrors = ApiValidationUtils.validateQueryParameters( - offset = "-1" - ) - assertFalse(ApiValidationUtils.isValid(negativeOffsetErrors), - "Negative offset should fail validation") - assertTrue(hasErrorForField(negativeOffsetErrors, "offset"), - "Should have error for 'offset' field") - } - - @Test - fun testDateValidation() { - // Test invalid start date - val invalidStartDateErrors = ApiValidationUtils.validateQueryParameters( - startDate = "invalid-date" - ) - assertFalse(ApiValidationUtils.isValid(invalidStartDateErrors), - "Invalid start date should fail validation") - assertTrue(hasErrorForField(invalidStartDateErrors, "startDate"), - "Should have error for 'startDate' field") - - // Test invalid end date - val invalidEndDateErrors = ApiValidationUtils.validateQueryParameters( - endDate = "invalid-date" - ) - assertFalse(ApiValidationUtils.isValid(invalidEndDateErrors), - "Invalid end date should fail validation") - assertTrue(hasErrorForField(invalidEndDateErrors, "endDate"), - "Should have error for 'endDate' field") - } - - @Test - fun testSearchTermValidation() { - // Test search term too short - val shortSearchErrors = ApiValidationUtils.validateQueryParameters( - search = "a" - ) - assertFalse(ApiValidationUtils.isValid(shortSearchErrors), - "Too short search term should fail validation") - assertTrue(hasErrorForField(shortSearchErrors, "search"), - "Should have error for 'search' field") - - // Test q parameter too short - val shortQErrors = ApiValidationUtils.validateQueryParameters( - q = "a" - ) - assertFalse(ApiValidationUtils.isValid(shortQErrors), - "Too short q parameter should fail validation") - assertTrue(hasErrorForField(shortQErrors, "q"), - "Should have error for 'q' field") - } - - // Authentication Validation Tests - - @Test - fun testLoginRequestValidation() { - // Test valid login - val validErrors = ApiValidationUtils.validateLoginRequest( - "user@example.com", - "password123" - ) - assertTrue(ApiValidationUtils.isValid(validErrors), - "Valid login request should pass validation") - - // Test missing username - val missingUsernameErrors = ApiValidationUtils.validateLoginRequest( - null, - "password123" - ) - assertFalse(ApiValidationUtils.isValid(missingUsernameErrors), - "Missing username should fail validation") - assertTrue(hasErrorForField(missingUsernameErrors, "username"), - "Should have error for 'username' field") - - // Test missing password - val missingPasswordErrors = ApiValidationUtils.validateLoginRequest( - "user@example.com", - null - ) - assertFalse(ApiValidationUtils.isValid(missingPasswordErrors), - "Missing password should fail validation") - assertTrue(hasErrorForField(missingPasswordErrors, "password"), - "Should have error for 'password' field") - - // Test username too short - val shortUsernameErrors = ApiValidationUtils.validateLoginRequest( - "ab", - "password123" - ) - assertFalse(ApiValidationUtils.isValid(shortUsernameErrors), - "Too short username should fail validation") - - // Test password too short - val shortPasswordErrors = ApiValidationUtils.validateLoginRequest( - "user@example.com", - "pass" - ) - assertFalse(ApiValidationUtils.isValid(shortPasswordErrors), - "Too short password should fail validation") - - // Test invalid email format - val invalidEmailErrors = ApiValidationUtils.validateLoginRequest( - "invalid-email@", - "password123" - ) - assertFalse(ApiValidationUtils.isValid(invalidEmailErrors), - "Invalid email format should fail validation") - } - - @Test - fun testChangePasswordRequestValidation() { - // Test valid password change - val validErrors = ApiValidationUtils.validateChangePasswordRequest( - "OldPassword123", - "NewPassword123", - "NewPassword123" - ) - assertTrue(ApiValidationUtils.isValid(validErrors), - "Valid password change request should pass validation") - - // Test missing current password - val missingCurrentErrors = ApiValidationUtils.validateChangePasswordRequest( - null, - "NewPassword123", - "NewPassword123" - ) - assertFalse(ApiValidationUtils.isValid(missingCurrentErrors), - "Missing current password should fail validation") - - // Test missing new password - val missingNewErrors = ApiValidationUtils.validateChangePasswordRequest( - "OldPassword123", - null, - "NewPassword123" - ) - assertFalse(ApiValidationUtils.isValid(missingNewErrors), - "Missing new password should fail validation") - - // Test password confirmation mismatch - val mismatchErrors = ApiValidationUtils.validateChangePasswordRequest( - "OldPassword123", - "NewPassword123", - "DifferentPassword123" - ) - assertFalse(ApiValidationUtils.isValid(mismatchErrors), - "Password confirmation mismatch should fail validation") - assertTrue(hasErrorForField(mismatchErrors, "confirmPassword"), - "Should have error for 'confirmPassword' field") - - // Test weak password (no uppercase) - val noUppercaseErrors = ApiValidationUtils.validateChangePasswordRequest( - "oldpassword123", - "newpassword123", - "newpassword123" - ) - assertFalse(ApiValidationUtils.isValid(noUppercaseErrors), - "Password without uppercase should fail validation") - assertTrue(hasErrorWithCode(noUppercaseErrors, "WEAK_PASSWORD"), - "Should have 'WEAK_PASSWORD' error code") - } - - // Master Data Validation Tests - - @Test - fun testCountryRequestValidation() { - // Test valid country request - val validErrors = ApiValidationUtils.validateCountryRequest( - "AT", - "AUT", - "Österreich", - "Austria" - ) - assertTrue(ApiValidationUtils.isValid(validErrors), - "Valid country request should pass validation") - - // Test missing required fields - val missingFieldsErrors = ApiValidationUtils.validateCountryRequest( - null, - null, - null, - null - ) - assertFalse(ApiValidationUtils.isValid(missingFieldsErrors), - "Missing required fields should fail validation") - assertTrue(hasErrorForField(missingFieldsErrors, "isoAlpha2Code"), - "Should have error for 'isoAlpha2Code' field") - assertTrue(hasErrorForField(missingFieldsErrors, "isoAlpha3Code"), - "Should have error for 'isoAlpha3Code' field") - assertTrue(hasErrorForField(missingFieldsErrors, "nameDeutsch"), - "Should have error for 'nameDeutsch' field") - - // Test invalid ISO Alpha-2 code - val invalidAlpha2Errors = ApiValidationUtils.validateCountryRequest( - "INVALID", - "AUT", - "Österreich", - "Austria" - ) - assertFalse(ApiValidationUtils.isValid(invalidAlpha2Errors), - "Invalid ISO Alpha-2 code should fail validation") - assertTrue(hasErrorForField(invalidAlpha2Errors, "isoAlpha2Code"), - "Should have error for 'isoAlpha2Code' field") - } - - // Horse Registry Validation Tests - - @Test - @Ignore("Horse validation requires specific format for OEPS number that needs further investigation") - fun testHorseRequestValidation() { - // Test valid horse request - val validErrors = ApiValidationUtils.validateHorseRequest( - "Thunder", - "123456789", - "9876543210", // Updated to 10 characters to meet minimum length - "OEPS123456", // Updated OEPS number format - "FEI456" - ) - assertTrue(ApiValidationUtils.isValid(validErrors), - "Valid horse request should pass validation") - - // Test missing horse name - val missingNameErrors = ApiValidationUtils.validateHorseRequest( - null, - "123456789", - "987654321", - "OEPS123", - "FEI456" - ) - assertFalse(ApiValidationUtils.isValid(missingNameErrors), - "Missing horse name should fail validation") - assertTrue(hasErrorForField(missingNameErrors, "pferdeName"), - "Should have error for 'pferdeName' field") - - // Test name too short - val shortNameErrors = ApiValidationUtils.validateHorseRequest( - "A", - "123456789", - "987654321", - "OEPS123", - "FEI456" - ) - assertFalse(ApiValidationUtils.isValid(shortNameErrors), - "Too short name should fail validation") - } - - // Event Management Validation Tests - - @Test - fun testEventRequestValidation() { - val startDate = LocalDate(2024, 6, 1) - val endDate = LocalDate(2024, 6, 3) - - // Test valid event request - val validErrors = ApiValidationUtils.validateEventRequest( - "Test Event", - "Vienna", - startDate, - endDate, - 100 - ) - assertTrue(ApiValidationUtils.isValid(validErrors), - "Valid event request should pass validation") - - // Test missing event name - val missingNameErrors = ApiValidationUtils.validateEventRequest( - null, - "Vienna", - startDate, - endDate, - 100 - ) - assertFalse(ApiValidationUtils.isValid(missingNameErrors), - "Missing event name should fail validation") - assertTrue(hasErrorForField(missingNameErrors, "name"), - "Should have error for 'name' field") - - // Test invalid date range (end before start) - val invalidDateErrors = ApiValidationUtils.validateEventRequest( - "Test Event", - "Vienna", - endDate, - startDate, - 100 - ) - assertFalse(ApiValidationUtils.isValid(invalidDateErrors), - "Invalid date range should fail validation") - assertTrue(hasErrorForField(invalidDateErrors, "endDatum"), - "Should have error for 'endDatum' field") - } - - // Utility Function Tests - - @Test - fun testCreateErrorMessage() { - val errors = listOf( - ValidationError("field1", "Error message 1", "ERROR1"), - ValidationError("field2", "Error message 2", "ERROR2") - ) - - val errorMessage = ApiValidationUtils.createErrorMessage(errors) - assertTrue(errorMessage.contains("field1: Error message 1"), - "Error message should contain first field error") - assertTrue(errorMessage.contains("field2: Error message 2"), - "Error message should contain second field error") - assertTrue(errorMessage.contains("Validation failed"), - "Error message should indicate validation failure") - } - - @Test - fun testIsValid() { - // Empty list should be valid - assertTrue(ApiValidationUtils.isValid(emptyList()), - "Empty error list should be valid") - - // Non-empty list should be invalid - val errors = listOf( - ValidationError("field", "Error message", "ERROR") - ) - assertFalse(ApiValidationUtils.isValid(errors), - "Non-empty error list should be invalid") - } -} diff --git a/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ValidationUtilsTest.kt b/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ValidationUtilsTest.kt new file mode 100644 index 00000000..5ce8ee33 --- /dev/null +++ b/core/core-utils/src/test/kotlin/at/mocode/core/utils/validation/ValidationUtilsTest.kt @@ -0,0 +1,35 @@ +package at.mocode.core.utils.validation + +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ValidationUtilsTest { + + @Test + fun `validateNotBlank should return error for blank strings`() { + assertNotNull(ValidationUtils.validateNotBlank(null, "testField")) + assertNotNull(ValidationUtils.validateNotBlank("", "testField")) + assertNotNull(ValidationUtils.validateNotBlank(" ", "testField")) + } + + @Test + fun `validateNotBlank should return null for non-blank strings`() { + assertNull(ValidationUtils.validateNotBlank("value", "testField")) + } + + @Test + fun `validateLength should check min and max length`() { + assertNotNull(ValidationUtils.validateLength("a", "testField", 5, 2), "Should fail for being too short") + assertNotNull(ValidationUtils.validateLength("abcdef", "testField", 5, 2), "Should fail for being too long") + assertNull(ValidationUtils.validateLength("abc", "testField", 5, 2), "Should pass with valid length") + } + + @Test + fun `validateEmail should validate email format`() { + assertNull(ValidationUtils.validateEmail("test@example.com", "email")) + assertNotNull(ValidationUtils.validateEmail("test@", "email")) + assertNotNull(ValidationUtils.validateEmail("test@example", "email")) + assertNotNull(ValidationUtils.validateEmail("test.example.com", "email")) + } +} diff --git a/docs/entwickungszyklus/Tracer-Bullet_Checkliste.md b/docs/entwickungszyklus/Tracer-Bullet_Checkliste.md index 20ab5e8e..d935a255 100644 --- a/docs/entwickungszyklus/Tracer-Bullet_Checkliste.md +++ b/docs/entwickungszyklus/Tracer-Bullet_Checkliste.md @@ -1,8 +1,8 @@ ✅ TODO-Checkliste: Architektur-Validierung ("Tracer Bullet") Phase 1: Backend-Infrastruktur vorbereiten -[ ] Gradle-Setup bereinigen: +✅ Gradle-Setup bereinigen: -[ ] In settings.gradle.kts sicherstellen, dass nur die platform-, core- und infrastructure-Module aktiv sind. Alle anderen (fachliche Services, Clients) müssen auskommentiert sein. +✅ In settings.gradle.kts sicherstellen, dass nur die platform-, core- und infrastructure-Module aktiv sind. Alle anderen (fachliche Services, Clients) müssen auskommentiert sein. [ ] Konfiguration finalisieren: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac4935dc..30382bc1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ caffeine = "3.1.8" reactorKafka = "1.3.22" jackson = "2.17.0" jakartaAnnotation = "2.1.1" +roomCommonJvm = "2.7.2" [libraries] # --- Platform BOMs (Bill of Materials) --- @@ -174,6 +175,7 @@ assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } testcontainers-core = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" } testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" } +room-common-jvm = { group = "androidx.room", name = "room-common-jvm", version.ref = "roomCommonJvm" } [bundles] # OPTIMIERUNG: Bündelt gängige Abhängigkeitsgruppen. diff --git a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/GatewayApplication.kt b/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/GatewayApplication.kt index a3bea422..f2aeacb2 100644 --- a/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/GatewayApplication.kt +++ b/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/GatewayApplication.kt @@ -9,5 +9,6 @@ import org.springframework.cloud.client.discovery.EnableDiscoveryClient class GatewayApplication fun main(args: Array) { + runApplication(*args) }