refactor: Migrate from monolithic to modular architecture

### **Service-Implementation**
- [ ] **Tag 1**: Members-Service REST-API implementieren
- [ ] **Tag 2**: Database-Migrations und Repository-Layer
- [ ] **Tag 3**: Event-Publishing nach Kafka aktivieren
- [ ] **Tag 4**: Horses-Service analog implementieren
- [ ] **Tag 5**: Integration-Tests für beide Services
- [ ] **Tag 6-7**: Events-Service und Masterdata-Service
This commit is contained in:
stefan
2025-07-24 17:18:22 +02:00
parent dbbc303068
commit a4c7d53aa3
27 changed files with 2582 additions and 29 deletions
+2
View File
@@ -8,6 +8,8 @@ dependencies {
implementation(projects.members.membersDomain)
implementation(projects.members.membersApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation("org.springframework:spring-web")
implementation("org.springdoc:springdoc-openapi-starter-common")
@@ -0,0 +1,284 @@
package at.mocode.members.api.rest
import at.mocode.core.domain.model.ApiResponse
import at.mocode.members.application.usecase.CreateMemberUseCase
import at.mocode.members.application.usecase.DeleteMemberUseCase
import at.mocode.members.application.usecase.GetMemberUseCase
import at.mocode.members.application.usecase.UpdateMemberUseCase
import at.mocode.members.domain.repository.MemberRepository
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.LocalDate
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* REST API controller for member management operations.
*
* This controller provides HTTP endpoints for all member-related operations
* including CRUD operations and member search functionality.
*/
@RestController
@RequestMapping("/api/members")
class MemberController(
private val memberRepository: MemberRepository
) {
private val createMemberUseCase = CreateMemberUseCase(memberRepository)
private val getMemberUseCase = GetMemberUseCase(memberRepository)
private val updateMemberUseCase = UpdateMemberUseCase(memberRepository)
private val deleteMemberUseCase = DeleteMemberUseCase(memberRepository)
/**
* Get all members with optional filtering
*/
@GetMapping
fun getAllMembers(
@RequestParam(defaultValue = "true") activeOnly: Boolean,
@RequestParam(defaultValue = "100") limit: Int,
@RequestParam(defaultValue = "0") offset: Int,
@RequestParam(required = false) search: String?
): ResponseEntity<ApiResponse<List<*>>> {
return try {
val members = runBlocking {
when {
search != null -> memberRepository.findByName(search, limit)
activeOnly -> memberRepository.findAllActive(limit, offset)
else -> memberRepository.findAll(limit, offset)
}
}
ResponseEntity.ok(ApiResponse.success(members))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<List<*>>("Failed to retrieve members: ${e.message}"))
}
}
/**
* Get member by ID
*/
@GetMapping("/{id}")
fun getMemberById(@PathVariable id: String): ResponseEntity<ApiResponse<*>> {
return try {
val memberId = uuidFrom(id)
val request = GetMemberUseCase.GetMemberRequest(memberId)
val response = runBlocking { getMemberUseCase.execute(request) }
if (response.success && response.data != null) {
ResponseEntity.ok(ApiResponse.success((response.data as GetMemberUseCase.GetMemberResponse).member))
} else {
ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error<Any>("Member not found"))
}
} catch (_: IllegalArgumentException) {
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error<Any>("Invalid member ID format"))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<Any>("Failed to retrieve member: ${e.message}"))
}
}
/**
* Get member by membership number
*/
@GetMapping("/by-membership-number/{membershipNumber}")
fun getMemberByMembershipNumber(@PathVariable membershipNumber: String): ResponseEntity<ApiResponse<*>> {
return try {
val response = runBlocking { getMemberUseCase.getByMembershipNumber(membershipNumber) }
if (response.success && response.data != null) {
ResponseEntity.ok(ApiResponse.success((response.data as GetMemberUseCase.GetMemberResponse).member))
} else {
ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error<Any>("Member not found"))
}
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<Any>("Failed to retrieve member: ${e.message}"))
}
}
/**
* Get member by email
*/
@GetMapping("/by-email/{email}")
fun getMemberByEmail(@PathVariable email: String): ResponseEntity<ApiResponse<*>> {
return try {
val response = runBlocking { getMemberUseCase.getByEmail(email) }
if (response.success && response.data != null) {
ResponseEntity.ok(ApiResponse.success((response.data as GetMemberUseCase.GetMemberResponse).member))
} else {
ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error<Any>("Member not found"))
}
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<Any>("Failed to retrieve member: ${e.message}"))
}
}
/**
* Get member statistics
*/
@GetMapping("/stats")
fun getMemberStats(): ResponseEntity<ApiResponse<MemberStats>> {
return try {
val activeCount = runBlocking { memberRepository.countActive() }
val totalCount = runBlocking { memberRepository.countAll() }
val stats = MemberStats(
totalActive = activeCount,
totalMembers = totalCount
)
ResponseEntity.ok(ApiResponse.success(stats))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<MemberStats>("Failed to retrieve member statistics: ${e.message}"))
}
}
/**
* Create new member
*/
@PostMapping
fun createMember(@RequestBody createRequest: CreateMemberRequest): ResponseEntity<ApiResponse<*>> {
return try {
val useCaseRequest = CreateMemberUseCase.CreateMemberRequest(
firstName = createRequest.firstName,
lastName = createRequest.lastName,
email = createRequest.email,
phone = createRequest.phone,
dateOfBirth = createRequest.dateOfBirth,
membershipNumber = createRequest.membershipNumber,
membershipStartDate = createRequest.membershipStartDate,
membershipEndDate = createRequest.membershipEndDate,
isActive = createRequest.isActive,
address = createRequest.address,
emergencyContact = createRequest.emergencyContact
)
val response = runBlocking { createMemberUseCase.execute(useCaseRequest) }
if (response.success && response.data != null) {
ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success((response.data as CreateMemberUseCase.CreateMemberResponse).member))
} else {
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to create member"))
}
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<Any>("Failed to create member: ${e.message}"))
}
}
/**
* Update member
*/
@PutMapping("/{id}")
fun updateMember(@PathVariable id: String, @RequestBody updateRequest: UpdateMemberRequest): ResponseEntity<ApiResponse<*>> {
return try {
val memberId = uuidFrom(id)
val useCaseRequest = UpdateMemberUseCase.UpdateMemberRequest(
memberId = memberId,
firstName = updateRequest.firstName,
lastName = updateRequest.lastName,
email = updateRequest.email,
phone = updateRequest.phone,
dateOfBirth = updateRequest.dateOfBirth,
membershipNumber = updateRequest.membershipNumber,
membershipStartDate = updateRequest.membershipStartDate,
membershipEndDate = updateRequest.membershipEndDate,
isActive = updateRequest.isActive,
address = updateRequest.address,
emergencyContact = updateRequest.emergencyContact
)
val response = runBlocking { updateMemberUseCase.execute(useCaseRequest) }
if (response.success && response.data != null) {
ResponseEntity.ok(ApiResponse.success((response.data as UpdateMemberUseCase.UpdateMemberResponse).member))
} else {
val statusCode = when (response.error?.code) {
"MEMBER_NOT_FOUND" -> HttpStatus.NOT_FOUND
else -> HttpStatus.BAD_REQUEST
}
ResponseEntity.status(statusCode)
.body(ApiResponse.error<Any>(response.error?.message ?: "Failed to update member"))
}
} catch (_: IllegalArgumentException) {
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error<Any>("Invalid member ID format"))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<Any>("Failed to update member: ${e.message}"))
}
}
/**
* Delete member
*/
@DeleteMapping("/{id}")
fun deleteMember(@PathVariable id: String): ResponseEntity<ApiResponse<String>> {
return try {
val memberId = uuidFrom(id)
val request = DeleteMemberUseCase.DeleteMemberRequest(memberId)
val response = runBlocking { deleteMemberUseCase.execute(request) }
if (response.success) {
ResponseEntity.ok(ApiResponse.success("Member deleted successfully"))
} else {
val statusCode = when (response.error?.code) {
"MEMBER_NOT_FOUND" -> HttpStatus.NOT_FOUND
else -> HttpStatus.BAD_REQUEST
}
ResponseEntity.status(statusCode)
.body(ApiResponse.error<String>(response.error?.message ?: "Failed to delete member"))
}
} catch (_: IllegalArgumentException) {
ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error<String>("Invalid member ID format"))
} catch (e: Exception) {
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error<String>("Failed to delete member: ${e.message}"))
}
}
data class CreateMemberRequest(
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
data class UpdateMemberRequest(
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
data class MemberStats(
val totalActive: Long,
val totalMembers: Long
)
}
@@ -6,5 +6,6 @@ dependencies {
implementation(projects.members.membersDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.messaging.messagingClient)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,239 @@
package at.mocode.members.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import at.mocode.members.domain.events.MemberCreatedEvent
import at.mocode.infrastructure.messaging.client.EventPublisher
import at.mocode.core.utils.validation.ValidationResult
import at.mocode.core.utils.validation.ValidationError
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
/**
* Use case for creating new members.
*
* This use case handles the business logic for creating members,
* including validation and persistence.
*/
class CreateMemberUseCase(
private val memberRepository: MemberRepository,
private val eventPublisher: EventPublisher
) {
/**
* Request data for creating a new member.
*/
data class CreateMemberRequest(
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
/**
* Response data containing the created member.
*/
data class CreateMemberResponse(
val member: Member
)
/**
* Executes the create member use case.
*
* @param request The request containing member data
* @return ApiResponse with the created member or error information
*/
suspend fun execute(request: CreateMemberRequest): ApiResponse<CreateMemberResponse> {
return try {
// Validate the request
val validationResult = validateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Invalid input data",
details = errors.associate { it.field to it.message }
)
)
}
// Check for duplicate membership number
if (memberRepository.existsByMembershipNumber(request.membershipNumber)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_MEMBERSHIP_NUMBER",
message = "Membership number already exists"
)
)
}
// Check for duplicate email
if (memberRepository.existsByEmail(request.email)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_EMAIL",
message = "Email address already exists"
)
)
}
// Create the domain object
val member = Member(
firstName = request.firstName.trim(),
lastName = request.lastName.trim(),
email = request.email.trim().lowercase(),
phone = request.phone?.trim(),
dateOfBirth = request.dateOfBirth,
membershipNumber = request.membershipNumber.trim(),
membershipStartDate = request.membershipStartDate,
membershipEndDate = request.membershipEndDate,
isActive = request.isActive,
address = request.address?.trim(),
emergencyContact = request.emergencyContact?.trim(),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
// Validate the domain object
val domainValidationErrors = member.validate()
if (domainValidationErrors.isNotEmpty()) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DOMAIN_VALIDATION_ERROR",
message = "Domain validation failed",
details = domainValidationErrors.mapIndexed { index, error ->
"error_$index" to error
}.toMap()
)
)
}
// Save the member
val savedMember = memberRepository.save(member)
// Publish member created event
try {
val event = MemberCreatedEvent(
eventId = uuid4().toString(),
memberId = savedMember.memberId,
timestamp = Clock.System.now(),
firstName = savedMember.firstName,
lastName = savedMember.lastName,
email = savedMember.email,
membershipNumber = savedMember.membershipNumber,
membershipStartDate = savedMember.membershipStartDate,
isActive = savedMember.isActive
)
eventPublisher.publishEvent("member-events", savedMember.memberId.toString(), event)
} catch (e: Exception) {
// Log the error but don't fail the operation
// In a production system, you might want to use a dead letter queue or retry mechanism
println("Failed to publish member created event: ${e.message}")
}
ApiResponse(
success = true,
data = CreateMemberResponse(savedMember)
)
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to create member: ${e.message}"
)
)
}
}
/**
* Validates the create member request.
*/
private fun validateRequest(request: CreateMemberRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Validate first name
if (request.firstName.isBlank()) {
errors.add(ValidationError("firstName", "First name is required"))
} else if (request.firstName.length > 100) {
errors.add(ValidationError("firstName", "First name must not exceed 100 characters"))
}
// Validate last name
if (request.lastName.isBlank()) {
errors.add(ValidationError("lastName", "Last name is required"))
} else if (request.lastName.length > 100) {
errors.add(ValidationError("lastName", "Last name must not exceed 100 characters"))
}
// Validate email
if (request.email.isBlank()) {
errors.add(ValidationError("email", "Email is required"))
} else if (!isValidEmail(request.email)) {
errors.add(ValidationError("email", "Email format is invalid"))
} else if (request.email.length > 255) {
errors.add(ValidationError("email", "Email must not exceed 255 characters"))
}
// Validate membership number
if (request.membershipNumber.isBlank()) {
errors.add(ValidationError("membershipNumber", "Membership number is required"))
} else if (request.membershipNumber.length > 50) {
errors.add(ValidationError("membershipNumber", "Membership number must not exceed 50 characters"))
}
// Validate membership dates
request.membershipEndDate?.let { endDate ->
if (endDate < request.membershipStartDate) {
errors.add(ValidationError("membershipEndDate", "Membership end date cannot be before start date"))
}
}
// Validate phone
request.phone?.let { phone ->
if (phone.length > 50) {
errors.add(ValidationError("phone", "Phone number must not exceed 50 characters"))
}
}
// Validate address
request.address?.let { address ->
if (address.length > 500) {
errors.add(ValidationError("address", "Address must not exceed 500 characters"))
}
}
// Validate emergency contact
request.emergencyContact?.let { contact ->
if (contact.length > 255) {
errors.add(ValidationError("emergencyContact", "Emergency contact must not exceed 255 characters"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".") && email.indexOf("@") < email.lastIndexOf(".")
}
}
@@ -0,0 +1,84 @@
package at.mocode.members.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.members.domain.repository.MemberRepository
import com.benasher44.uuid.Uuid
/**
* Use case for deleting members.
*
* This use case handles the business logic for deleting members
* from the system.
*/
class DeleteMemberUseCase(
private val memberRepository: MemberRepository
) {
/**
* Request data for deleting a member.
*/
data class DeleteMemberRequest(
val memberId: Uuid
)
/**
* Response data for delete operation.
*/
data class DeleteMemberResponse(
val success: Boolean,
val message: String
)
/**
* Executes the delete member use case.
*
* @param request The request containing member ID to delete
* @return ApiResponse with the result or error information
*/
suspend fun execute(request: DeleteMemberRequest): ApiResponse<DeleteMemberResponse> {
return try {
// Check if member exists
val existingMember = memberRepository.findById(request.memberId)
if (existingMember == null) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
// Delete the member
val deleted = memberRepository.delete(request.memberId)
if (deleted) {
ApiResponse(
success = true,
data = DeleteMemberResponse(
success = true,
message = "Member deleted successfully"
)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "DELETE_FAILED",
message = "Failed to delete member"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to delete member: ${e.message}"
)
)
}
}
}
@@ -0,0 +1,131 @@
package at.mocode.members.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import com.benasher44.uuid.Uuid
/**
* Use case for retrieving members.
*
* This use case handles the business logic for retrieving members
* by various criteria.
*/
class GetMemberUseCase(
private val memberRepository: MemberRepository
) {
/**
* Request data for getting a member by ID.
*/
data class GetMemberRequest(
val memberId: Uuid
)
/**
* Response data containing the retrieved member.
*/
data class GetMemberResponse(
val member: Member
)
/**
* Executes the get member use case.
*
* @param request The request containing member ID
* @return ApiResponse with the member or error information
*/
suspend fun execute(request: GetMemberRequest): ApiResponse<GetMemberResponse> {
return try {
val member = memberRepository.findById(request.memberId)
if (member != null) {
ApiResponse(
success = true,
data = GetMemberResponse(member)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to retrieve member: ${e.message}"
)
)
}
}
/**
* Gets a member by membership number.
*/
suspend fun getByMembershipNumber(membershipNumber: String): ApiResponse<GetMemberResponse> {
return try {
val member = memberRepository.findByMembershipNumber(membershipNumber)
if (member != null) {
ApiResponse(
success = true,
data = GetMemberResponse(member)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to retrieve member: ${e.message}"
)
)
}
}
/**
* Gets a member by email address.
*/
suspend fun getByEmail(email: String): ApiResponse<GetMemberResponse> {
return try {
val member = memberRepository.findByEmail(email)
if (member != null) {
ApiResponse(
success = true,
data = GetMemberResponse(member)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to retrieve member: ${e.message}"
)
)
}
}
}
@@ -0,0 +1,226 @@
package at.mocode.members.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import at.mocode.core.utils.validation.ValidationResult
import at.mocode.core.utils.validation.ValidationError
import com.benasher44.uuid.Uuid
import kotlinx.datetime.LocalDate
/**
* Use case for updating existing members.
*
* This use case handles the business logic for updating members,
* including validation and persistence.
*/
class UpdateMemberUseCase(
private val memberRepository: MemberRepository
) {
/**
* Request data for updating a member.
*/
data class UpdateMemberRequest(
val memberId: Uuid,
val firstName: String,
val lastName: String,
val email: String,
val phone: String? = null,
val dateOfBirth: LocalDate? = null,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate? = null,
val isActive: Boolean = true,
val address: String? = null,
val emergencyContact: String? = null
)
/**
* Response data containing the updated member.
*/
data class UpdateMemberResponse(
val member: Member
)
/**
* Executes the update member use case.
*
* @param request The request containing updated member data
* @return ApiResponse with the updated member or error information
*/
suspend fun execute(request: UpdateMemberRequest): ApiResponse<UpdateMemberResponse> {
return try {
// Check if member exists
val existingMember = memberRepository.findById(request.memberId)
if (existingMember == null) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "MEMBER_NOT_FOUND",
message = "Member not found"
)
)
}
// Validate the request
val validationResult = validateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Invalid input data",
details = errors.associate { it.field to it.message }
)
)
}
// Check for duplicate membership number (excluding current member)
if (memberRepository.existsByMembershipNumber(request.membershipNumber, request.memberId)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_MEMBERSHIP_NUMBER",
message = "Membership number already exists"
)
)
}
// Check for duplicate email (excluding current member)
if (memberRepository.existsByEmail(request.email, request.memberId)) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DUPLICATE_EMAIL",
message = "Email address already exists"
)
)
}
// Update the member
val updatedMember = existingMember.copy(
firstName = request.firstName.trim(),
lastName = request.lastName.trim(),
email = request.email.trim().lowercase(),
phone = request.phone?.trim(),
dateOfBirth = request.dateOfBirth,
membershipNumber = request.membershipNumber.trim(),
membershipStartDate = request.membershipStartDate,
membershipEndDate = request.membershipEndDate,
isActive = request.isActive,
address = request.address?.trim(),
emergencyContact = request.emergencyContact?.trim()
).withUpdatedTimestamp()
// Validate the domain object
val domainValidationErrors = updatedMember.validate()
if (domainValidationErrors.isNotEmpty()) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DOMAIN_VALIDATION_ERROR",
message = "Domain validation failed",
details = domainValidationErrors.mapIndexed { index, error ->
"error_$index" to error
}.toMap()
)
)
}
// Save the updated member
val savedMember = memberRepository.save(updatedMember)
ApiResponse(
success = true,
data = UpdateMemberResponse(savedMember)
)
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to update member: ${e.message}"
)
)
}
}
/**
* Validates the update member request.
*/
private fun validateRequest(request: UpdateMemberRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Validate first name
if (request.firstName.isBlank()) {
errors.add(ValidationError("firstName", "First name is required"))
} else if (request.firstName.length > 100) {
errors.add(ValidationError("firstName", "First name must not exceed 100 characters"))
}
// Validate last name
if (request.lastName.isBlank()) {
errors.add(ValidationError("lastName", "Last name is required"))
} else if (request.lastName.length > 100) {
errors.add(ValidationError("lastName", "Last name must not exceed 100 characters"))
}
// Validate email
if (request.email.isBlank()) {
errors.add(ValidationError("email", "Email is required"))
} else if (!isValidEmail(request.email)) {
errors.add(ValidationError("email", "Email format is invalid"))
} else if (request.email.length > 255) {
errors.add(ValidationError("email", "Email must not exceed 255 characters"))
}
// Validate membership number
if (request.membershipNumber.isBlank()) {
errors.add(ValidationError("membershipNumber", "Membership number is required"))
} else if (request.membershipNumber.length > 50) {
errors.add(ValidationError("membershipNumber", "Membership number must not exceed 50 characters"))
}
// Validate membership dates
request.membershipEndDate?.let { endDate ->
if (endDate < request.membershipStartDate) {
errors.add(ValidationError("membershipEndDate", "Membership end date cannot be before start date"))
}
}
// Validate phone
request.phone?.let { phone ->
if (phone.length > 50) {
errors.add(ValidationError("phone", "Phone number must not exceed 50 characters"))
}
}
// Validate address
request.address?.let { address ->
if (address.length > 500) {
errors.add(ValidationError("address", "Address must not exceed 500 characters"))
}
}
// Validate emergency contact
request.emergencyContact?.let { contact ->
if (contact.length > 255) {
errors.add(ValidationError("emergencyContact", "Emergency contact must not exceed 255 characters"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".") && email.indexOf("@") < email.lastIndexOf(".")
}
}
@@ -0,0 +1,82 @@
package at.mocode.members.domain.events
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
/**
* Base interface for all member domain events.
*/
sealed interface MemberEvent {
val eventId: String
val memberId: Uuid
val timestamp: Instant
val eventType: String
}
/**
* Event published when a new member is created.
*/
data class MemberCreatedEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val firstName: String,
val lastName: String,
val email: String,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val isActive: Boolean
) : MemberEvent {
override val eventType: String = "MemberCreated"
}
/**
* Event published when a member is updated.
*/
data class MemberUpdatedEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val firstName: String,
val lastName: String,
val email: String,
val membershipNumber: String,
val membershipStartDate: LocalDate,
val membershipEndDate: LocalDate?,
val isActive: Boolean,
val changes: Map<String, Any?>
) : MemberEvent {
override val eventType: String = "MemberUpdated"
}
/**
* Event published when a member is deleted.
*/
data class MemberDeletedEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val membershipNumber: String,
val firstName: String,
val lastName: String
) : MemberEvent {
override val eventType: String = "MemberDeleted"
}
/**
* Event published when a member's membership is about to expire.
*/
data class MembershipExpiringEvent(
override val eventId: String,
override val memberId: Uuid,
override val timestamp: Instant,
val membershipNumber: String,
val firstName: String,
val lastName: String,
val email: String,
val membershipEndDate: LocalDate,
val daysUntilExpiry: Int
) : MemberEvent {
override val eventType: String = "MembershipExpiring"
}
@@ -0,0 +1,127 @@
package at.mocode.members.domain.model
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinLocalDateSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* Domain model representing a member in the member management system.
*
* This entity represents a member of the organization with their personal
* information and membership details.
*
* @property memberId Unique internal identifier for this member (UUID).
* @property firstName First name of the member.
* @property lastName Last name of the member.
* @property email Email address of the member.
* @property phone Phone number of the member (optional).
* @property dateOfBirth Date of birth of the member (optional).
* @property membershipNumber Unique membership number.
* @property membershipStartDate Date when membership started.
* @property membershipEndDate Date when membership ends (optional).
* @property isActive Whether the membership is currently active.
* @property address Address of the member (optional).
* @property emergencyContact Emergency contact information (optional).
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class Member(
@Serializable(with = UuidSerializer::class)
val memberId: Uuid = uuid4(),
// Personal Information
var firstName: String,
var lastName: String,
var email: String,
var phone: String? = null,
@Serializable(with = KotlinLocalDateSerializer::class)
var dateOfBirth: LocalDate? = null,
// Membership Information
var membershipNumber: String,
@Serializable(with = KotlinLocalDateSerializer::class)
var membershipStartDate: LocalDate,
@Serializable(with = KotlinLocalDateSerializer::class)
var membershipEndDate: LocalDate? = null,
var isActive: Boolean = true,
// Additional Information
var address: String? = null,
var emergencyContact: String? = null,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Returns the full name of the member.
*/
fun getFullName(): String {
return "$firstName $lastName"
}
/**
* Checks if the membership is currently valid.
*/
fun isMembershipValid(): Boolean {
// Simplified implementation - can be enhanced with proper date comparison
return isActive && membershipEndDate != null
}
/**
* Validates that the member data is consistent.
*/
fun validate(): List<String> {
val errors = mutableListOf<String>()
if (firstName.isBlank()) {
errors.add("First name is required")
}
if (lastName.isBlank()) {
errors.add("Last name is required")
}
if (email.isBlank()) {
errors.add("Email is required")
} else if (!isValidEmail(email)) {
errors.add("Email format is invalid")
}
if (membershipNumber.isBlank()) {
errors.add("Membership number is required")
}
membershipEndDate?.let { endDate ->
if (endDate < membershipStartDate) {
errors.add("Membership end date cannot be before start date")
}
}
return errors
}
/**
* Creates a copy of this member with updated timestamp.
*/
fun withUpdatedTimestamp(): Member {
return this.copy(updatedAt = Clock.System.now())
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".")
}
}
@@ -0,0 +1,139 @@
package at.mocode.members.domain.repository
import at.mocode.members.domain.model.Member
import com.benasher44.uuid.Uuid
import kotlinx.datetime.LocalDate
/**
* Repository interface for Member entities.
*
* This interface defines the contract for data access operations
* related to members in the member management bounded context.
*/
interface MemberRepository {
/**
* Finds a member by their unique identifier.
*
* @param id The unique identifier of the member
* @return The member if found, null otherwise
*/
suspend fun findById(id: Uuid): Member?
/**
* Finds a member by their membership number.
*
* @param membershipNumber The membership number to search for
* @return The member if found, null otherwise
*/
suspend fun findByMembershipNumber(membershipNumber: String): Member?
/**
* Finds a member by their email address.
*
* @param email The email address to search for
* @return The member if found, null otherwise
*/
suspend fun findByEmail(email: String): Member?
/**
* Finds members by name (partial match on first or last name).
*
* @param searchTerm The search term to match against member names
* @param limit Maximum number of results to return
* @return List of matching members
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Member>
/**
* Finds all active members.
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of active members
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Member>
/**
* Finds all members (active and inactive).
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of all members
*/
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<Member>
/**
* Finds members whose membership started within a date range.
*
* @param startDate The earliest membership start date to include
* @param endDate The latest membership start date to include
* @return List of members within the specified date range
*/
suspend fun findByMembershipStartDateRange(startDate: LocalDate, endDate: LocalDate): List<Member>
/**
* Finds members whose membership expires within a date range.
*
* @param startDate The earliest membership end date to include
* @param endDate The latest membership end date to include
* @return List of members whose membership expires within the specified date range
*/
suspend fun findByMembershipEndDateRange(startDate: LocalDate, endDate: LocalDate): List<Member>
/**
* Finds members with expiring memberships (within the next specified days).
*
* @param daysAhead Number of days to look ahead for expiring memberships
* @return List of members with expiring memberships
*/
suspend fun findMembersWithExpiringMembership(daysAhead: Int = 30): List<Member>
/**
* Saves a member (insert or update).
*
* @param member The member to save
* @return The saved member
*/
suspend fun save(member: Member): Member
/**
* Deletes a member by their ID.
*
* @param id The unique identifier of the member to delete
* @return True if the member was deleted, false if not found
*/
suspend fun delete(id: Uuid): Boolean
/**
* Counts the number of active members.
*
* @return The number of active members
*/
suspend fun countActive(): Long
/**
* Counts the total number of members.
*
* @return The total number of members
*/
suspend fun countAll(): Long
/**
* Checks if a membership number already exists.
*
* @param membershipNumber The membership number to check
* @param excludeMemberId Optional member ID to exclude from the check (for updates)
* @return True if the membership number exists, false otherwise
*/
suspend fun existsByMembershipNumber(membershipNumber: String, excludeMemberId: Uuid? = null): Boolean
/**
* Checks if an email address already exists.
*
* @param email The email address to check
* @param excludeMemberId Optional member ID to exclude from the check (for updates)
* @return True if the email exists, false otherwise
*/
suspend fun existsByEmail(email: String, excludeMemberId: Uuid? = null): Boolean
}
@@ -9,6 +9,8 @@ dependencies {
implementation(projects.members.membersDomain)
implementation(projects.members.membersApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.cache.cacheApi)
implementation(projects.infrastructure.eventStore.eventStoreApi)
implementation(projects.infrastructure.messaging.messagingClient)
@@ -0,0 +1,180 @@
package at.mocode.members.infrastructure.persistence
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import at.mocode.core.utils.database.DatabaseFactory
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.springframework.stereotype.Repository
/**
* Database implementation of MemberRepository using Exposed ORM.
*/
@Repository
class MemberRepositoryImpl : MemberRepository {
override suspend fun findById(id: Uuid): Member? = DatabaseFactory.dbQuery {
MemberTable.select { MemberTable.id eq id }
.map { rowToMember(it) }
.singleOrNull()
}
override suspend fun findByMembershipNumber(membershipNumber: String): Member? = DatabaseFactory.dbQuery {
MemberTable.select { MemberTable.membershipNumber eq membershipNumber }
.map { rowToMember(it) }
.singleOrNull()
}
override suspend fun findByEmail(email: String): Member? = DatabaseFactory.dbQuery {
MemberTable.select { MemberTable.email.lowerCase() eq email.lowercase() }
.map { rowToMember(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Member> = DatabaseFactory.dbQuery {
MemberTable.select {
(MemberTable.firstName.lowerCase() like "%${searchTerm.lowercase()}%") or
(MemberTable.lastName.lowerCase() like "%${searchTerm.lowercase()}%")
}
.limit(limit)
.map { rowToMember(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Member> = DatabaseFactory.dbQuery {
MemberTable.select { MemberTable.isActive eq true }
.limit(limit, offset.toLong())
.map { rowToMember(it) }
}
override suspend fun findAll(limit: Int, offset: Int): List<Member> = DatabaseFactory.dbQuery {
MemberTable.selectAll()
.limit(limit, offset.toLong())
.map { rowToMember(it) }
}
override suspend fun findByMembershipStartDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> = DatabaseFactory.dbQuery {
MemberTable.select {
(MemberTable.membershipStartDate greaterEq startDate) and
(MemberTable.membershipStartDate lessEq endDate)
}
.map { rowToMember(it) }
}
override suspend fun findByMembershipEndDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> = DatabaseFactory.dbQuery {
MemberTable.select {
(MemberTable.membershipEndDate.isNotNull()) and
(MemberTable.membershipEndDate greaterEq startDate) and
(MemberTable.membershipEndDate lessEq endDate)
}
.map { rowToMember(it) }
}
override suspend fun findMembersWithExpiringMembership(daysAhead: Int): List<Member> = DatabaseFactory.dbQuery {
val currentDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val futureDate = LocalDate(currentDate.year, currentDate.month, currentDate.dayOfMonth + daysAhead)
MemberTable.select {
(MemberTable.membershipEndDate.isNotNull()) and
(MemberTable.membershipEndDate lessEq futureDate) and
(MemberTable.isActive eq true)
}
.map { rowToMember(it) }
}
override suspend fun save(member: Member): Member = DatabaseFactory.dbQuery {
val existingMember = MemberTable.select { MemberTable.id eq member.memberId }.singleOrNull()
if (existingMember != null) {
// Update existing member
MemberTable.update({ MemberTable.id eq member.memberId }) {
it[firstName] = member.firstName
it[lastName] = member.lastName
it[email] = member.email
it[phone] = member.phone
it[dateOfBirth] = member.dateOfBirth
it[membershipNumber] = member.membershipNumber
it[membershipStartDate] = member.membershipStartDate
it[membershipEndDate] = member.membershipEndDate
it[isActive] = member.isActive
it[address] = member.address
it[emergencyContact] = member.emergencyContact
it[updatedAt] = Clock.System.now()
}
} else {
// Insert new member
MemberTable.insert {
it[id] = member.memberId
it[firstName] = member.firstName
it[lastName] = member.lastName
it[email] = member.email
it[phone] = member.phone
it[dateOfBirth] = member.dateOfBirth
it[membershipNumber] = member.membershipNumber
it[membershipStartDate] = member.membershipStartDate
it[membershipEndDate] = member.membershipEndDate
it[isActive] = member.isActive
it[address] = member.address
it[emergencyContact] = member.emergencyContact
}
}
member
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
MemberTable.deleteWhere { MemberTable.id eq id } > 0
}
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
MemberTable.select { MemberTable.isActive eq true }.count()
}
override suspend fun countAll(): Long = DatabaseFactory.dbQuery {
MemberTable.selectAll().count()
}
override suspend fun existsByMembershipNumber(membershipNumber: String, excludeMemberId: Uuid?): Boolean = DatabaseFactory.dbQuery {
val query = if (excludeMemberId != null) {
MemberTable.select {
(MemberTable.membershipNumber eq membershipNumber) and
(MemberTable.id neq excludeMemberId)
}
} else {
MemberTable.select { MemberTable.membershipNumber eq membershipNumber }
}
query.count() > 0
}
override suspend fun existsByEmail(email: String, excludeMemberId: Uuid?): Boolean = DatabaseFactory.dbQuery {
val query = if (excludeMemberId != null) {
MemberTable.select {
(MemberTable.email.lowerCase() eq email.lowercase()) and
(MemberTable.id neq excludeMemberId)
}
} else {
MemberTable.select { MemberTable.email.lowerCase() eq email.lowercase() }
}
query.count() > 0
}
private fun rowToMember(row: ResultRow): Member {
return Member(
memberId = row[MemberTable.id],
firstName = row[MemberTable.firstName],
lastName = row[MemberTable.lastName],
email = row[MemberTable.email],
phone = row[MemberTable.phone],
dateOfBirth = row[MemberTable.dateOfBirth],
membershipNumber = row[MemberTable.membershipNumber],
membershipStartDate = row[MemberTable.membershipStartDate],
membershipEndDate = row[MemberTable.membershipEndDate],
isActive = row[MemberTable.isActive],
address = row[MemberTable.address],
emergencyContact = row[MemberTable.emergencyContact]
)
}
}
@@ -0,0 +1,31 @@
package at.mocode.members.infrastructure.persistence
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.date
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp
/**
* Database table definition for members in the member management context.
*
* This table stores member information including personal details,
* membership information, and contact details.
*/
object MemberTable : Table("members") {
val id = uuid("id").autoGenerate()
val firstName = varchar("first_name", 100)
val lastName = varchar("last_name", 100)
val email = varchar("email", 255).uniqueIndex()
val phone = varchar("phone", 50).nullable()
val dateOfBirth = date("date_of_birth").nullable()
val membershipNumber = varchar("membership_number", 50).uniqueIndex()
val membershipStartDate = date("membership_start_date")
val membershipEndDate = date("membership_end_date").nullable()
val isActive = bool("is_active").default(true)
val address = varchar("address", 500).nullable()
val emergencyContact = varchar("emergency_contact", 255).nullable()
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,103 @@
package at.mocode.members.infrastructure.repository
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import com.benasher44.uuid.Uuid
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.springframework.stereotype.Repository
import java.util.concurrent.ConcurrentHashMap
/**
* In-memory implementation of MemberRepository for development and testing purposes.
*/
@Repository
class InMemoryMemberRepository : MemberRepository {
private val members = ConcurrentHashMap<Uuid, Member>()
override suspend fun findById(id: Uuid): Member? {
return members[id]
}
override suspend fun findByMembershipNumber(membershipNumber: String): Member? {
return members.values.find { it.membershipNumber == membershipNumber }
}
override suspend fun findByEmail(email: String): Member? {
return members.values.find { it.email.equals(email, ignoreCase = true) }
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Member> {
return members.values
.filter {
it.firstName.contains(searchTerm, ignoreCase = true) ||
it.lastName.contains(searchTerm, ignoreCase = true)
}
.take(limit)
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Member> {
return members.values
.filter { it.isActive }
.drop(offset)
.take(limit)
}
override suspend fun findAll(limit: Int, offset: Int): List<Member> {
return members.values
.drop(offset)
.take(limit)
}
override suspend fun findByMembershipStartDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> {
return members.values
.filter { it.membershipStartDate >= startDate && it.membershipStartDate <= endDate }
}
override suspend fun findByMembershipEndDateRange(startDate: LocalDate, endDate: LocalDate): List<Member> {
return members.values
.filter { member ->
member.membershipEndDate?.let { memberEndDate ->
memberEndDate >= startDate && memberEndDate <= endDate
} ?: false
}
}
override suspend fun findMembersWithExpiringMembership(daysAhead: Int): List<Member> {
// Simplified implementation - returns members with end dates set
return members.values
.filter { it.membershipEndDate != null }
}
override suspend fun save(member: Member): Member {
members[member.memberId] = member
return member
}
override suspend fun delete(id: Uuid): Boolean {
return members.remove(id) != null
}
override suspend fun countActive(): Long {
return members.values.count { it.isActive }.toLong()
}
override suspend fun countAll(): Long {
return members.size.toLong()
}
override suspend fun existsByMembershipNumber(membershipNumber: String, excludeMemberId: Uuid?): Boolean {
return members.values.any {
it.membershipNumber == membershipNumber && it.memberId != excludeMemberId
}
}
override suspend fun existsByEmail(email: String, excludeMemberId: Uuid?): Boolean {
return members.values.any {
it.email.equals(email, ignoreCase = true) && it.memberId != excludeMemberId
}
}
}
@@ -2,6 +2,7 @@ package at.mocode.members.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
/**
* Main application class for the Members Service.
@@ -9,6 +10,7 @@ import org.springframework.boot.runApplication
* This service provides APIs for managing members and their data.
*/
@SpringBootApplication
@ComponentScan(basePackages = ["at.mocode.members"])
class MembersServiceApplication
/**
@@ -0,0 +1,239 @@
package at.mocode.members.service.integration
import at.mocode.members.api.rest.MemberController
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import at.mocode.members.infrastructure.persistence.MemberRepositoryImpl
import at.mocode.infrastructure.messaging.client.EventPublisher
import kotlinx.datetime.LocalDate
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestInstance
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestPropertySource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Integration tests for the Members Service.
*
* These tests verify the complete functionality including:
* - REST API endpoints
* - Database operations
* - Event publishing
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@TestPropertySource(properties = [
"spring.datasource.url=jdbc:h2:mem:testdb",
"spring.kafka.bootstrap-servers=localhost:9092"
])
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MemberServiceIntegrationTest {
@Autowired
@Qualifier("memberRepositoryImpl")
private lateinit var memberRepository: MemberRepository
@MockBean
private lateinit var eventPublisher: EventPublisher
@BeforeEach
fun setUp() = runBlocking {
// Clean up database before each test
// Note: In a real implementation, you might want to use @Transactional or @DirtiesContext
println("[DEBUG_LOG] Setting up test - cleaning database")
}
@Test
fun `should create member successfully`() = runBlocking {
println("[DEBUG_LOG] Testing member creation")
// Given
val createRequest = MemberController.CreateMemberRequest(
firstName = "John",
lastName = "Doe",
email = "john.doe@example.com",
phone = "+43123456789",
dateOfBirth = LocalDate(1990, 1, 15),
membershipNumber = "M001",
membershipStartDate = LocalDate(2024, 1, 1),
membershipEndDate = null,
isActive = true,
address = "123 Test Street, Vienna",
emergencyContact = "Jane Doe: +43987654321"
)
// When
val member = Member(
firstName = createRequest.firstName,
lastName = createRequest.lastName,
email = createRequest.email,
phone = createRequest.phone,
dateOfBirth = createRequest.dateOfBirth,
membershipNumber = createRequest.membershipNumber,
membershipStartDate = createRequest.membershipStartDate,
membershipEndDate = createRequest.membershipEndDate,
isActive = createRequest.isActive,
address = createRequest.address,
emergencyContact = createRequest.emergencyContact
)
val savedMember = memberRepository.save(member)
// Then
assertNotNull(savedMember)
assertEquals(createRequest.firstName, savedMember.firstName)
assertEquals(createRequest.lastName, savedMember.lastName)
assertEquals(createRequest.email, savedMember.email)
assertEquals(createRequest.membershipNumber, savedMember.membershipNumber)
assertTrue(savedMember.isActive)
println("[DEBUG_LOG] Member created successfully with ID: ${savedMember.memberId}")
}
@Test
fun `should find member by membership number`() = runBlocking {
println("[DEBUG_LOG] Testing find member by membership number")
// Given
val member = Member(
firstName = "Jane",
lastName = "Smith",
email = "jane.smith@example.com",
membershipNumber = "M002",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member)
// When
val foundMember = memberRepository.findByMembershipNumber("M002")
// Then
assertNotNull(foundMember)
assertEquals("Jane", foundMember.firstName)
assertEquals("Smith", foundMember.lastName)
assertEquals("M002", foundMember.membershipNumber)
println("[DEBUG_LOG] Member found by membership number: ${foundMember.memberId}")
}
@Test
fun `should find member by email`() = runBlocking {
println("[DEBUG_LOG] Testing find member by email")
// Given
val member = Member(
firstName = "Bob",
lastName = "Johnson",
email = "bob.johnson@example.com",
membershipNumber = "M003",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member)
// When
val foundMember = memberRepository.findByEmail("bob.johnson@example.com")
// Then
assertNotNull(foundMember)
assertEquals("Bob", foundMember.firstName)
assertEquals("Johnson", foundMember.lastName)
assertEquals("bob.johnson@example.com", foundMember.email)
println("[DEBUG_LOG] Member found by email: ${foundMember.memberId}")
}
@Test
fun `should count active members`() = runBlocking {
println("[DEBUG_LOG] Testing count active members")
// Given
val activeMember = Member(
firstName = "Active",
lastName = "Member",
email = "active@example.com",
membershipNumber = "M004",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
val inactiveMember = Member(
firstName = "Inactive",
lastName = "Member",
email = "inactive@example.com",
membershipNumber = "M005",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = false
)
memberRepository.save(activeMember)
memberRepository.save(inactiveMember)
// When
val activeCount = memberRepository.countActive()
val totalCount = memberRepository.countAll()
// Then
assertTrue(activeCount >= 1, "Should have at least 1 active member")
assertTrue(totalCount >= 2, "Should have at least 2 total members")
println("[DEBUG_LOG] Active members: $activeCount, Total members: $totalCount")
}
@Test
fun `should validate duplicate membership number`() = runBlocking {
println("[DEBUG_LOG] Testing duplicate membership number validation")
// Given
val member1 = Member(
firstName = "First",
lastName = "Member",
email = "first@example.com",
membershipNumber = "M006",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member1)
// When
val exists = memberRepository.existsByMembershipNumber("M006")
// Then
assertTrue(exists, "Should detect existing membership number")
println("[DEBUG_LOG] Duplicate membership number validation passed")
}
@Test
fun `should validate duplicate email`() = runBlocking {
println("[DEBUG_LOG] Testing duplicate email validation")
// Given
val member = Member(
firstName = "Email",
lastName = "Test",
email = "email.test@example.com",
membershipNumber = "M007",
membershipStartDate = LocalDate(2024, 1, 1),
isActive = true
)
memberRepository.save(member)
// When
val exists = memberRepository.existsByEmail("email.test@example.com")
// Then
assertTrue(exists, "Should detect existing email")
println("[DEBUG_LOG] Duplicate email validation passed")
}
}