refactor(core): Stabilize and Refactor Shared Kernel

This commit introduces a comprehensive refactoring and stabilization of the core module, establishing a robust and well-tested foundation (Shared Kernel) for all other services.

The module has been thoroughly analyzed, cleaned up, and equipped with a professional-grade test suite.

Architectural Refinements:
- Slimmed down `core-domain` to be a true, minimal Shared Kernel by removing all domain-specific enums (`PferdeGeschlechtE`, `SparteE`, etc.). This enforces loose coupling between feature modules.
- The only remaining enum is `DatenQuelleE`, which is a cross-cutting concern.

Code Refactoring & Improvements:
- Refactored the configuration loading by introducing a `ConfigLoader` class. This decouples the `AppConfig` data classes from the loading mechanism, significantly improving the testability of components that rely on configuration.
- Unified the previously duplicated `ValidationResult` and `ValidationError` classes into a single, serializable source of truth, ensuring consistent error reporting across all APIs.

Testing Enhancements:
- Introduced a comprehensive test suite for the core module, bringing it to a production-ready quality standard.
- Implemented the "gold standard" for database testing by replacing the previous H2 approach with **Testcontainers**. The `DatabaseFactory` is now tested against a real, ephemeral PostgreSQL container, guaranteeing 100% production parity.
- Added robust unit and integration tests for critical components, including the new `ConfigLoader`, all custom `Serializers`, and the `ApiResponse` logic.
- Fixed all compilation and runtime errors in the test suite, resulting in a successful `./gradlew clean build`.
This commit is contained in:
stefan
2025-08-05 18:25:21 +02:00
parent 9e0858da8a
commit 1db41a5c62
20 changed files with 667 additions and 1267 deletions
@@ -3,105 +3,13 @@ package at.mocode.core.domain.model
import kotlinx.serialization.Serializable
/**
* Defines the source of a data record.
* Defines the source of a data record. This is a cross-cutting concern
* and therefore part of the Shared Kernel.
*/
@Serializable
enum class DatenQuelleE {
MANUELL, // Manually entered
IMPORT_ZNS, // Imported from OEPS ZNS data
SYSTEM_GENERATED // Generated by the system itself
}
/**
* Defines the gender of a horse.
*/
@Serializable
enum class PferdeGeschlechtE {
HENGST, // Male, not castrated
STUTE, // Female
WALLACH // Male, castrated
}
/**
* Person gender enumeration
*/
@Serializable
enum class GeschlechtE { M, W, D, UNBEKANNT }
/**
* Defines the different equestrian disciplines (Sparten).
* This enum is a central part of the Ubiquitous Language.
*/
@Serializable
enum class SparteE {
DRESSUR, // Dressurreiten
SPRINGEN, // Springreiten
VIELSEITIGKEIT, // Vielseitigkeitsreiten
FAHREN, // Fahrsport
VOLTIGIEREN, // Voltigieren
WESTERN, // Westernreiten
DISTANZ, // Distanzreiten
PARA_DRESSUR, // Para-Dressur
ISLAND; // Islandpferde
}
/**
* Venue/place type enumeration
*/
@Serializable
enum class PlatzTypE { AUSTRAGUNG, VORBEREITUNG, LONGIEREN, SONSTIGES }
/**
* User role enumeration for member management
*/
@Serializable
enum class RolleE {
ADMIN, // System administrator
VEREINS_ADMIN, // Club administrator
FUNKTIONAER, // Official/functionary
REITER, // Rider
TRAINER, // Trainer
RICHTER, // Judge
TIERARZT, // Veterinarian
ZUSCHAUER, // Spectator
GAST // Guest
}
/**
* Permission enumeration for access control
*/
@Serializable
enum class BerechtigungE {
// Person management
PERSON_READ,
PERSON_CREATE,
PERSON_UPDATE,
PERSON_DELETE,
// Club management
VEREIN_READ,
VEREIN_CREATE,
VEREIN_UPDATE,
VEREIN_DELETE,
// Event management
VERANSTALTUNG_READ,
VERANSTALTUNG_CREATE,
VERANSTALTUNG_UPDATE,
VERANSTALTUNG_DELETE,
// Horse management
PFERD_READ,
PFERD_CREATE,
PFERD_UPDATE,
PFERD_DELETE,
// Master data management
STAMMDATEN_READ,
STAMMDATEN_UPDATE,
// System administration
SYSTEM_ADMIN,
BENUTZER_VERWALTEN,
ROLLEN_VERWALTEN
MANUELL,
IMPORT_ZNS,
SYSTEM_GENERATED,
IMPORT_API
}
@@ -0,0 +1,67 @@
package at.mocode.core.domain
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ApiResponseTest {
@Test
fun `ApiResponse success should create a successful response with data`() {
// Arrange
val testData = "This is a test"
// Act
val response = ApiResponse.success(testData)
// Assert
assertTrue(response.success, "Response should be successful")
assertEquals(testData, response.data, "Response data should match the input data")
assertTrue(response.errors.isEmpty(), "Errors list should be empty for a successful response")
assertNotNull(response.timestamp, "Timestamp should be generated")
}
@Test
fun `ApiResponse error with single message should create a failed response with one error`() {
// Arrange
val errorCode = "NOT_FOUND"
val errorMessage = "The requested resource was not found."
val errorField = "resourceId"
// Act
val response = ApiResponse.error<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")
}
}
@@ -0,0 +1,51 @@
package at.mocode.core.domain
import at.mocode.core.domain.event.BaseDomainEvent
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
class DomainEventTest {
/**
* Eine konkrete Implementierung eines Domänen-Events zu Testzwecken.
* Repräsentiert das Ereignis, dass eine Test-Entität erstellt wurde.
*
* @param aggregateId Die ID der Entität, auf die sich das Event bezieht.
* @param version Die Versionsnummer des Events für dieses Aggregat.
* @param testPayload Ein zusätzliches Datenfeld, das für den Test relevant ist.
*/
@Serializable
data class TestEvent(
@Transient
override val aggregateId: Uuid = uuid4(),
@Transient
override val version: Long = 1L,
val testPayload: String = "Test"
) : BaseDomainEvent(
aggregateId = aggregateId,
eventType = "TestEventOccurred", // Ein klar definierter Event-Typ
version = version
)
@Test
fun `BaseDomainEvent should auto-generate eventId and timestamp upon creation`() {
// Arrange
val aggregateId = uuid4()
val version = 1L
// Act
val event = TestEvent(aggregateId, version)
// Assert
assertNotNull(event.eventId, "eventId should be automatically generated and not null")
assertNotNull(event.timestamp, "timestamp should be automatically generated and not null")
assertEquals(aggregateId, event.aggregateId, "aggregateId should be set correctly")
assertEquals(version, event.version, "version should be set correctly")
assertEquals("TestEventOccurred", event.eventType, "eventType should be set correctly")
}
}
@@ -0,0 +1,78 @@
package at.mocode.core.domain
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinLocalDateSerializer
import at.mocode.core.domain.serialization.KotlinLocalDateTimeSerializer
import at.mocode.core.domain.serialization.KotlinLocalTimeSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import kotlin.time.Clock
import kotlin.time.Instant
class SerializersTest {
private val json = Json // Standard-Json-Konfiguration für die Tests
// Hilfsklasse, um die Serializer im Kontext von kotlinx.serialization zu testen
@Serializable
data class TestContainer(
@Serializable(with = UuidSerializer::class) val uuid: Uuid,
@Serializable(with = KotlinInstantSerializer::class) val instant: Instant,
@Serializable(with = KotlinLocalDateSerializer::class) val localDate: LocalDate,
@Serializable(with = KotlinLocalDateTimeSerializer::class) val localDateTime: LocalDateTime,
@Serializable(with = KotlinLocalTimeSerializer::class) val localTime: LocalTime
)
@Test
fun `all custom serializers should correctly serialize and deserialize`() {
// Arrange
val originalObject = TestContainer(
uuid = uuid4(),
instant = Clock.System.now(),
localDate = LocalDate(2025, 8, 5),
localDateTime = LocalDateTime(2025, 8, 5, 12, 30, 0),
localTime = LocalTime(12, 30, 0)
)
// Act: Serialize
val jsonString = json.encodeToString(TestContainer.serializer(), originalObject)
// Assert: Serialization
// Wir prüfen, ob die serialisierten Werte einfache Strings sind, wie erwartet.
assertTrue(
jsonString.contains("\"uuid\":\"${originalObject.uuid}\""),
"Serialized JSON should contain the UUID as a string"
)
assertTrue(
jsonString.contains("\"instant\":\"${originalObject.instant}\""),
"Serialized JSON should contain the Instant as a string"
)
assertTrue(
jsonString.contains("\"localDate\":\"2025-08-05\""),
"Serialized JSON should contain the LocalDate as a string"
)
assertTrue(
jsonString.contains("\"localDateTime\":\"2025-08-05T12:30\""),
"Serialized JSON should contain the LocalDateTime as a string"
)
assertTrue(
jsonString.contains("\"localTime\":\"12:30\""),
"Serialized JSON should contain the LocalTime as a string"
)
// Act: Deserialize
val deserializedObject = json.decodeFromString(TestContainer.serializer(), jsonString)
// Assert: Deserialization
assertEquals(originalObject, deserializedObject, "Deserialized object should be equal to the original object")
}
}