fixing(gradle)

This commit is contained in:
2025-08-24 21:31:31 +02:00
parent 8d01fa0e9a
commit 89ef9698af
77 changed files with 2060 additions and 1656 deletions
-42
View File
@@ -1,42 +0,0 @@
# Core Module
## Ü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änen-agnostische Konzepte, Utilities und Infrastrukturkomponenten bereit, die von allen anderen Modulen verwendet werden.
## Architektur
Das Modul ist nach den Prinzipien der Clean Architecture in zwei Hauptkomponenten unterteilt:
* **`: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
Dieses Modul hat eine **minimale Oberfläche**, um eine maximale Entkopplung der Fach-Services zu gewährleisten.
* **`BaseDto.kt`**: Definiert standardisierte DTOs (Data Transfer Objects) wie `ApiResponse<T>` und `PagedResponse<T>`, 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
* **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.
## Testing-Strategie
Das `core`-Modul ist durch eine umfassende Suite von Unit- und Integrationstests abgesichert, die einen hohen Qualitätsstandard setzen.
* **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.
---
+31 -13
View File
@@ -1,17 +1,16 @@
// Dieses Modul definiert die Kern-Domänenobjekte des Shared kernels.
// Es enthält keine Implementierungsdetails, nur reine Datenklassen und Enums.
// Core domain objects of the Shared kernel
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
// Target platforms
jvm {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
}
js(IR) {
browser()
}
@@ -19,33 +18,52 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// Kern-Abhängigkeiten für das Domänen-Modul (common for all platforms)
// Core dependencies (that aren't included in platform-dependencies)
api(libs.uuid)
// Serialization and date-time for commonMain
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
}
}
val jvmMain by getting {
dependencies {
// Stellt sicher, dass dieses Modul Zugriff auf die im zentralen Katalog
// definierten Bibliotheken hat (JVM-specific)
api(projects.platform.platformDependencies)
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jsMain by getting {
dependencies {
api(libs.kotlinx.coroutines.core)
}
}
val jsTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jvmMain by getting {
dependencies {
// Fachliches Domain-Modul: keine technischen Abhängigkeiten hier hinterlegen.
// Falls in Zukunft JVM-spezifische, fachlich neutrale Ergänzungen nötig sind,
// bitte bewusst und minimal hinzufügen.
}
}
val jvmTest by getting {
dependencies {
// Stellt die Test-Bibliotheken bereit (JVM-specific)
// implementation(kotlin("test-junit5"))
implementation(libs.junit.jupiter.api)
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
}
}
}
tasks.named<Test>("jvmTest") {
useJUnitPlatform()
}
@@ -2,20 +2,17 @@ package at.mocode.core.domain.event
import at.mocode.core.domain.model.*
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class)
/**
* Basis-Interface für alle Domänen-Events im System.
* Ein Domänen-Event repräsentiert etwas fachlich Bedeutsames, das passiert ist.
* Basis-Interface für alle Domain-Events im System.
* Ein Domain-Event beschreibt ein fachlich relevantes Ereignis, das stattgefunden hat.
*/
@OptIn(ExperimentalTime::class)
interface DomainEvent {
val eventId: EventId
val aggregateId: AggregateId
@@ -27,7 +24,7 @@ interface DomainEvent {
}
/**
* Abstrakte Basisklasse für Domänen-Events, um Boilerplate-Code zu reduzieren.
* Abstrakte Basisklasse für Domain-Events, um Boilerplate zu reduzieren.
*/
@Serializable
@OptIn(ExperimentalTime::class)
@@ -37,13 +34,36 @@ abstract class BaseDomainEvent(
override val version: EventVersion,
override val eventId: EventId = EventId(uuid4()),
@Serializable(with = KotlinInstantSerializer::class)
override val timestamp: Instant = Clock.System.now(),
override val timestamp: Instant,
override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null
) : DomainEvent
) : DomainEvent {
constructor(
aggregateId: AggregateId,
eventType: EventType,
version: EventVersion,
eventId: EventId = EventId(uuid4()),
correlationId: CorrelationId? = null,
causationId: CausationId? = null
) : this(
aggregateId = aggregateId,
eventType = eventType,
version = version,
eventId = eventId,
timestamp = createTimestamp(),
correlationId = correlationId,
causationId = causationId
)
companion object {
@OptIn(ExperimentalTime::class)
private fun createTimestamp(): Instant = Clock.System.now()
}
}
/**
* Interface für einen Publisher, der Domänen-Events veröffentlichen kann.
* Schnittstelle für einen Publisher, der Domain-Events veröffentlichen kann.
*/
interface DomainEventPublisher {
suspend fun publish(event: DomainEvent)
@@ -51,9 +71,14 @@ interface DomainEventPublisher {
}
/**
* Interface für einen Handler, der auf bestimmte Domänen-Events reagieren kann.
* Schnittstelle für einen Handler, der auf bestimmte Domain-Events reagieren kann.
*/
interface DomainEventHandler<T : DomainEvent> {
suspend fun handle(event: T)
fun canHandle(eventType: String): Boolean
fun canHandle(eventType: EventType): Boolean
/**
* Rückwärtskompatible Methode für String-basierte Prüfung des Event-Typs.
*/
fun canHandle(eventType: String): Boolean = canHandle(EventType(eventType))
}
@@ -1,20 +1,18 @@
package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.ExperimentalTime
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
/**
* A marker interface for all Data Transfer Objects.
* Marker-Interface für alle Data-Transfer-Objekte (DTO).
*/
interface BaseDto
/**
* Base DTO for domain entities that have unique ID and audit timestamps.
* Basis-DTO für Domänen-Entitäten mit eindeutiger ID und Audit-Zeitstempeln.
*/
@Serializable
@OptIn(ExperimentalTime::class)
@@ -29,17 +27,17 @@ abstract class EntityDto : BaseDto {
}
/**
* A structured representation of a single error.
* Strukturierte Darstellung eines einzelnen Fehlers (Code, Nachricht, optionales Feld).
*/
@Serializable
data class ErrorDto(
val code: String,
val code: ErrorCode,
val message: String,
val field: String? = null
) : BaseDto
/**
* A standardized and consistent wrapper for all API responses.
* Standardisierte Hülle für API-Antworten mit einheitlicher Struktur.
*/
@Serializable
@OptIn(ExperimentalTime::class)
@@ -48,12 +46,26 @@ data class ApiResponse<T>(
val success: Boolean,
val errors: List<ErrorDto> = emptyList(),
@Serializable(with = KotlinInstantSerializer::class)
val timestamp: Instant = Clock.System.now()
val timestamp: Instant
) {
companion object {
@OptIn(ExperimentalTime::class)
fun <T> success(data: T): ApiResponse<T> {
return ApiResponse(data = data, success = true)
return ApiResponse(data = data, success = true, timestamp = Clock.System.now())
}
@OptIn(ExperimentalTime::class)
fun <T> error(
code: ErrorCode,
message: String,
field: String? = null
): ApiResponse<T> {
return ApiResponse(
data = null,
success = false,
errors = listOf(ErrorDto(code = code, message = message, field = field)),
timestamp = Clock.System.now()
)
}
@OptIn(ExperimentalTime::class)
@@ -62,30 +74,52 @@ data class ApiResponse<T>(
message: String,
field: String? = null
): ApiResponse<T> {
return ApiResponse(
data = null,
success = false,
errors = listOf(ErrorDto(code = code, message = message, field = field))
)
return error(ErrorCode(code), message, field)
}
@OptIn(ExperimentalTime::class)
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
return ApiResponse(data = null, success = false, errors = errors)
return ApiResponse(data = null, success = false, errors = errors, timestamp = Clock.System.now())
}
}
}
/**
* A standardized wrapper for paginated API responses.
* Standardisierte Hülle für paginierte API-Antworten.
*/
@Serializable
data class PagedResponse<T>(
val content: List<T>,
val page: Int,
val size: Int,
val page: PageNumber,
val size: PageSize,
val totalElements: Long,
val totalPages: Int,
val hasNext: Boolean,
val hasPrevious: Boolean
)
) {
companion object {
/**
* Erzeugt eine PagedResponse mit Rückwärtskompatibilität für einfache Int-Werte.
* Nützlich, wenn Aufrufer noch keine PageNumber/PageSize verwenden.
*/
fun <T> create(
content: List<T>,
page: Int,
size: Int,
totalElements: Long,
totalPages: Int,
hasNext: Boolean,
hasPrevious: Boolean
): PagedResponse<T> {
return PagedResponse(
content = content,
page = PageNumber(page),
size = PageSize(size),
totalElements = totalElements,
totalPages = totalPages,
hasNext = hasNext,
hasPrevious = hasPrevious
)
}
}
}
@@ -0,0 +1,79 @@
package at.mocode.core.domain.model
import kotlinx.serialization.Serializable
/**
* Gemeinsame Enums, die domänenweit verwendet werden.
* Teil des Shared Kernel zur Sicherung einer konsistenten Fachsprache.
*/
/**
* Quelle eines Datensatzes. Querschnittsthema und daher Teil des Shared Kernel.
*/
@Serializable
enum class DatenQuelleE {
MANUELL,
IMPORT_ZNS,
SYSTEM_GENERATED,
IMPORT_API
}
/**
* Allgemeiner Status von Entitäten in der Domäne.
*/
@Serializable
enum class StatusE {
AKTIV,
INAKTIV,
ENTWURF,
ARCHIVIERT,
GELOESCHT
}
/**
* Prioritätsstufen für unterschiedliche Domänen-Objekte.
*/
@Serializable
enum class PrioritaetE {
NIEDRIG,
NORMAL,
HOCH,
KRITISCH
}
/**
* Häufige Benutzerrollen im System.
*/
@Serializable
enum class BenutzerRolleE {
ADMIN,
BENUTZER,
MODERATOR,
GAST,
SYSTEM
}
/**
* Verifikationsstatus für Datensätze.
*/
@Serializable
enum class VerifikationsStatusE {
NICHT_VERIFIZIERT,
IN_PRUEFUNG,
VERIFIZIERT,
ABGELEHNT,
KORREKTUR_ERFORDERLICH
}
/**
* Processing states for workflows and tasks.
*/
@Serializable
enum class BearbeitungsStatusE {
OFFEN,
IN_BEARBEITUNG,
WARTEND,
ABGESCHLOSSEN,
ABGEBROCHEN,
FEHLER
}
@@ -0,0 +1,45 @@
package at.mocode.core.domain.model
import kotlinx.serialization.Serializable
/**
* Repräsentiert einen Validierungsfehler mit Feldname, Nachricht und Fehlercode.
* Wird von Validierungs-Hilfsfunktionen im gesamten System verwendet.
*/
@Serializable
data class ValidationError(
val field: String,
val message: String,
val code: String
) : BaseDto {
companion object {
/**
* Erzeugt einen Validierungsfehler für Pflichtfeld-Prüfungen.
*/
fun required(field: String): ValidationError {
return ValidationError(field, "$field ist erforderlich", "REQUIRED")
}
/**
* Erzeugt einen Validierungsfehler für ungültiges Format.
*/
fun invalidFormat(field: String, message: String = "Ungültiges Format"): ValidationError {
return ValidationError(field, message, "INVALID_FORMAT")
}
/**
* Erzeugt einen Validierungsfehler für Längenprüfungen.
*/
fun invalidLength(field: String, message: String): ValidationError {
return ValidationError(field, message, "INVALID_LENGTH")
}
/**
* Erzeugt einen Validierungsfehler für Bereichsprüfungen.
*/
fun invalidRange(field: String, message: String): ValidationError {
return ValidationError(field, message, "INVALID_RANGE")
}
}
}
@@ -2,23 +2,23 @@ package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
import kotlin.jvm.JvmInline
/**
* Value classes for strongly typed IDs and domain values.
* These provide compile-time type safety without runtime overhead.
* Value-Classes für stark typisierte IDs und Fachwerte.
* Bieten Typsicherheit zur Compile-Zeit ohne Laufzeit-Overhead.
*/
// === ID Value Classes ===
/**
* A strongly typed wrapper for entity IDs.
* Stark typisierte Hülle für Entitäts-IDs.
*/
@Serializable
@JvmInline
value class EntityId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
companion object
}
/**
@@ -27,7 +27,7 @@ value class EntityId(@Serializable(with = UuidSerializer::class) val value: Uuid
@Serializable
@JvmInline
value class EventId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
companion object
}
/**
@@ -36,7 +36,7 @@ value class EventId(@Serializable(with = UuidSerializer::class) val value: Uuid)
@Serializable
@JvmInline
value class AggregateId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
companion object
}
/**
@@ -45,7 +45,7 @@ value class AggregateId(@Serializable(with = UuidSerializer::class) val value: U
@Serializable
@JvmInline
value class CorrelationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
companion object
}
/**
@@ -54,7 +54,7 @@ value class CorrelationId(@Serializable(with = UuidSerializer::class) val value:
@Serializable
@JvmInline
value class CausationId(@Serializable(with = UuidSerializer::class) val value: Uuid) {
override fun toString(): String = value.toString()
companion object
}
// === Domain Value Classes ===
@@ -2,8 +2,6 @@ package at.mocode.core.domain.serialization
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
import kotlin.time.Instant // KORRIGIERT: Finaler Wechsel zu kotlin.time
import kotlin.time.ExperimentalTime
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -13,34 +11,86 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
object UuidSerializer : KSerializer<Uuid> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uuid) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString())
}
/**
* Serializer für kotlin.time. Instant Objekte.
* Konvertiert Instant zu/von ISO-8601 String-Repräsentation.
*/
@OptIn(ExperimentalTime::class)
object KotlinInstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: Instant) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Instant {
return Instant.parse(decoder.decodeString())
}
}
object KotlinLocalDateSerializer : KSerializer<LocalDate> {
/**
* Serializer für UUID Objekte.
* Konvertiert UUID zu/von String-Repräsentation.
*/
object UuidSerializer : KSerializer<Uuid> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uuid) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Uuid {
return uuidFrom(decoder.decodeString())
}
}
/**
* Serializer für kotlinx.datetime.LocalDate Objekte.
* Konvertiert LocalDate zu/von ISO-8601 String-Repräsentation.
*/
object LocalDateSerializer : KSerializer<LocalDate> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDate) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalDate = LocalDate.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: LocalDate) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDate {
return LocalDate.parse(decoder.decodeString())
}
}
object KotlinLocalDateTimeSerializer : KSerializer<LocalDateTime> {
/**
* Serializer für kotlinx.datetime. LocalDateTime Objekte.
* Konvertiert LocalDateTime zu/von ISO-8601 String-Repräsentation.
*/
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalDateTime = LocalDateTime.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString())
}
}
object KotlinLocalTimeSerializer : KSerializer<LocalTime> {
/**
* Serializer für kotlinx.datetime.LocalTime Objekte.
* Konvertiert LocalTime zu/von ISO-8601 String-Repräsentation.
*/
object LocalTimeSerializer : KSerializer<LocalTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalTime) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalTime = LocalTime.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: LocalTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalTime {
return LocalTime.parse(decoder.decodeString())
}
}
@@ -0,0 +1,53 @@
package at.mocode.core.domain
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorCode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(kotlin.time.ExperimentalTime::class)
class ApiResponseTest {
@Test
fun `success factory sets flags and timestamp`() {
val res = ApiResponse.success(data = 42)
assertTrue(res.success)
assertEquals(42, res.data)
assertTrue(res.errors.isEmpty())
assertNotNull(res.timestamp)
}
@Test
fun `error factory with code object`() {
val res = ApiResponse.error<Int>(ErrorCode("INVALID_INPUT"), "Fehlerhafte Eingabe", field = "name")
assertFalse(res.success)
assertNull(res.data)
assertEquals(1, res.errors.size)
assertEquals("INVALID_INPUT", res.errors.first().code.value)
assertEquals("Fehlerhafte Eingabe", res.errors.first().message)
assertEquals("name", res.errors.first().field)
assertNotNull(res.timestamp)
}
@Test
fun `error factory with code string`() {
val res = ApiResponse.error<Int>("NOT_FOUND", "Nicht gefunden")
assertFalse(res.success)
assertNull(res.data)
assertEquals(1, res.errors.size)
assertEquals("NOT_FOUND", res.errors.first().code.value)
}
@Test
fun `error factory with list`() {
val res = ApiResponse.error<Int>(listOf())
assertFalse(res.success)
assertNull(res.data)
assertTrue(res.errors.isEmpty())
assertNotNull(res.timestamp)
}
}
@@ -0,0 +1,63 @@
package at.mocode.core.domain
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.*
import com.benasher44.uuid.uuid4
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@OptIn(kotlin.time.ExperimentalTime::class)
class BaseDomainEventTest {
@kotlinx.serialization.Serializable
data class TestEvent(
val name: String,
// Delegiert an BaseDomainEvent
private val base: BaseDomainEvent
) : BaseDomainEvent(
aggregateId = base.aggregateId,
eventType = base.eventType,
version = base.version,
eventId = base.eventId,
timestamp = base.timestamp,
correlationId = base.correlationId,
causationId = base.causationId
)
@Test
fun `secondary constructor generates id and timestamp`() {
val aggId = AggregateId(uuid4())
val ev = object : BaseDomainEvent(
aggregateId = aggId,
eventType = EventType("TestEvent"),
version = EventVersion(1)
) {}
assertNotNull(ev.eventId)
assertNotNull(ev.timestamp)
assertEquals(aggId, ev.aggregateId)
assertEquals(EventType("TestEvent"), ev.eventType)
assertEquals(EventVersion(1), ev.version)
}
@Test
fun `primary constructor uses provided id and timestamp`() {
val aggId = AggregateId(uuid4())
val eid = EventId(uuid4())
val ts = kotlin.time.Instant.parse("2025-01-01T00:00:00Z")
val base = object : BaseDomainEvent(
aggregateId = aggId,
eventType = EventType("TestEvent"),
version = EventVersion(2),
eventId = eid,
timestamp = ts,
correlationId = CorrelationId(uuid4()),
causationId = CausationId(uuid4())
) {}
assertEquals(eid, base.eventId)
assertEquals(ts, base.timestamp)
assertEquals(EventVersion(2), base.version)
}
}
@@ -0,0 +1,54 @@
package at.mocode.core.domain
import at.mocode.core.domain.serialization.*
import com.benasher44.uuid.uuid4
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(kotlin.time.ExperimentalTime::class)
class SerializersTest {
@Test
fun `Instant roundtrip`() {
val instant = kotlin.time.Instant.parse("2024-01-01T00:00:00Z")
val json = Json.encodeToString(KotlinInstantSerializer, instant)
val decoded = Json.decodeFromString(KotlinInstantSerializer, json)
assertEquals(instant, decoded)
}
@Test
fun `UUID roundtrip`() {
val uuid = uuid4()
val json = Json.encodeToString(UuidSerializer, uuid)
val decoded = Json.decodeFromString(UuidSerializer, json)
assertEquals(uuid, decoded)
}
@Test
fun `LocalDate roundtrip`() {
val ld = LocalDate.parse("2024-06-15")
val json = Json.encodeToString(LocalDateSerializer, ld)
val decoded = Json.decodeFromString(LocalDateSerializer, json)
assertEquals(ld, decoded)
}
@Test
fun `LocalDateTime roundtrip`() {
val ldt = LocalDateTime.parse("2024-06-15T12:34:56")
val json = Json.encodeToString(LocalDateTimeSerializer, ldt)
val decoded = Json.decodeFromString(LocalDateTimeSerializer, json)
assertEquals(ldt, decoded)
}
@Test
fun `LocalTime roundtrip`() {
val lt = LocalTime.parse("12:34:56")
val json = Json.encodeToString(LocalTimeSerializer, lt)
val decoded = Json.decodeFromString(LocalTimeSerializer, json)
assertEquals(lt, decoded)
}
}
@@ -0,0 +1,46 @@
package at.mocode.core.domain
import at.mocode.core.domain.model.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class ValueTypesTest {
@Test
fun `EventType validation works`() {
assertFailsWith<IllegalArgumentException> { EventType("") }
assertFailsWith<IllegalArgumentException> { EventType("1Bad") }
assertFailsWith<IllegalArgumentException> { EventType("bad-char!") }
assertEquals("OrderCreated", EventType("OrderCreated").toString())
}
@Test
fun `EventVersion must be non-negative and comparable`() {
assertFailsWith<IllegalArgumentException> { EventVersion(-1) }
assertEquals(0, EventVersion(0).compareTo(EventVersion(0)))
assertTrue(EventVersion(2) > EventVersion(1))
}
@Test
fun `ErrorCode must be uppercase with allowed characters`() {
assertFailsWith<IllegalArgumentException> { ErrorCode("") }
assertFailsWith<IllegalArgumentException> { ErrorCode("abc") }
assertFailsWith<IllegalArgumentException> { ErrorCode("Bad_Code") }
assertEquals("VALID_CODE1", ErrorCode("VALID_CODE1").toString())
}
@Test
fun `PageNumber must be non-negative`() {
assertFailsWith<IllegalArgumentException> { PageNumber(-1) }
assertEquals("0", PageNumber(0).toString())
}
@Test
fun `PageSize range is enforced`() {
assertFailsWith<IllegalArgumentException> { PageSize(0) }
assertFailsWith<IllegalArgumentException> { PageSize(1001) }
assertEquals("1000", PageSize(1000).toString())
}
}
@@ -1,15 +0,0 @@
package at.mocode.core.domain.model
import kotlinx.serialization.Serializable
/**
* 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,
IMPORT_ZNS,
SYSTEM_GENERATED,
IMPORT_API
}
@@ -1,37 +0,0 @@
package at.mocode.core.utils.error
/**
* A functional approach to error handling, avoiding exceptions for predictable errors.
* Represents a value that can either be a Success (containing the result) or a Failure (containing an error).
*
* @param T The type of the success value.
* @param E The type of the error value.
*/
sealed class Result<out T, out E> {
data class Success<out T>(val value: T) : Result<T, Nothing>()
data class Failure<out E>(val error: E) : Result<Nothing, E>()
val isSuccess: Boolean get() = this is Success
val isFailure: Boolean get() = this is Failure
fun getOrNull(): T? = when (this) {
is Success -> value
is Failure -> null
}
fun getOrElse(defaultValue: @UnsafeVariance T): T = when (this) {
is Success -> value
is Failure -> defaultValue
}
}
// Extension functions for convenient usage
inline fun <T, E> Result<T, E>.onSuccess(action: (T) -> Unit): Result<T, E> {
if (this is Result.Success) action(value)
return this
}
inline fun <T, E> Result<T, E>.onFailure(action: (E) -> Unit): Result<T, E> {
if (this is Result.Failure) action(error)
return this
}
@@ -1,48 +0,0 @@
package at.mocode.core.utils.validation
/**
* Represents a single validation error.
* @param field The name of the field that failed validation.
* @param message A user-friendly error message.
* @param code A machine-readable error code for the client.
*/
data class ValidationError(
val field: String,
val message: String,
val code: String? = null
)
/**
* Represents the result of a validation process as a sealed class.
* This ensures that a result is either Valid or Invalid, but never both.
*/
sealed class ValidationResult {
/**
* Represents a successful validation.
*/
object Valid : ValidationResult()
/**
* Represents a failed validation with a list of specific errors.
*/
data class Invalid(val errors: List<ValidationError>) : ValidationResult()
fun isValid(): Boolean = this is Valid
fun isInvalid(): Boolean = this is Invalid
companion object {
fun invalid(field: String, message: String, code: String? = null): Invalid {
return Invalid(listOf(ValidationError(field, message, code)))
}
}
}
/**
* An exception that can be thrown to represent validation failure,
* allowing it to be caught by centralized error handling (like Ktor StatusPages).
*/
class ValidationException(
val validationResult: ValidationResult.Invalid
) : IllegalArgumentException(
"Validation failed: ${validationResult.errors.joinToString { "${it.field}: ${it.message}" }}"
)
@@ -1,67 +0,0 @@
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<Unit>(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<Unit>(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")
}
}
@@ -1,52 +0,0 @@
package at.mocode.core.domain
import at.mocode.core.domain.event.BaseDomainEvent
import at.mocode.core.domain.model.*
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: AggregateId = AggregateId(uuid4()),
@Transient
override val version: EventVersion = EventVersion(1L),
val testPayload: String = "Test"
) : BaseDomainEvent(
aggregateId = aggregateId,
eventType = EventType("TestEventOccurred"), // Ein klar definierter Event-Typ
version = version
)
@Test
fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() {
// Arrange
val aggregateId = AggregateId(uuid4())
val version = EventVersion(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(EventType("TestEventOccurred"), event.eventType, "eventType should be set correctly")
}
}
@@ -1,78 +0,0 @@
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")
}
}
+20 -14
View File
@@ -11,6 +11,7 @@ kotlin {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
}
js(IR) {
browser()
}
@@ -18,44 +19,45 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// Abhängigkeit zum core-domain-Modul, um dessen Typen zu verwenden
// Dependency on core-domain module to use its types
api(projects.core.coreDomain)
// Asynchronität (available for all platforms) - explicit version to avoid BOM issues
// Async support (available for all platforms)
api(libs.kotlinx.coroutines.core)
// Utilities (multiplatform compatible)
api(libs.bignum)
}
}
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
val jvmMain by getting {
dependencies {
// Abhängigkeit zum platform-Modul für zentrale Versionsverwaltung
// JVM-specific dependencies - access to central catalog
api(projects.platform.platformDependencies)
// Datenbank-Management (JVM-specific)
// OPTIMIERUNG: Verwendung von Bundles für Exposed und Flyway
// Database Management (JVM-specific)
api(libs.bundles.exposed)
api(libs.bundles.flyway)
api(libs.hikari.cp)
// Service Discovery (JVM-specific)
// api(libs.consul.client) wird getauscht mir spring-cloud-starter-consul-discovery
api(libs.spring.cloud.starter.consul.discovery)
// Logging (JVM-specific)
api(libs.kotlin.logging.jvm)
// JVM-specific utilities
implementation(libs.room.common.jvm) // Für BigDecimal Serialisierung
}
}
// Jakarta Annotation API
api(libs.jakarta.annotation.api)
val commonTest by getting {
dependencies {
implementation(libs.kotlin.test)
// JSON Processing
api(libs.jackson.module.kotlin)
api(libs.jackson.datatype.jsr310)
}
}
@@ -69,3 +71,7 @@ kotlin {
}
}
}
tasks.named<Test>("jvmTest") {
useJUnitPlatform()
}
@@ -0,0 +1,123 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.*
import com.benasher44.uuid.uuid4
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
import kotlin.time.Clock
/**
* Extension-Funktionen für häufig verwendete Operationen im gesamten System.
*/
// === UUID Generation Extensions ===
/**
* Erstellt eine neue EntityId mit einer zufälligen UUID.
*/
fun EntityId.Companion.random(): EntityId = EntityId(uuid4())
/**
* Erstellt eine neue EventId mit einer zufälligen UUID.
*/
fun EventId.Companion.random(): EventId = EventId(uuid4())
/**
* Erstellt eine neue AggregateId mit einer zufälligen UUID.
*/
fun AggregateId.Companion.random(): AggregateId = AggregateId(uuid4())
/**
* Erstellt eine neue CorrelationId mit einer zufälligen UUID.
*/
fun CorrelationId.Companion.random(): CorrelationId = CorrelationId(uuid4())
/**
* Erstellt eine neue CausationId mit einer zufälligen UUID.
*/
fun CausationId.Companion.random(): CausationId = CausationId(uuid4())
// === String Extensions ===
/**
* Konvertiert einen String zu einem EventType mit Validierung.
*/
fun String.toEventType(): EventType = EventType(this)
/**
* Konvertiert einen String zu einem ErrorCode mit Validierung.
*/
fun String.toErrorCode(): ErrorCode = ErrorCode(this)
/**
* Prüft ob der String ein gültiger EventType-Name ist.
*/
fun String.isValidEventType(): Boolean {
return isNotBlank() && matches(Regex("^[A-Za-z][A-Za-z0-9]*$"))
}
/**
* Prüft ob der String ein gültiger ErrorCode ist.
*/
fun String.isValidErrorCode(): Boolean {
return isNotBlank() && matches(Regex("^[A-Z][A-Z0-9_]*$"))
}
// === Collection Extensions ===
/**
* Erstellt eine PagedResponse aus einer Liste mit Standard-Paginierung.
*/
fun <T> List<T>.toPagedResponse(
page: Int = 0,
size: Int = 20
): PagedResponse<T> {
val startIndex = page * size
val endIndex = minOf(startIndex + size, this.size)
val content = if (startIndex < this.size) this.subList(startIndex, endIndex) else emptyList()
return PagedResponse.create(
content = content,
page = page,
size = size,
totalElements = this.size.toLong(),
totalPages = (this.size + size - 1) / size,
hasNext = endIndex < this.size,
hasPrevious = page > 0
)
}
// === Validation Extensions ===
/**
* Erstellt eine Liste von ValidationError aus einer Map von Fehlern.
*/
fun Map<String, String>.toValidationErrors(): List<ValidationError> {
return this.map { (field, message) -> ValidationError(field, message, "VALIDATION_ERROR") }
}
/**
* Prüft ob eine Liste von ValidationError leer ist.
*/
fun List<ValidationError>.hasErrors(): Boolean = this.isNotEmpty()
/**
* Konvertiert eine Liste von ValidationError zu ErrorDto.
*/
fun List<ValidationError>.toErrorDtos(): List<ErrorDto> {
return this.map { ErrorDto(ErrorCode(it.code), it.message, it.field) }
}
// === Time Extensions ===
/**
* Prüft ob ein Zeitstempel in der Vergangenheit liegt.
*/
@OptIn(ExperimentalTime::class)
fun Instant.isPast(): Boolean = this < Clock.System.now()
/**
* Prüft ob ein Zeitstempel in der Zukunft liegt.
*/
@OptIn(ExperimentalTime::class)
fun Instant.isFuture(): Boolean = this > Clock.System.now()
@@ -0,0 +1,338 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.ValidationError
import kotlin.jvm.JvmName
/**
* Typsichere Result-Klasse für Fehlermanagement im gesamten System.
* Bietet einen funktionalen Ansatz zur Fehlerbehandlung ohne Exceptions.
*/
sealed class Result<out T> {
/**
* Represents a successful operation with a value.
*/
data class Success<T>(val value: T) : Result<T>()
/**
* Represents a failed operation with error messages.
*/
data class Failure(val errors: List<ErrorDto>) : Result<Nothing>()
/**
* Checks if the Result is a success.
*/
val isSuccess: Boolean get() = this is Success
/**
* Checks if the Result is a failure.
*/
val isFailure: Boolean get() = this is Failure
/**
* Gets the value if it's a success, otherwise null.
*
* @return the value if this is a Success, or null if this is a Failure
*/
fun getOrNull(): T? = when (this) {
is Success -> value
is Failure -> null
}
/**
* Gets the value if it's a success, otherwise the default value.
*
* @param defaultValue the value to return if this is a Failure
* @return the value if this is a Success, or the default value if this is a Failure
*/
fun getOrDefault(defaultValue: @UnsafeVariance T): T = when (this) {
is Success -> value
is Failure -> defaultValue
}
/**
* Gets the errors if it's a failure, otherwise an empty list.
*
* @return the list of errors if this is a Failure, or an empty list if this is a Success
*/
@JvmName("retrieveErrors")
fun getErrors(): List<ErrorDto> = when (this) {
is Success -> emptyList()
is Failure -> errors
}
/**
* Transforms the value if it's a success.
*
* @param transform function to apply to the success value
* @return a new Success with the transformed value if this is a Success, or this unchanged Failure
*/
inline fun <R> map(transform: (T) -> R): Result<R> = when (this) {
is Success -> Success(transform(value))
is Failure -> this
}
/**
* Transforms the Result flatly (for nested Results).
* Unlike map, which wraps the transformed value in a new Success, flatMap uses the Result returned by the transform function.
*
* @param transform function that returns a Result
* @return the Result returned by the transform function if this is a Success, or this unchanged Failure
*/
inline fun <R> flatMap(transform: (T) -> Result<R>): Result<R> = when (this) {
is Success -> transform(value)
is Failure -> this
}
/**
* Executes an action if it's a success.
*
* @param action the function to execute with the success value
* @return this Result, unchanged, to allow for chaining
*/
inline fun onSuccess(action: (T) -> Unit): Result<T> {
if (this is Success) action(value)
return this
}
/**
* Executes an action if it's a failure.
*
* @param action the function to execute with the list of errors
* @return this Result, unchanged, to allow for chaining
*/
inline fun onFailure(action: (List<ErrorDto>) -> Unit): Result<T> {
if (this is Failure) action(errors)
return this
}
/**
* Transforms the Result by applying one of two functions depending on whether it's a success or failure.
*
* @param onSuccess function to apply if this is a success
* @param onFailure function to apply if this is a failure
* @return the result of applying the appropriate function
*/
inline fun <R> fold(
onSuccess: (T) -> R,
onFailure: (List<ErrorDto>) -> R
): R = when (this) {
is Success -> onSuccess(value)
is Failure -> onFailure(errors)
}
/**
* Attempts to recover from a failure by applying the specified function to the error list.
* If this is already a success, it is returned unchanged.
*
* @param transform function to apply to the error list to recover
* @return a new Success if recovery was successful, or this unchanged Result if already a success
*/
inline fun recover(transform: (List<ErrorDto>) -> @UnsafeVariance T): Result<T> = when (this) {
is Success -> this
is Failure -> Success(transform(errors))
}
/**
* Attempts to recover from a failure by applying the specified function to the error list.
* If this is already a success, it is returned unchanged.
* If an exception occurs during recovery, it is converted to a new failure.
*
* @param transform function to apply to the error list to recover
* @return a new Success if recovery was successful, a new Failure if recovery threw an exception,
* or this unchanged Result if already a success
*/
inline fun recoverCatching(transform: (List<ErrorDto>) -> @UnsafeVariance T): Result<T> = when (this) {
is Success -> this
is Failure -> try {
Success(transform(errors))
} catch (e: Exception) {
Failure(listOf(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("RECOVERY_FAILED"),
message = e.message ?: "Recovery failed with an unknown error"
)))
}
}
/**
* Combines this Result with another Result, creating a pair of their values if both are successful.
* If either Result is a failure, the combined Result will be a failure containing all errors.
*
* @param other the Result to combine with this one
* @return a Result containing a Pair of values if both are successful, or a Failure with all errors
*/
fun <R> zip(other: Result<R>): Result<Pair<T, R>> = when {
this is Success && other is Success -> Success(Pair(this.value, other.value))
this is Success && other is Failure -> Failure(other.errors)
this is Failure && other is Success -> Failure(this.errors)
this is Failure && other is Failure -> {
val allErrors = this.errors + other.errors
Failure(allErrors)
}
// This branch should never be reached due to sealed class, but included for completeness
else -> throw IllegalStateException("Unreachable code - Result should be either Success or Failure")
}
/**
* Safely attempts to get the value, throwing a custom exception if this is a failure.
*
* @param errorHandler function that converts the list of errors to an exception
* @return the value if this is a Success
* @throws E if this is a Failure, as created by the errorHandler
*/
inline fun <E : Throwable> getOrThrow(errorHandler: (List<ErrorDto>) -> E): T = when (this) {
is Success -> value
is Failure -> throw errorHandler(errors)
}
/**
* Gets the value if it's a success, or throws an IllegalStateException with a message constructed from the errors.
*
* @return the value if this is a Success
* @throws IllegalStateException if this is a Failure, with a message containing the error details
*/
fun getOrThrow(): T = getOrThrow { errors ->
IllegalStateException("Result is a Failure with errors: ${errors.joinToString { it.message }}")
}
companion object {
/**
* Creates a successful Result.
*
* @param value the value to wrap in a Success
* @return a new Success containing the provided value
*/
fun <T> success(value: T): Result<T> = Success(value)
/**
* Creates a failure Result with a single error.
*
* @param error the error to include in the Failure
* @return a new Failure containing the provided error
*/
fun <T> failure(error: ErrorDto): Result<T> = Failure(listOf(error))
/**
* Creates a failure Result with multiple errors.
*
* @param errors the list of errors to include in the Failure
* @return a new Failure containing the provided errors
*/
fun <T> failure(errors: List<ErrorDto>): Result<T> = Failure(errors)
/**
* Creates a failure Result from ValidationErrors.
* Converts the ValidationErrors to ErrorDtos internally.
*
* @param validationErrors the list of validation errors to convert and include in the Failure
* @return a new Failure containing ErrorDtos converted from the provided ValidationErrors
*/
@JvmName("failureFromValidationErrors")
fun <T> failure(validationErrors: List<ValidationError>): Result<T> =
Failure(validationErrors.toErrorDtos())
/**
* Executes an operation that returns a Result and catches exceptions.
* Provides more specific error codes based on the type of exception caught.
*
* @param operation the operation to execute
* @return a Success with the operation result, or a Failure with error details if an exception occurred
*/
inline fun <T> runCatching(operation: () -> T): Result<T> = try {
success(operation())
} catch (e: IllegalArgumentException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_ARGUMENT"),
message = e.message ?: "Invalid argument provided"
))
} catch (e: IllegalStateException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_STATE"),
message = e.message ?: "Operation called in invalid state"
))
} catch (e: UnsupportedOperationException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("UNSUPPORTED_OPERATION"),
message = e.message ?: "Operation not supported"
))
} catch (e: IndexOutOfBoundsException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INDEX_OUT_OF_BOUNDS"),
message = e.message ?: "Index out of bounds"
))
} catch (e: NullPointerException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_REFERENCE"),
message = e.message ?: "Unexpected null reference"
))
} catch (e: ClassCastException) {
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("TYPE_MISMATCH"),
message = e.message ?: "Type mismatch occurred"
))
} catch (e: Exception) {
// Fallback for any other exception type
failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("OPERATION_FAILED"),
message = e.message ?: "Unknown error occurred"
))
}
/**
* Combines multiple Results into a single Result with a list.
* Optimized for performance with large collections.
*
* @param results a list of Results to combine
* @return a Success containing a list of all success values if all Results are successful,
* or a Failure containing all error messages if any Results are failures
*/
fun <T> combine(results: List<Result<T>>): Result<List<T>> {
// Fast path for empty list
if (results.isEmpty()) {
return success(emptyList())
}
// Fast path for single result
if (results.size == 1) {
return results.first().map { listOf(it) }
}
// Check if there are any failures
val anyFailure = results.any { it.isFailure }
// If no failures, we can optimize by directly mapping to values
if (!anyFailure) {
return success(results.map { (it as Success).value })
}
// If there are failures, collect all errors
val errors = results
.filterIsInstance<Failure>()
.flatMap { it.errors }
// If empty results list contained no failures, return empty success
if (errors.isEmpty()) {
return success(emptyList())
}
return failure(errors)
}
}
}
/**
* Extension function to convert nullable values to Results.
* This is useful for handling nullable values in a functional way.
*
* @param errorMessage custom error message to use when the value is null
* @return a Success containing the non-null value, or a Failure if the value is null
*/
fun <T> T?.toResult(errorMessage: String = "Value is null"): Result<T> =
if (this != null) {
Result.success(this)
} else {
Result.failure(ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_VALUE"),
message = errorMessage
))
}
@@ -0,0 +1,224 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ValidationError
/**
* Umfassende Validierungs-Utilities für das gesamte System.
* Stellt typsichere und wiederverwendbare Validierungslogik bereit.
*/
/**
* Builder-Klasse für die Erstellung von Validierungsregeln.
*/
class ValidationBuilder {
private val errors = mutableListOf<ValidationError>()
/**
* Validiert ein Feld gegen mehrere Regeln.
*/
fun <T> field(name: String, value: T, vararg rules: ValidationRule<T>): ValidationBuilder {
rules.forEach { rule ->
rule.validate(name, value)?.let { error ->
errors.add(error)
}
}
return this
}
/**
* Fügt einen benutzerdefinierten Validierungsfehler hinzu.
*/
fun addError(field: String, message: String, code: String = "VALIDATION_ERROR"): ValidationBuilder {
errors.add(ValidationError(field, message, code))
return this
}
/**
* Führt eine benutzerdefinierten Validierung aus.
*/
fun custom(validation: () -> ValidationError?): ValidationBuilder {
validation()?.let { error ->
errors.add(error)
}
return this
}
/**
* Erstellt das finale Validierungsergebnis.
*/
fun build(): Result<Unit> {
return if (errors.isEmpty()) {
Result.success(Unit)
} else {
Result.failure(errors)
}
}
/**
* Gibt die gesammelten Fehler zurück.
*/
fun getErrors(): List<ValidationError> = errors.toList()
}
/**
* Interface für Validierungsregeln.
*/
fun interface ValidationRule<T> {
/**
* Validiert einen Wert und gibt einen Fehler zurück, wenn die Validierung fehlschlägt.
*/
fun validate(fieldName: String, value: T): ValidationError?
}
/**
* Vordefinierte Validierungsregeln.
*/
object ValidationRules {
// === String-Validierungen ===
/**
* Prüft ob ein String nicht leer ist.
*/
fun notBlank(): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.isBlank()) ValidationError.required(fieldName) else null
}
/**
* Prüft die Mindestlänge eines Strings.
*/
fun minLength(min: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length < min) {
ValidationError.invalidLength(fieldName, "$fieldName must be at least $min characters long")
} else null
}
/**
* Prüft die Maximallänge eines Strings.
*/
fun maxLength(max: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not exceed $max characters")
} else null
}
/**
* Prüft ob ein String einem RegEx-Pattern entspricht.
*/
fun matches(pattern: Regex, message: String): ValidationRule<String> = ValidationRule { fieldName, value ->
if (!value.matches(pattern)) {
ValidationError.invalidFormat(fieldName, message)
} else null
}
/**
* Prüft ob ein String eine gültige E-Mail-Adresse ist.
*/
fun email(): ValidationRule<String> = ValidationRule { fieldName, value ->
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!value.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
} else null
}
// === Numerische Validierungen ===
/**
* Prüft den Mindestwert einer Zahl.
*/
fun <T : Comparable<T>> min(minValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value < minValue) {
ValidationError.invalidRange(fieldName, "$fieldName must be at least $minValue")
} else null
}
/**
* Prüft den Maximalwert einer Zahl.
*/
fun <T : Comparable<T>> max(maxValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value > maxValue) {
ValidationError.invalidRange(fieldName, "$fieldName must not exceed $maxValue")
} else null
}
/**
* Prüft ob eine Zahl positiv ist.
*/
fun positive(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() <= 0) {
ValidationError.invalidRange(fieldName, "$fieldName must be positive")
} else null
}
/**
* Prüft ob eine Zahl nicht negativ ist.
*/
fun nonNegative(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() < 0) {
ValidationError.invalidRange(fieldName, "$fieldName must not be negative")
} else null
}
// === Collection-Validierungen ===
/**
* Prüft ob eine Collection nicht leer ist.
*/
fun <T> notEmpty(): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.isEmpty()) {
ValidationError.required(fieldName)
} else null
}
/**
* Prüft die Mindestgröße einer Collection.
*/
fun <T> minSize(min: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size < min) {
ValidationError.invalidLength(fieldName, "$fieldName must contain at least $min items")
} else null
}
/**
* Prüft die Maximalgröße einer Collection.
*/
fun <T> maxSize(max: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not contain more than $max items")
} else null
}
// === Null-Validierungen ===
/**
* Prüft ob ein Wert nicht null ist.
*/
fun <T> notNull(): ValidationRule<T?> = ValidationRule { fieldName, value ->
if (value == null) ValidationError.required(fieldName) else null
}
}
/**
* DSL-Funktion für die Erstellung von Validierungen.
*/
inline fun validate(builder: ValidationBuilder.() -> Unit): Result<Unit> {
return ValidationBuilder().apply(builder).build()
}
/**
* Extension-Funktion für einfache String-Validierung.
*/
fun String?.validateNotBlank(fieldName: String): ValidationError? {
return if (this.isNullOrBlank()) ValidationError.required(fieldName) else null
}
/**
* Extension-Funktion für einfache E-Mail-Validierung.
*/
fun String?.validateEmail(fieldName: String): ValidationError? {
if (this.isNullOrBlank()) return ValidationError.required(fieldName)
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
return if (!this.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
} else null
}
@@ -0,0 +1,36 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.PageNumber
import at.mocode.core.domain.model.PageSize
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ExtensionsPagedResponseTest {
@Test
fun `toPagedResponse basic pagination`() {
val list = (1..50).toList()
val page0 = list.toPagedResponse(page = 0, size = 10)
assertEquals(10, page0.content.size)
assertEquals(PageNumber(0), page0.page)
assertEquals(PageSize(10), page0.size)
assertEquals(50L, page0.totalElements)
assertEquals(5, page0.totalPages)
assertTrue(page0.hasNext)
assertFalse(page0.hasPrevious)
val page4 = list.toPagedResponse(page = 4, size = 10)
assertEquals((41..50).toList(), page4.content)
assertFalse(page4.hasNext)
assertTrue(page4.hasPrevious)
val emptyPage = list.toPagedResponse(page = 6, size = 10)
assertTrue(emptyPage.content.isEmpty())
assertEquals(5, emptyPage.totalPages)
assertFalse(emptyPage.hasNext)
assertTrue(emptyPage.hasPrevious)
}
}
@@ -0,0 +1,107 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorCode
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.ValidationError
import kotlin.test.*
class ResultTest {
@Test
fun `success and failure flags`() {
val s = Result.success(1)
assertTrue(s.isSuccess)
assertFalse(s.isFailure)
val f: Result<Int> = Result.failure(ErrorDto(ErrorCode("E"), "m"))
assertTrue(f.isFailure)
assertFalse(f.isSuccess)
}
@Test
fun `map flatMap fold`() {
val s = Result.success(2).map { it * 2 }
assertEquals(4, (s as Result.Success).value)
val f: Result<Int> = Result.failure(ErrorDto(ErrorCode("E"), "m"))
assertTrue(f.map { it + 1 } is Result.Failure)
val flat = Result.success(2).flatMap { Result.success(it.toString()) }
assertEquals("2", (flat as Result.Success).value)
val folded = flat.fold({ it.length }, { -1 })
assertEquals(1, folded)
}
@Test
fun `zip and combine`() {
val a = Result.success(1)
val b = Result.success("x")
val zipped = a.zip(b)
assertTrue(zipped is Result.Success)
assertEquals(Pair(1, "x"), (zipped as Result.Success).value)
val f1: Result<Int> = Result.failure(ErrorDto(ErrorCode("E1"), ""))
val f2: Result<String> = Result.failure(ErrorDto(ErrorCode("E2"), ""))
val z2 = f1.zip(b)
assertTrue(z2 is Result.Failure)
val combined = Result.combine(listOf(Result.success(1), Result.success(2)))
assertTrue(combined is Result.Success)
assertEquals(listOf(1, 2), (combined as Result.Success).value)
val combinedFail = Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), ""))))
assertTrue(combinedFail is Result.Failure)
assertEquals(2, (combinedFail as Result.Failure).errors.size)
}
@Test
fun `runCatching failure conversion failureFromValidation and recovery`() {
val ok = Result.runCatching { "ok" }
assertTrue(ok is Result.Success)
val iae = Result.runCatching<String> { throw IllegalArgumentException("bad") }
assertTrue(iae is Result.Failure)
assertEquals("INVALID_ARGUMENT", (iae as Result.Failure).errors.first().code.value)
val generic = Result.runCatching<String> { throw Exception("x") }
assertTrue(generic is Result.Failure)
val verrs = listOf(ValidationError.required("name"), ValidationError.invalidFormat("email"))
val fromVal: Result<Unit> = Result.failure(verrs)
assertTrue(fromVal is Result.Failure)
assertEquals("REQUIRED", (fromVal as Result.Failure).errors.first().code.value)
val rec = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recover { _ -> "fallback" }
assertTrue(rec is Result.Success)
val recFail = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") }
assertTrue(recFail is Result.Failure)
assertEquals("RECOVERY_FAILED", (recFail as Result.Failure).errors.first().code.value)
}
@Test
fun `getOrNull default throw and toResult`() {
val s = Result.success(5)
assertEquals(5, s.getOrNull())
val f: Result<Int> = Result.failure(ErrorDto(ErrorCode("E"), ""))
assertNull(f.getOrNull())
assertEquals(7, f.getOrDefault(7))
assertEquals(5, s.getOrThrow())
try {
f.getOrThrow()
fail("should throw")
} catch (e: IllegalStateException) {
// ok
}
val nullable: Int? = null
val r = nullable.toResult("ist leer")
assertTrue(r is Result.Failure)
val r2 = 3.toResult()
assertTrue(r2 is Result.Success)
}
}
@@ -0,0 +1,234 @@
package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorCode
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.PagedResponse
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.BatchInsertStatement
import org.jetbrains.exposed.sql.transactions.transaction
import java.sql.SQLException
/**
* JVM-specific database utilities for the Core module.
* Provides common database operations and configurations.
*/
/**
* Executes a database operation in a transaction and returns a Result.
* Provides specific error handling for different database-related exceptions.
*
* @param database Optional database to use (uses default if null)
* @param block The transaction block to execute
* @return A Result containing either the operation result or error information
*/
inline fun <T> transactionResult(
database: Database? = null,
crossinline block: Transaction.() -> T
): Result<T> {
return try {
val result = transaction(database) { block() }
Result.success(result)
} catch (e: SQLException) {
// Handle specific SQL exceptions
val errorCode = when {
e.message?.contains("constraint", ignoreCase = true) == true -> "CONSTRAINT_VIOLATION"
e.message?.contains("duplicate", ignoreCase = true) == true -> "DUPLICATE_ENTRY"
e.message?.contains("timeout", ignoreCase = true) == true -> "DATABASE_TIMEOUT"
else -> "DATABASE_ERROR"
}
Result.failure(
ErrorDto(
code = ErrorCode(errorCode),
message = "Database operation failed: ${e.message}"
)
)
} catch (e: Exception) {
Result.failure(
ErrorDto(
code = ErrorCode("TRANSACTION_ERROR"),
message = "Transaction failed: ${e.message ?: "Unknown error"}"
)
)
}
}
/**
* Executes a write database operation.
*/
inline fun <T> writeTransaction(
database: Database? = null,
crossinline block: Transaction.() -> T
): Result<T> = transactionResult(database, block)
/**
* Executes a read database operation.
*/
inline fun <T> readTransaction(
database: Database? = null,
crossinline block: Transaction.() -> T
): Result<T> = transactionResult(database, block)
/**
* Extension function for Query-Builder to add pagination.
*/
fun Query.paginate(page: Int, size: Int): Query {
require(page >= 0) { "Page number must be non-negative" }
require(size > 0) { "Page size must be positive" }
return this.limit(size, offset = (page * size).toLong())
}
/**
* Creates a PagedResponse from a Query.
* Handles pagination efficiently and manages edge cases properly.
*
* @param page The requested page number (0-based)
* @param size The requested page size
* @param transform Function to transform each ResultRow to the target type
* @return A PagedResponse containing the paginated and transformed data
*/
fun <T> Query.toPagedResponse(
page: Int,
size: Int,
transform: (ResultRow) -> T
): PagedResponse<T> {
// Validate input parameters
require(page >= 0) { "Page number must be non-negative" }
require(size > 0) { "Page size must be positive" }
// Calculate the total count first (executes a COUNT query)
val totalCount = this.count()
// If there are no results, return an empty page
if (totalCount == 0L) {
return PagedResponse.create(
content = emptyList(),
page = page,
size = size,
totalElements = 0,
totalPages = 0,
hasNext = false,
hasPrevious = page > 0
)
}
// Calculate total pages - use ceil division to ensure we round up
val totalPages = ((totalCount + size - 1) / size).toInt()
// Ensure the requested page exists (if page is beyond available pages, return the last page)
val adjustedPage = if (page >= totalPages) (totalPages - 1).coerceAtLeast(0) else page
// Then apply pagination and transform results
val content = this.paginate(adjustedPage, size).map(transform)
return PagedResponse.create(
content = content,
page = adjustedPage,
size = size,
totalElements = totalCount,
totalPages = totalPages,
hasNext = adjustedPage < totalPages - 1,
hasPrevious = adjustedPage > 0
)
}
/**
* Utility class for common database operations.
*/
object DatabaseUtils {
/**
* Checks if a table exists.
* Uses a safe query approach to verify table existence.
*/
fun tableExists(tableName: String, database: Database? = null): Boolean {
return try {
transaction(database) {
// Execute a safer SQL statement to check if table exists
val result = exec("SELECT 1 FROM information_schema.tables WHERE table_name = '$tableName' LIMIT 1")
// If the query returns a result, the table exists
result != null
}
} catch (e: Exception) {
false
}
}
/**
* Creates an index if it doesn't exist.
*/
fun createIndexIfNotExists(
tableName: String,
indexName: String,
columns: Array<String>,
unique: Boolean = false,
database: Database? = null
): Result<Unit> {
return transactionResult(database) {
val uniqueStr = if (unique) "UNIQUE" else ""
val columnsStr = columns.joinToString(", ")
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $indexName ON $tableName ($columnsStr)"
exec(sql)
}
}
/**
* Executes a raw SQL query and returns the number of affected rows.
*/
fun executeRawSql(sql: String, database: Database? = null): Result<Int> {
return transactionResult(database) {
(exec(sql) ?: 0) as Int
}
}
/**
* Helper function for batch inserts.
*/
inline fun <T> batchInsert(
table: Table,
data: Iterable<T>,
crossinline body: BatchInsertStatement.(T) -> Unit
): Result<List<ResultRow>> {
return transactionResult {
table.batchInsert(data) { item ->
body(item)
}
}
}
}
/**
* Extension functions for ResultRow.
*/
/**
* Safely gets a value from a ResultRow.
*/
fun <T> ResultRow.getOrNull(column: Column<T>): T? {
return try {
this[column]
} catch (e: Exception) {
null
}
}
/**
* Converts a ResultRow to a Map.
* Safely handles any exceptions during the conversion process.
*/
fun ResultRow.toMap(): Map<String, Any?> {
val result = mutableMapOf<String, Any?>()
this.fieldIndex.forEach { (expression, _) ->
try {
when (expression) {
is Column<*> -> result[expression.name] = this[expression]
else -> result[expression.toString()] = this[expression]
}
} catch (e: Exception) {
// Ignore columns that can't be read and log the error if needed
// You could add logging here in a production environment
}
}
return result
}
@@ -1,69 +0,0 @@
package at.mocode.core.utils.config
/**
* Eine reine, unveränderliche Datenhalte-Klasse für die gesamte Anwendungskonfiguration.
* Wird vom ConfigLoader instanziiert.
*/
data class AppConfig(
val environment: AppEnvironment,
val appInfo: AppInfoConfig,
val server: ServerConfig,
val database: DatabaseConfig,
val serviceDiscovery: ServiceDiscoveryConfig,
val security: SecurityConfig,
val logging: LoggingConfig,
val rateLimit: RateLimitConfig
)
data class AppInfoConfig(
val name: ApplicationName,
val version: ApplicationVersion,
val description: String
)
data class ServerConfig(
val port: Port,
val host: Host,
val advertisedHost: Host,
val workers: WorkerCount,
val cors: CorsConfig
) {
data class CorsConfig(val enabled: Boolean, val allowedOrigins: List<String>)
}
data class DatabaseConfig(
val host: Host,
val port: Port,
val name: DatabaseName,
val jdbcUrl: JdbcUrl,
val username: DatabaseUsername,
val password: DatabasePassword,
val driverClassName: String,
val maxPoolSize: PoolSize,
val minPoolSize: PoolSize,
val autoMigrate: Boolean
)
data class ServiceDiscoveryConfig(
val enabled: Boolean,
val consulHost: Host,
val consulPort: Port
)
data class SecurityConfig(val jwt: JwtConfig, val apiKey: ApiKey?) {
data class JwtConfig(
val secret: JwtSecret,
val issuer: JwtIssuer,
val audience: JwtAudience,
val realm: JwtRealm,
val expirationInMinutes: Long
)
}
data class LoggingConfig(val level: String, val logRequests: Boolean, val logResponses: Boolean)
data class RateLimitConfig(
val enabled: Boolean,
val globalLimit: RateLimit,
val globalPeriodMinutes: PeriodMinutes
)
@@ -1,26 +0,0 @@
package at.mocode.core.utils.config
import org.slf4j.LoggerFactory
enum class AppEnvironment {
DEVELOPMENT,
TEST,
STAGING,
PRODUCTION;
fun isProduction() = this == PRODUCTION
companion object {
private val logger = LoggerFactory.getLogger(AppEnvironment::class.java)
fun current(): AppEnvironment {
val envName = System.getenv("APP_ENV")?.uppercase() ?: "DEVELOPMENT"
return try {
valueOf(envName)
} catch (_: IllegalArgumentException) {
logger.warn("Unknown environment '{}', falling back to DEVELOPMENT.", envName)
DEVELOPMENT
}
}
}
}
@@ -1,136 +0,0 @@
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 = ApplicationName(props.getProperty("app.name", "Meldestelle")),
version = ApplicationVersion(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 = Port(props.getIntProperty("server.port", "API_PORT", 8081)),
host = Host(props.getStringProperty("server.host", "API_HOST", "0.0.0.0")),
advertisedHost = Host(props.getStringProperty("server.advertisedHost", "API_HOST_ADVERTISED", defaultHost)),
workers = WorkerCount(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(host),
port = Port(port),
name = DatabaseName(name),
jdbcUrl = JdbcUrl("jdbc:postgresql://$host:$port/$name"),
username = DatabaseUsername(props.getStringProperty("database.username", "DB_USER", "meldestelle_user")),
password = DatabasePassword(props.getStringProperty("database.password", "DB_PASSWORD", "secure_password_change_me")),
driverClassName = "org.postgresql.Driver",
maxPoolSize = PoolSize(props.getIntProperty("database.maxPoolSize", "DB_MAX_POOL_SIZE", 10)),
minPoolSize = PoolSize(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 = Host(props.getStringProperty("service-discovery.consul.host", "CONSUL_HOST", "consul")),
consulPort = Port(props.getIntProperty("service-discovery.consul.port", "CONSUL_PORT", 8500))
)
private fun createSecurityConfig(props: Properties) = SecurityConfig(
jwt = SecurityConfig.JwtConfig(
secret = JwtSecret(props.getStringProperty(
"security.jwt.secret",
"JWT_SECRET",
"default-secret-please-change-in-production"
)),
issuer = JwtIssuer(props.getStringProperty("security.jwt.issuer", "JWT_ISSUER", "meldestelle-api")),
audience = JwtAudience(props.getStringProperty("security.jwt.audience", "JWT_AUDIENCE", "meldestelle-clients")),
realm = JwtRealm(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 }?.let { ApiKey(it) }
)
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 = RateLimit(props.getIntProperty("ratelimit.global.limit", "RATE_LIMIT_GLOBAL_LIMIT", 100)),
globalPeriodMinutes = PeriodMinutes(props.getIntProperty("ratelimit.global.periodMinutes", "RATE_LIMIT_GLOBAL_PERIOD", 1))
)
}
@@ -1,260 +0,0 @@
package at.mocode.core.utils.config
import kotlinx.serialization.Serializable
/**
* Value classes for strongly typed configuration parameters.
* These provide compile-time type safety for configuration values.
*/
// === Network Configuration Value Classes ===
/**
* A strongly typed wrapper for port numbers.
*/
@Serializable
@JvmInline
value class Port(val value: Int) {
init {
require(value in 1..65535) { "Port must be between 1 and 65535, got: $value" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for host names or IP addresses.
*/
@Serializable
@JvmInline
value class Host(val value: String) {
init {
require(value.isNotBlank()) { "Host cannot be blank" }
require(value.length <= 253) { "Host name cannot exceed 253 characters" }
}
override fun toString(): String = value
}
// === Database Configuration Value Classes ===
/**
* A strongly typed wrapper for database names.
*/
@Serializable
@JvmInline
value class DatabaseName(val value: String) {
init {
require(value.isNotBlank()) { "Database name cannot be blank" }
require(value.matches(Regex("^[a-zA-Z][a-zA-Z0-9_]*$"))) {
"Database name must start with a letter and contain only alphanumeric characters and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for database usernames.
*/
@Serializable
@JvmInline
value class DatabaseUsername(val value: String) {
init {
require(value.isNotBlank()) { "Database username cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for database passwords.
*/
@Serializable
@JvmInline
value class DatabasePassword(val value: String) {
init {
require(value.isNotBlank()) { "Database password cannot be blank" }
}
override fun toString(): String = "***" // Never expose the actual password
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JDBC URLs.
*/
@Serializable
@JvmInline
value class JdbcUrl(val value: String) {
init {
require(value.isNotBlank()) { "JDBC URL cannot be blank" }
require(value.startsWith("jdbc:")) { "JDBC URL must start with 'jdbc:'" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for connection pool sizes.
*/
@Serializable
@JvmInline
value class PoolSize(val value: Int) {
init {
require(value > 0) { "Pool size must be positive" }
require(value <= 1000) { "Pool size cannot exceed 1000" }
}
override fun toString(): String = value.toString()
}
// === Security Configuration Value Classes ===
/**
* A strongly typed wrapper for API keys.
*/
@Serializable
@JvmInline
value class ApiKey(val value: String) {
init {
require(value.isNotBlank()) { "API key cannot be blank" }
require(value.length >= 16) { "API key must be at least 16 characters long" }
}
override fun toString(): String = "***" // Never expose the actual key
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JWT secrets.
*/
@Serializable
@JvmInline
value class JwtSecret(val value: String) {
init {
require(value.isNotBlank()) { "JWT secret cannot be blank" }
require(value.length >= 32) { "JWT secret must be at least 32 characters long" }
}
override fun toString(): String = "***" // Never expose the actual secret
fun getValue(): String = value
}
/**
* A strongly typed wrapper for JWT issuer.
*/
@Serializable
@JvmInline
value class JwtIssuer(val value: String) {
init {
require(value.isNotBlank()) { "JWT issuer cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for JWT audience.
*/
@Serializable
@JvmInline
value class JwtAudience(val value: String) {
init {
require(value.isNotBlank()) { "JWT audience cannot be blank" }
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for JWT realm.
*/
@Serializable
@JvmInline
value class JwtRealm(val value: String) {
init {
require(value.isNotBlank()) { "JWT realm cannot be blank" }
}
override fun toString(): String = value
}
// === Application Configuration Value Classes ===
/**
* A strongly typed wrapper for application names.
*/
@Serializable
@JvmInline
value class ApplicationName(val value: String) {
init {
require(value.isNotBlank()) { "Application name cannot be blank" }
require(value.matches(Regex("^[A-Za-z][A-Za-z0-9-_]*$"))) {
"Application name must start with a letter and contain only letters, numbers, hyphens, and underscores"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for application versions.
*/
@Serializable
@JvmInline
value class ApplicationVersion(val value: String) {
init {
require(value.isNotBlank()) { "Application version cannot be blank" }
require(value.matches(Regex("^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9]+)?$"))) {
"Application version must follow semantic versioning (e.g., 1.0.0 or 1.0.0-beta)"
}
}
override fun toString(): String = value
}
/**
* A strongly typed wrapper for worker thread counts.
*/
@Serializable
@JvmInline
value class WorkerCount(val value: Int) {
init {
require(value > 0) { "Worker count must be positive" }
require(value <= Runtime.getRuntime().availableProcessors() * 4) {
"Worker count should not exceed 4 times the available processors"
}
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for rate limits.
*/
@Serializable
@JvmInline
value class RateLimit(val value: Int) {
init {
require(value > 0) { "Rate limit must be positive" }
}
override fun toString(): String = value.toString()
}
/**
* A strongly typed wrapper for time periods in minutes.
*/
@Serializable
@JvmInline
value class PeriodMinutes(val value: Int) {
init {
require(value > 0) { "Period must be positive" }
}
override fun toString(): String = value.toString()
}
@@ -1,39 +0,0 @@
package at.mocode.core.utils.config
import java.util.Properties
/**
* Liest eine String-Property, wobei eine Umgebungsvariable Vorrang hat.
*
* @param key Der Schlüssel in der '.properties-Datei'.
* @param envVar Der Name der Umgebungsvariable.
* @param default Der Standardwert, falls weder Property noch Env-Var existieren.
* @return Der geladene Konfigurationswert.
*/
fun Properties.getStringProperty(key: String, envVar: String, default: String): String {
return System.getenv(envVar) ?: this.getProperty(key, default)
}
/**
* Liest eine Integer-Property, wobei eine Umgebungsvariable Vorrang hat.
*/
fun Properties.getIntProperty(key: String, envVar: String, default: Int): Int {
val value = System.getenv(envVar) ?: this.getProperty(key)
return value?.toIntOrNull() ?: default
}
/**
* Liest eine Boolean-Property, wobei eine Umgebungsvariable Vorrang hat.
*/
fun Properties.getBooleanProperty(key: String, envVar: String, default: Boolean): Boolean {
val value = System.getenv(envVar) ?: this.getProperty(key)
return value?.toBoolean() ?: default
}
/**
* Liest eine Long-Property, wobei eine Umgebungsvariable Vorrang hat.
*/
fun Properties.getLongProperty(key: String, envVar: String, default: Long): Long {
val value = System.getenv(envVar) ?: this.getProperty(key)
return value?.toLongOrNull() ?: default
}
@@ -1,85 +0,0 @@
package at.mocode.core.utils.database
import at.mocode.core.utils.config.DatabaseConfig
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.slf4j.LoggerFactory
class DatabaseFactory(private val config: DatabaseConfig) {
private companion object {
private val logger = LoggerFactory.getLogger(DatabaseFactory::class.java)
}
private var dataSource: HikariDataSource? = null
private var database: Database? = null
fun connect() {
if (dataSource != null) {
logger.warn("Database already connected. Closing existing connection before creating a new one.")
close()
}
logger.info("Initializing database connection to ${config.jdbcUrl}")
val hikariConfig = createHikariConfig()
val ds = HikariDataSource(hikariConfig)
dataSource = ds
database = Database.connect(ds)
if (config.autoMigrate) {
runFlyway(ds)
}
}
fun close() {
dataSource?.close()
dataSource = null
database = null
logger.info("Database connection closed.")
}
suspend fun <T> dbQuery(block: suspend () -> T): T {
val db = database ?: throw IllegalStateException("Database has not been connected. Call connect() first.")
return newSuspendedTransaction(Dispatchers.IO, db = db) {
block()
}
}
private fun createHikariConfig(): HikariConfig {
return HikariConfig().apply {
driverClassName = config.driverClassName
jdbcUrl = config.jdbcUrl.value
username = config.username.value
password = config.password.getValue() // Use getValue() for password to access actual value
maximumPoolSize = config.maxPoolSize.value
minimumIdle = config.minPoolSize.value
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validationTimeout = 5000
connectionTimeout = 30000
idleTimeout = 600000
maxLifetime = 1800000
leakDetectionThreshold = 60000
poolName = "MeldestelleDbPool"
}
}
private fun runFlyway(dataSource: HikariDataSource) {
logger.info("Starting Flyway migrations...")
try {
val count = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load()
.migrate()
.migrationsExecuted
logger.info("Flyway migrations completed successfully. Applied $count migrations.")
} catch (e: Exception) {
logger.error("Flyway migration failed!", e)
throw IllegalStateException("Flyway migration failed", e)
}
}
}
@@ -1,54 +0,0 @@
package at.mocode.core.utils.serialization
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
// KORREKTUR: Der Import wurde von java.math.BigDecimal auf die korrekte Bibliothek geändert.
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlin.time.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.time.ExperimentalTime
object BigDecimalSerializer : KSerializer<BigDecimal> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BigDecimal", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: BigDecimal) = encoder.encodeString(value.toStringExpanded())
override fun deserialize(decoder: Decoder): BigDecimal = BigDecimal.parseString(decoder.decodeString())
}
object UuidSerializer : KSerializer<Uuid> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uuid) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString())
}
@OptIn(ExperimentalTime::class)
object KotlinInstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
}
object KotlinLocalDateSerializer : KSerializer<LocalDate> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDate) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalDate = LocalDate.parse(decoder.decodeString())
}
object KotlinLocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalDateTime = LocalDateTime.parse(decoder.decodeString())
}
object KotlinLocalTimeSerializer : KSerializer<LocalTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalTime) = encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): LocalTime = LocalTime.parse(decoder.decodeString())
}
@@ -1,66 +0,0 @@
package at.mocode.core.utils.validation
/**
* API-specific validation utilities for all modules.
*/
object ApiValidationUtils {
/**
* Validates query parameters with common validation rules
*/
fun validateQueryParameters(
limit: String? = null,
offset: String? = null,
): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
// Validate limit parameter
limit?.let { limitStr ->
try {
val limitValue = limitStr.toInt()
if (limitValue < 1 || limitValue > 1000) {
errors.add(ValidationError("limit", "Limit must be between 1 and 1000", "INVALID_RANGE"))
}
} catch (_: NumberFormatException) {
errors.add(ValidationError("limit", "Limit must be a valid integer", "INVALID_FORMAT"))
}
}
// Validate offset parameter
offset?.let { offsetStr ->
try {
val offsetValue = offsetStr.toInt()
if (offsetValue < 0) {
errors.add(ValidationError("offset", "Offset must be non-negative", "INVALID_RANGE"))
}
} catch (_: NumberFormatException) {
errors.add(ValidationError("offset", "Offset must be a valid integer", "INVALID_FORMAT"))
}
}
return errors
}
/**
* Validates authentication request data
*/
fun validateLoginRequest(username: String?, password: String?): List<ValidationError> {
val errors = mutableListOf<ValidationError>()
ValidationUtils.validateNotBlank(username, "username")?.let { errors.add(it) }
ValidationUtils.validateNotBlank(password, "password")?.let { errors.add(it) }
username?.let {
ValidationUtils.validateLength(it, "username", 50, 3)?.let { error -> errors.add(error) }
if (it.contains("@")) {
ValidationUtils.validateEmail(it, "username")?.let { error -> errors.add(error) }
}
}
password?.let {
ValidationUtils.validateLength(it, "password", 128, 8)?.let { error -> errors.add(error) }
}
return errors
}
}
@@ -1,49 +0,0 @@
package at.mocode.core.utils.validation
import kotlinx.serialization.Serializable
/**
* 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<ValidationError>) : ValidationResult()
fun isValid(): Boolean = this is Valid
fun isInvalid(): Boolean = this is Invalid
}
/**
* 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(
val field: String,
val message: String,
val code: String? = null
)
/**
* 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}" }}"
)
@@ -1,51 +0,0 @@
package at.mocode.core.utils.validation
/**
* Common validation utilities
*/
object ValidationUtils {
/**
* Validates that a string is not blank
*/
fun validateNotBlank(value: String?, fieldName: String): ValidationError? {
return if (value.isNullOrBlank()) {
ValidationError(fieldName, "$fieldName cannot be blank", "REQUIRED")
} else null
}
/**
* Validates string length
*/
fun validateLength(value: String?, fieldName: String, maxLength: Int, minLength: Int = 0): ValidationError? {
if (value == null) return null
return when {
value.length < minLength -> ValidationError(
fieldName,
"$fieldName must be at least $minLength characters long",
"MIN_LENGTH"
)
value.length > maxLength -> ValidationError(
fieldName,
"$fieldName cannot exceed $maxLength characters",
"MAX_LENGTH"
)
else -> null
}
}
/**
* Validates email format
*/
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()
return if (!emailRegex.matches(email)) {
ValidationError(fieldName, "Invalid email format", "INVALID_FORMAT")
} else null
}
}
@@ -1,15 +0,0 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
<logger name="at.mocode" level="debug" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
</configuration>
@@ -1,90 +0,0 @@
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.value)
assertEquals(8081, config.server.port.value) // 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.value)
assertEquals(9999, config.server.port.value)
}
@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.value, "app.name should be overridden")
assertEquals(9000, config.server.port.value, "server.port should be overridden")
assertEquals("base-db-host", config.database.host.value, "database.host should come from the base file")
}
}
@@ -1,89 +0,0 @@
package at.mocode.core.utils.database
import at.mocode.core.utils.config.*
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<Nothing>("postgres:16-alpine").apply {
withDatabaseName("testdb")
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 = Host(postgresContainer.host),
port = Port(postgresContainer.firstMappedPort),
name = DatabaseName(postgresContainer.databaseName),
jdbcUrl = JdbcUrl(postgresContainer.jdbcUrl),
username = DatabaseUsername(postgresContainer.username),
password = DatabasePassword(postgresContainer.password),
driverClassName = "org.postgresql.Driver",
maxPoolSize = PoolSize(2),
minPoolSize = PoolSize(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)
}
}
}
@@ -1,46 +0,0 @@
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" })
}
}
@@ -1,35 +0,0 @@
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"))
}
}