diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index e6ce6809..1bac08bd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -109,7 +109,7 @@ class EmailService( val endsAt: Instant?, val location: String?, val description: String?, - val hostEmail: String, + val hostEmail: String?, ) data class DemotionEmailData( @@ -240,7 +240,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{hostEmail}", data.hostEmail) + .replace("{hostEmail}", data.hostEmail ?: "탈퇴유저") .replace("{description}", formatDescription(data.description)) sendHtmlEmail( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt index 778ea5ca..6a1a65bd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt @@ -13,7 +13,7 @@ data class EventDto( val waitlistEnabled: Boolean, val registrationStartsAt: Long?, val registrationEndsAt: Long, - val createdBy: Long, + val createdBy: Long?, val createdAt: Long?, val updatedAt: Long?, ) { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt index 685cfd04..a1ab0afd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt @@ -26,7 +26,7 @@ data class EventInfo( data class CreatorInfo( val name: String, - val email: String, + val email: String?, val profileImage: String?, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt index 3f635d9d..9779a740 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt @@ -28,7 +28,7 @@ class Event( @Column("registration_ends_at") var registrationEndsAt: Instant, @Column("created_by") - var createdBy: Long, + var createdBy: Long?, @CreatedDate @Column("created_at") var createdAt: Instant? = null, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt index 04e87e26..a99b0205 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt @@ -8,6 +8,8 @@ import java.time.Instant interface EventRepository : ListCrudRepository { fun findByPublicId(publicId: String): Event? + fun findByCreatedBy(createdBy: Long): List + fun findByCreatedByAndCreatedAtIsNotNullOrderByCreatedAtDesc( createdBy: Long, pageable: Pageable, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 7d41b3e9..7826de9b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -99,7 +99,7 @@ class EventService( val userIdsToFetch = listOfNotNull(event.createdBy, requesterId).distinct() val usersById = userRepository.findAllById(userIdsToFetch).associateBy { it.id!! } - val creatorUser = usersById[event.createdBy] ?: throw EventNotFoundException() + val creatorUser = event.createdBy?.let { usersById[it] } val myReg = if (requesterId == null) { @@ -230,9 +230,9 @@ class EventService( ), creator = CreatorInfo( - name = creatorUser.name, - email = creatorUser.email, - profileImage = creatorUser.profileImage?.let { imageService.presignedGetUrl(it) }, + name = creatorUser?.name ?: "탈퇴유저", + email = creatorUser?.email, + profileImage = creatorUser?.profileImage?.let { imageService.presignedGetUrl(it) }, ), viewer = viewer, capabilities = capabilities, @@ -391,7 +391,7 @@ class EventService( ) // 이메일 데이터 구성 (삭제 전에 user 정보 조회) - val hostUser = userRepository.findById(event.createdBy).orElse(null) + val hostUser = event.createdBy?.let { userRepository.findById(it).orElse(null) } val userIds = registrationsToNotify.mapNotNull { it.userId }.distinct() val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } @@ -408,7 +408,7 @@ class EventService( endsAt = event.endsAt, location = event.location, description = event.description, - hostEmail = hostUser?.email ?: "", + hostEmail = hostUser?.email, ) } @@ -423,6 +423,54 @@ class EventService( } } + /** + * 유저 탈퇴 시 호출. 시작 전(now < startsAt) 이벤트들을 일괄 삭제하고 + * 참여자/대기자에게 익명화된 주최자 정보로 취소 메일을 발송한다. + * 소유권 체크는 호출 측에서 보장. + */ + @Transactional + fun deleteEventsForWithdrawal(events: List) { + if (events.isEmpty()) return + val eventIds = events.map { it.id!! }.sorted() + + // 락 획득 (ID 오름차순으로 deadlock 방지) + eventIds.forEach { eventLockRepository.lockById(it) } + + val eventsById = events.associateBy { it.id!! } + val registrations = + registrationRepository.findByEventIdInAndStatusIn( + eventIds = eventIds, + statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + ) + val userIds = registrations.mapNotNull { it.userId }.distinct() + val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } + + val emailDataList = + registrations.mapNotNull { reg -> + val event = eventsById[reg.eventId] ?: return@mapNotNull null + val user = reg.userId?.let { usersById[it] } + val toEmail = user?.email ?: reg.guestEmail + if (toEmail.isNullOrBlank()) return@mapNotNull null + EmailService.EventCancellationEmailData( + toEmail = toEmail, + name = user?.name ?: reg.guestName ?: "참여자", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + description = event.description, + hostEmail = null, + ) + } + + registrationRepository.deleteByEventIdIn(eventIds) + eventRepository.deleteAllById(eventIds) + + afterCommit { + emailDataList.forEach { emailService.sendEventCancellationEmail(it) } + } + } + private fun getEventByPublicId(publicId: String): Event = eventRepository.findByPublicId(publicId) ?: throw EventNotFoundException() diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 1c91aa06..84e7cae0 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -14,6 +14,18 @@ interface RegistrationRepository : fun deleteByEventId(eventId: Long) + fun deleteByEventIdIn(eventIds: Collection) + + fun findByUserIdAndStatusIn( + userId: Long, + statuses: Collection, + ): List + + fun findByEventIdInAndStatusIn( + eventIds: Collection, + statuses: Collection, + ): List + fun findByRegistrationPublicId(registrationPublicId: String): Registration? @Query( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 46ea8c70..12e7309b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -281,6 +281,35 @@ RegistrationService( } } + /** + * 유저 탈퇴 시 호출. 신청기간/소유권 체크 없이 다수 등록을 일괄 삭제하고 + * 확정 등록이 있던 이벤트에 대해서만 대기자 승격을 트리거한다. + * 탈퇴자에게 취소 메일은 발송하지 않는다. 대기자 승격 메일은 reconcileWaitlist 내부에서 처리됨. + */ + @Transactional + fun cancelRegistrationsForWithdrawal(registrations: List) { + if (registrations.isEmpty()) return + + val byEventId = registrations.groupBy { it.eventId } + val eventIds = byEventId.keys.sorted() + + // 1. 이벤트 락 (ID 오름차순으로 deadlock 방지) + eventIds.forEach { eventLockRepository.lockById(it) } + + // 2. CONFIRMED 등록이 포함된 이벤트만 대기자 승격 필요 + val eventsToReconcile = + byEventId + .filterValues { regs -> regs.any { it.status == RegistrationStatus.CONFIRMED } } + .keys + .sorted() + + // 3. 배치 삭제 + registrationRepository.deleteAllById(registrations.map { it.id!! }) + + // 4. 필요한 이벤트만 승격 + eventsToReconcile.forEach { reconcileWaitlist(it) } + } + private fun toResponseStatus(status: RegistrationStatus): RegistrationStatusResponse = when (status) { RegistrationStatus.HOST -> RegistrationStatusResponse.CONFIRMED diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 8f214cf1..0eb1572c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -8,6 +8,7 @@ import com.wafflestudio.spring2025.domain.user.dto.PatchMeResponse import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.service.UserService import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.RequestBody @@ -41,4 +42,12 @@ class UserController( ) return ResponseEntity.ok(userService.me(user)) } + + @DeleteMapping("/me") + fun deleteMe( + @LoggedInUser user: User, + ): ResponseEntity { + userService.deleteMe(user = user) + return ResponseEntity.noContent().build() + } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt index f0c68bfe..d89f5bff 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt @@ -18,4 +18,9 @@ enum class UserErrorCode( title = "이메일은 변경할 수 없습니다.", message = "이메일 주소는 변경이 불가능합니다.", ), + NO_SUCH_USER( + httpStatusCode = HttpStatus.NOT_FOUND, + title = "존재하지 않는 회원입니다.", + message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다.", + ), } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt index 5145a2c5..ccb9d6ed 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt @@ -12,4 +12,6 @@ interface UserIdentityRepository : ListCrudRepository { fun findByUserId(userId: Long): List fun findByProviderUserId(providerId: String): List + + fun deleteByUserId(userId: Long) } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt index 321f1e6e..0433c204 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt @@ -5,17 +5,26 @@ import com.wafflestudio.spring2025.config.AwsS3Properties import com.wafflestudio.spring2025.domain.auth.exception.AuthErrorCode import com.wafflestudio.spring2025.domain.auth.exception.AuthValidationException import com.wafflestudio.spring2025.domain.auth.exception.AuthenticationRequiredException +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.event.service.EventService +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.dto.core.UserDto import com.wafflestudio.spring2025.domain.user.exception.EmailChangeForbiddenException import com.wafflestudio.spring2025.domain.user.exception.UserErrorCode +import com.wafflestudio.spring2025.domain.user.exception.UserException import com.wafflestudio.spring2025.domain.user.exception.UserValidationException +import com.wafflestudio.spring2025.domain.user.identity.repository.UserIdentityRepository import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.mindrot.jbcrypt.BCrypt import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.HeadObjectRequest import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import java.time.Instant @Service class UserService( @@ -23,6 +32,11 @@ class UserService( private val s3Client: S3Client, private val s3Props: AwsS3Properties, private val imageService: ImageService, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, + private val userIdentityRepository: UserIdentityRepository, + private val eventService: EventService, + private val registrationService: RegistrationService, ) { fun me(user: User?): UserDto { if (user == null) throw AuthenticationRequiredException() @@ -58,6 +72,86 @@ class UserService( userRepository.save(user) } + @Transactional + fun deleteMe(user: User) { + val now = Instant.now() + val userId = user.id ?: throw UserException(UserErrorCode.NO_SUCH_USER) + + // 1. 주최 이벤트 처리 + val hostedEvents = eventRepository.findByCreatedBy(userId) + val (startedEvents, notStartedEvents) = + hostedEvents.partition { it.startsAt != null && !now.isBefore(it.startsAt) } + + // 시작된 이벤트: Event + HOST 등록 익명화 + if (startedEvents.isNotEmpty()) { + val startedIds = startedEvents.map { it.id!! } + startedEvents.forEach { it.createdBy = null } + eventRepository.saveAll(startedEvents) + + val hostRegs = + registrationRepository.findByEventIdInAndStatusIn( + eventIds = startedIds, + statuses = listOf(RegistrationStatus.HOST), + ) + hostRegs.forEach { + it.userId = null + it.guestName = "탈퇴유저" + it.guestEmail = null + } + registrationRepository.saveAll(hostRegs) + } + + // 시작 전 이벤트: 일괄 삭제 + 취소 메일 (락은 EventService 내부에서 획득) + eventService.deleteEventsForWithdrawal(notStartedEvents) + + // 2. 참여 등록 처리 (CONFIRMED + WAITLISTED) + val regs = + registrationRepository.findByUserIdAndStatusIn( + userId = userId, + statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + ) + if (regs.isNotEmpty()) { + val eventsById = + eventRepository + .findAllById(regs.map { it.eventId }.distinct()) + .associateBy { it.id!! } + // 고아 등록(이벤트 없음)은 skip + val valid = regs.filter { eventsById.containsKey(it.eventId) } + val (toAnonymize, toCancel) = + valid.partition { reg -> !now.isBefore(eventsById[reg.eventId]!!.registrationEndsAt) } + + // 신청기간 마감: 익명화 + toAnonymize.forEach { + it.userId = null + it.guestName = "탈퇴유저" + it.guestEmail = null + } + registrationRepository.saveAll(toAnonymize) + + // 신청기간 내: 일괄 취소 + 대기자 승격 + registrationService.cancelRegistrationsForWithdrawal(toCancel) + } + + // 2b. BANNED 등록 익명화 (FK 정리) + val bannedRegs = + registrationRepository.findByUserIdAndStatusIn( + userId = userId, + statuses = listOf(RegistrationStatus.BANNED), + ) + bannedRegs.forEach { + it.userId = null + it.guestName = "탈퇴유저" + it.guestEmail = null + } + registrationRepository.saveAll(bannedRegs) + + // 3. UserIdentity 삭제 + userIdentityRepository.deleteByUserId(userId) + + // 4. User 삭제 + userRepository.delete(user) + } + private fun validateName(name: String) { if (name.isBlank()) { throw AuthValidationException(AuthErrorCode.BAD_NAME) @@ -89,72 +183,4 @@ class UserService( throw UserValidationException(UserErrorCode.PROFILE_IMAGE_NOT_FOUND) } } - -// -// fun updateProfileImage( -// user: User?, -// image: MultipartFile, -// ) { -// if (user == null) throw AuthenticationRequiredException() -// validateProfileImage(image) -// -// val ext = extractExtension(image.originalFilename) -// val key = "profile-images/${user.id}/${UUID.randomUUID()}$ext" -// -// val putReq = -// PutObjectRequest -// .builder() -// .bucket(s3Props.bucket) -// .key(key) -// .contentType(image.contentType ?: "application/octet-stream") -// .build() -// -// image.inputStream.use { input -> -// s3Client.putObject( -// putReq, -// RequestBody.fromInputStream(input, image.size), -// ) -// } -// -// // DB에는 URL이 아니라 key 저장 -// user.profileImage = key -// userRepository.save(user) -// } -// -// fun deleteProfileImage(user: User?) { -// if (user == null) throw AuthenticationRequiredException() -// -// // (선택) S3에서도 삭제 -// user.profileImage?.let { key -> -// val delReq = -// DeleteObjectRequest -// .builder() -// .bucket(s3Props.bucket) -// .key(key) -// .build() -// runCatching { s3Client.deleteObject(delReq) } -// } -// -// user.profileImage = null -// userRepository.save(user) -// } -// - -// -// private fun validateProfileImage(image: MultipartFile) { -// if (image.isEmpty) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_EMPTY) -// val contentType = image.contentType ?: "" -// if (!contentType.startsWith("image/")) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_FORMAT_INVALID) -// -// val maxSizeBytes = 5L * 1024L * 1024L -// if (image.size > maxSizeBytes) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_TOO_LARGE) -// } -// -// private fun extractExtension(originalFilename: String?): String { -// if (originalFilename.isNullOrBlank()) return ".jpg" -// val idx = originalFilename.lastIndexOf('.') -// if (idx < 0) return ".jpg" -// val ext = originalFilename.substring(idx).lowercase() -// return if (ext in setOf(".jpg", ".jpeg", ".png", ".webp")) ext else ".jpg" -// } } diff --git a/src/main/resources/db/migration/V15__make_event_created_by_nullable.sql b/src/main/resources/db/migration/V15__make_event_created_by_nullable.sql new file mode 100644 index 00000000..2161f421 --- /dev/null +++ b/src/main/resources/db/migration/V15__make_event_created_by_nullable.sql @@ -0,0 +1 @@ +ALTER TABLE events MODIFY COLUMN created_by BIGINT NULL; \ No newline at end of file diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml index 9f62c069..6c70d35d 100644 --- a/src/main/resources/static/docs/openapi.yaml +++ b/src/main/resources/static/docs/openapi.yaml @@ -393,6 +393,30 @@ paths: $ref: "#/components/schemas/UserDto" "401": description: 인증 필요 + delete: + tags: + - User + summary: 회원 탈퇴 + description: | + 로그인한 사용자를 탈퇴 처리합니다. 도메인 처리는 다음과 같이 분기됩니다. + + - 주최한 모임 + - 이미 시작된 모임 (now >= startsAt): 모임은 유지되고 주최자 정보만 익명화됩니다 (createdBy=null, 응답상 이름은 "탈퇴유저", 이메일은 null). + - 시작 전 모임 (now < startsAt): 모임이 삭제되고 참여자/대기자에게 취소 메일이 발송됩니다. 메일 내 주최자 이메일도 익명화됩니다. + - 참여한 모임 (확정 + 대기) + - 신청기간이 마감된 모임 (now >= registrationEndsAt): 등록 기록은 유지되되 본인 정보가 익명화됩니다. + - 신청기간이 마감되지 않은 모임 (now < registrationEndsAt): 등록이 자동 취소되며, 필요 시 대기자 승격이 발생합니다. + security: + - bearerAuth: [] + responses: + "204": + description: 탈퇴 완료 + "401": + description: 인증 실패 + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" /api/registrations/{registrationId}: get: security: [] @@ -945,17 +969,17 @@ components: description: 가능한 동작 블록 CreatorInfo: required: - - email - name type: object properties: name: type: string - description: 이름 + description: 이름 (탈퇴한 주최자는 "탈퇴유저"로 표시됨) example: 홍길동 email: type: string - description: 이메일 + description: 이메일 (탈퇴한 주최자의 경우 null) + nullable: true example: user@example.com profileImage: type: string diff --git a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt index 5e6313aa..d6000945 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt @@ -1,16 +1,24 @@ package com.wafflestudio.spring2025 import com.wafflestudio.spring2025.domain.auth.JwtTokenProvider +import com.wafflestudio.spring2025.domain.event.model.Event +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.model.Registration +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.mindrot.jbcrypt.BCrypt import org.springframework.stereotype.Component +import java.time.Instant import java.util.UUID @Component class DataGenerator( private val userRepository: UserRepository, private val jwtTokenProvider: JwtTokenProvider, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, ) { fun generateUser(): Pair { val email = "user-${UUID.randomUUID()}@example.com" @@ -54,4 +62,45 @@ class DataGenerator( val token = jwtTokenProvider.createToken(user.id!!) return user to token } + + fun createEvent( + createdBy: Long, + title: String = "테스트 이벤트", + capacity: Int = 10, + waitlistEnabled: Boolean = true, + startsAt: Instant = Instant.now().plusSeconds(7200), + endsAt: Instant = Instant.now().plusSeconds(10800), + registrationStartsAt: Instant? = null, + registrationEndsAt: Instant = Instant.now().plusSeconds(5400), + ): Event = + eventRepository.save( + Event( + publicId = UUID.randomUUID().toString(), + title = title, + capacity = capacity, + waitlistEnabled = waitlistEnabled, + startsAt = startsAt, + endsAt = endsAt, + registrationStartsAt = registrationStartsAt, + registrationEndsAt = registrationEndsAt, + createdBy = createdBy, + ), + ) + + fun createRegistration( + eventId: Long, + userId: Long? = null, + guestName: String? = null, + guestEmail: String? = null, + status: RegistrationStatus = RegistrationStatus.CONFIRMED, + ): Registration = + registrationRepository.save( + Registration( + userId = userId, + eventId = eventId, + guestName = guestName, + guestEmail = guestEmail, + status = status, + ), + ) } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt index dbe38e39..e7d2aa93 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt @@ -1,12 +1,21 @@ package com.wafflestudio.spring2025 import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.auth.dto.LoginRequest +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import com.wafflestudio.spring2025.domain.user.dto.PatchMeRequest +import com.wafflestudio.spring2025.domain.user.repository.UserRepository +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.BDDMockito.given +import org.mockito.kotlin.argThat +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -15,6 +24,7 @@ import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @@ -24,6 +34,7 @@ import org.testcontainers.junit.jupiter.Testcontainers import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.HeadObjectRequest import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import java.time.Instant @SpringBootTest @ActiveProfiles("test") @@ -36,6 +47,9 @@ class UserIntegrationTest private val mvc: MockMvc, private val mapper: ObjectMapper, private val dataGenerator: DataGenerator, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, + private val userRepository: UserRepository, ) { // UserService.validateProfileImage 에서 직접 호출하는 S3Client 모킹 @MockitoBean @@ -45,6 +59,10 @@ class UserIntegrationTest @MockitoBean private lateinit var imageService: ImageService + // 탈퇴 시나리오의 메일 호출 검증용 + @MockitoBean + private lateinit var emailService: EmailService + // ================================================================= // GET /api/users/me — 내 정보 조회 // ================================================================= @@ -336,4 +354,158 @@ class UserIntegrationTest ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } + + // ================================================================= + // DELETE /api/users/me — 회원 탈퇴 + // ================================================================= + + @Test + fun `시작된 이벤트를 주최한 유저가 탈퇴하면 이벤트는 유지되고 createdBy가 null이 된다`() { + val (host, token) = dataGenerator.generateUser() + // 이미 시작된 이벤트 (startsAt 과거, registrationEndsAt 과거) + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + startsAt = Instant.now().minusSeconds(3600), + endsAt = Instant.now().plusSeconds(3600), + registrationStartsAt = Instant.now().minusSeconds(7200), + registrationEndsAt = Instant.now().minusSeconds(60), + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + val anonymized = eventRepository.findById(event.id!!).orElseThrow() + assertThat(anonymized.createdBy).isNull() + assertThat(userRepository.findById(host.id!!)).isEmpty + } + + @Test + fun `시작 전 이벤트를 주최한 유저가 탈퇴하면 이벤트와 등록이 삭제되고 참여자에게 취소 메일이 발송된다`() { + val (host, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + startsAt = Instant.now().plusSeconds(7200), + ) + val registration = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = participant.id!!, + status = RegistrationStatus.CONFIRMED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(eventRepository.findById(event.id!!)).isEmpty + assertThat(registrationRepository.findById(registration.id!!)).isEmpty + + verify(emailService, times(1)).sendEventCancellationEmail( + argThat { data -> + data.toEmail == participant.email && data.hostEmail == null + }, + ) + } + + @Test + fun `신청기간이 마감된 모임에 등록한 유저가 탈퇴하면 등록이 익명화된다`() { + val (host, _) = dataGenerator.generateUser() + val (participant, token) = dataGenerator.generateUser() + // 신청기간 마감, 이벤트는 아직 안 시작 + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationStartsAt = Instant.now().minusSeconds(7200), + registrationEndsAt = Instant.now().minusSeconds(60), + ) + val registration = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = participant.id!!, + status = RegistrationStatus.CONFIRMED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + val anonymized = registrationRepository.findById(registration.id!!).orElseThrow() + assertThat(anonymized.userId).isNull() + assertThat(anonymized.guestName).isEqualTo("탈퇴유저") + assertThat(anonymized.guestEmail).isNull() + } + + @Test + fun `신청기간 내 모임에 CONFIRMED 등록한 유저가 탈퇴하면 등록이 삭제되고 대기자가 승격된다`() { + val (host, _) = dataGenerator.generateUser() + val (withdrawingUser, token) = dataGenerator.generateUser() + val (waitlisted, _) = dataGenerator.generateUser() + // 정원 1, 대기 허용. 탈퇴 유저는 CONFIRMED, 다른 유저는 WAITLISTED + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + capacity = 1, + waitlistEnabled = true, + ) + val withdrawingReg = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = withdrawingUser.id!!, + status = RegistrationStatus.CONFIRMED, + ) + val waitlistedReg = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = waitlisted.id!!, + status = RegistrationStatus.WAITLISTED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(registrationRepository.findById(withdrawingReg.id!!)).isEmpty + val promoted = registrationRepository.findById(waitlistedReg.id!!).orElseThrow() + assertThat(promoted.status).isEqualTo(RegistrationStatus.CONFIRMED) + assertThat(promoted.userId).isEqualTo(waitlisted.id) + } + + @Test + fun `BANNED 등록만 있는 유저도 정상 탈퇴되고 BANNED 등록이 익명화된다`() { + val (host, _) = dataGenerator.generateUser() + val (bannedUser, token) = dataGenerator.generateUser() + val event = dataGenerator.createEvent(createdBy = host.id!!) + val bannedReg = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = bannedUser.id!!, + status = RegistrationStatus.BANNED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(userRepository.findById(bannedUser.id!!)).isEmpty + val anonymized = registrationRepository.findById(bannedReg.id!!).orElseThrow() + assertThat(anonymized.userId).isNull() + assertThat(anonymized.status).isEqualTo(RegistrationStatus.BANNED) + assertThat(anonymized.guestName).isEqualTo("탈퇴유저") + } }