From 361582ca192abd3224aae66ad791659460f1d416 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 20 May 2026 12:14:07 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B0=98=20=EB=A9=A4=EB=B2=84?= =?UTF-8?q?=20=EB=B0=A9=20=ED=83=88=ED=87=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tripsync/application/room/RoomService.kt | 69 ++++++- .../repository/ConflictMapRepository.kt | 1 + .../domain/repository/RoomMemberRepository.kt | 1 + .../repository/SatisfactionScoreRepository.kt | 6 +- .../domain/repository/TripPhotoRepository.kt | 2 + .../com/tripsync/web/room/RoomController.kt | 5 + .../kotlin/com/tripsync/AuthContractTests.kt | 191 ++++++++++++++++++ 7 files changed, 267 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/room/RoomService.kt b/src/main/kotlin/com/tripsync/application/room/RoomService.kt index 5bcdade..1aa3b4b 100644 --- a/src/main/kotlin/com/tripsync/application/room/RoomService.kt +++ b/src/main/kotlin/com/tripsync/application/room/RoomService.kt @@ -14,6 +14,7 @@ import com.tripsync.domain.repository.* import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.time.LocalDate import java.util.* @@ -24,6 +25,10 @@ class RoomService( private val roomMemberProfileRepository: RoomMemberProfileRepository, private val tptiResultRepository: TptiResultRepository, private val scheduleRepository: ScheduleRepository, + private val scheduleSlotRepository: ScheduleSlotRepository, + private val satisfactionScoreRepository: SatisfactionScoreRepository, + private val conflictMapRepository: ConflictMapRepository, + private val tripPhotoRepository: TripPhotoRepository, private val scheduleResponseMapper: ScheduleResponseMapper, ) { @@ -71,8 +76,8 @@ class RoomService( @Transactional(readOnly = true) fun getRoom(roomId: Long, user: User): ApiResponse> { - validateRoomMember(roomId, user.id) val room = getActiveRoom(roomId) + validateRoomMember(room.id, user.id) val memberCount = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).size return ApiResponse.ok(roomSummary(room, memberCount, includeScheduleState = true)) @@ -116,7 +121,7 @@ class RoomService( val room = tripRoomRepository.findByShareCodeAndDelYn(shareCode, YnFlag.N) ?: throw DomainException(HttpStatus.NOT_FOUND, "INVALID_SHARE_CODE", "유효하지 않은 공유 코드입니다.") - val existingMember = roomMemberRepository.findByRoomIdAndUserIdAndDelYn(room.id, user.id, YnFlag.N) + val existingMember = roomMemberRepository.findByRoomIdAndUserId(room.id, user.id) if (existingMember == null) { roomMemberRepository.save( RoomMember( @@ -125,6 +130,9 @@ class RoomService( role = if (room.hostUser.id == user.id) RoomMemberRole.HOST else RoomMemberRole.MEMBER, ) ) + } else { + existingMember.delYn = YnFlag.N + existingMember.role = if (room.hostUser.id == user.id) RoomMemberRole.HOST else existingMember.role } if (tptiResultId != null) { @@ -135,7 +143,7 @@ class RoomService( upsertMemberProfile(room, user, result) } - val roomStatus = refreshRoomStatus(room.id) + val roomStatus = if (room.status == TripRoomStatus.COMPLETED) room.status else refreshRoomStatus(room.id) return ApiResponse.ok( mapOf( "roomId" to room.id, @@ -148,14 +156,15 @@ class RoomService( @Transactional(readOnly = true) fun getMembers(roomId: Long, user: User): ApiResponse> { - validateRoomMember(roomId, user.id) - val members = roomMemberRepository.findAllByRoomIdAndDelYn(roomId, YnFlag.N) - val profilesByUserId = roomMemberProfileRepository.findAllByRoomIdAndDelYn(roomId, YnFlag.N) + val room = getActiveRoom(roomId) + validateRoomMember(room.id, user.id) + val members = roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N) + val profilesByUserId = roomMemberProfileRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N) .associateBy { it.user.id } return ApiResponse.ok( mapOf( - "roomId" to roomId, + "roomId" to room.id, "members" to members.sortedBy { it.joinedAt }.map { member -> val profile = profilesByUserId[member.user.id] mapOf( @@ -178,6 +187,52 @@ class RoomService( ) } + @Transactional + fun deleteRoom(roomId: Long, user: User): ApiResponse> { + if (user.isGuest) { + throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "로그인한 계정으로만 여행 방을 삭제하거나 나갈 수 있습니다.") + } + + val room = getActiveRoom(roomId) + val member = roomMemberRepository.findByRoomIdAndUserIdAndDelYn(room.id, user.id, YnFlag.N) + ?: throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방 멤버만 여행 방을 삭제하거나 나갈 수 있습니다.") + + if (member.role != RoomMemberRole.HOST) { + return leaveRoom(room, member) + } + + val schedules = scheduleRepository.findByRoomIdAndDelYn(room.id, YnFlag.N) + schedules.forEach { schedule -> + satisfactionScoreRepository.findAllByScheduleIdAndDelYn(schedule.id, YnFlag.N).forEach { it.delYn = YnFlag.Y } + scheduleSlotRepository.findAllByScheduleIdAndDelYn(schedule.id, YnFlag.N).forEach { it.delYn = YnFlag.Y } + schedule.delYn = YnFlag.Y + } + + val deletedAt = Instant.now() + tripPhotoRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).forEach { photo -> + photo.delYn = YnFlag.Y + photo.deletedBy = user + photo.deletedAt = deletedAt + } + conflictMapRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).forEach { it.delYn = YnFlag.Y } + roomMemberProfileRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).forEach { it.delYn = YnFlag.Y } + roomMemberRepository.findAllByRoomIdAndDelYn(room.id, YnFlag.N).forEach { it.delYn = YnFlag.Y } + room.delYn = YnFlag.Y + + return ApiResponse.ok(mapOf("roomId" to room.id, "deleted" to true, "left" to false)) + } + + private fun leaveRoom(room: TripRoom, member: RoomMember): ApiResponse> { + member.delYn = YnFlag.Y + roomMemberProfileRepository.findByRoomIdAndUserId(room.id, member.user.id)?.delYn = YnFlag.Y + satisfactionScoreRepository.findAllByScheduleRoomIdAndUserIdAndDelYn(room.id, member.user.id, YnFlag.N) + .forEach { it.delYn = YnFlag.Y } + if (room.status != TripRoomStatus.COMPLETED) { + refreshRoomStatus(room.id) + } + return ApiResponse.ok(mapOf("roomId" to room.id, "deleted" to false, "left" to true)) + } + private fun upsertMemberProfile(room: TripRoom, user: User, result: com.tripsync.domain.entity.TptiResult): RoomMemberProfile { val profile = roomMemberProfileRepository.findByRoomIdAndUserId(room.id, user.id) diff --git a/src/main/kotlin/com/tripsync/domain/repository/ConflictMapRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/ConflictMapRepository.kt index 482ff0d..083a9d4 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/ConflictMapRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/ConflictMapRepository.kt @@ -8,4 +8,5 @@ import org.springframework.stereotype.Repository @Repository interface ConflictMapRepository : JpaRepository { fun findTopByRoomIdAndDelYnOrderByCreatedAtDesc(roomId: Long, delYn: YnFlag): ConflictMap? + fun findAllByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List } diff --git a/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt index 8a03646..1930e04 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository @Repository interface RoomMemberRepository : JpaRepository { + fun findByRoomIdAndUserId(roomId: Long, userId: Long): RoomMember? fun findByRoomIdAndUserIdAndDelYn(roomId: Long, userId: Long, delYn: YnFlag): RoomMember? fun existsByRoomIdAndUserIdAndDelYn(roomId: Long, userId: Long, delYn: YnFlag): Boolean fun findAllByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List diff --git a/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt index 7b5bd90..cc1196b 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt @@ -1,8 +1,12 @@ package com.tripsync.domain.repository import com.tripsync.domain.entity.SatisfactionScore +import com.tripsync.domain.enums.YnFlag import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -interface SatisfactionScoreRepository : JpaRepository +interface SatisfactionScoreRepository : JpaRepository { + fun findAllByScheduleIdAndDelYn(scheduleId: Long, delYn: YnFlag): List + fun findAllByScheduleRoomIdAndUserIdAndDelYn(roomId: Long, userId: Long, delYn: YnFlag): List +} diff --git a/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt index cd8169c..5a692d3 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/TripPhotoRepository.kt @@ -47,6 +47,8 @@ interface TripPhotoRepository : JpaRepository { status: PhotoStatus, ): List + fun findAllByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List + fun countByScheduleIdAndDelYnAndStatus(scheduleId: Long, delYn: YnFlag, status: PhotoStatus): Long fun countByScheduleSlotIdAndDelYnAndStatus(scheduleSlotId: Long, delYn: YnFlag, status: PhotoStatus): Long fun countByScheduleIdAndUploaderIdAndDelYnAndStatus(scheduleId: Long, uploaderId: Long, delYn: YnFlag, status: PhotoStatus): Long diff --git a/src/main/kotlin/com/tripsync/web/room/RoomController.kt b/src/main/kotlin/com/tripsync/web/room/RoomController.kt index 7639e13..fee9967 100644 --- a/src/main/kotlin/com/tripsync/web/room/RoomController.kt +++ b/src/main/kotlin/com/tripsync/web/room/RoomController.kt @@ -58,6 +58,11 @@ class RoomController( return roomService.getMembers(roomId, user) } + @DeleteMapping("/{roomId}") + fun deleteRoom(@PathVariable roomId: Long, @CurrentUser user: User): ApiResponse> { + return roomService.deleteRoom(roomId, user) + } + @GetMapping("/{roomId}") fun getRoom(@PathVariable roomId: Long, @CurrentUser user: User): ApiResponse> { return roomService.getRoom(roomId, user) diff --git a/src/test/kotlin/com/tripsync/AuthContractTests.kt b/src/test/kotlin/com/tripsync/AuthContractTests.kt index 9a199be..9be696b 100644 --- a/src/test/kotlin/com/tripsync/AuthContractTests.kt +++ b/src/test/kotlin/com/tripsync/AuthContractTests.kt @@ -1,7 +1,26 @@ package com.tripsync +import com.tripsync.domain.entity.Place +import com.tripsync.domain.entity.SatisfactionScore +import com.tripsync.domain.entity.Schedule +import com.tripsync.domain.entity.ScheduleSlot +import com.tripsync.domain.entity.TripPhoto +import com.tripsync.domain.enums.PhotoStatus +import com.tripsync.domain.enums.ReasonAxis +import com.tripsync.domain.enums.ScheduleOptionType +import com.tripsync.domain.enums.SlotType +import com.tripsync.domain.enums.TripRoomStatus +import com.tripsync.domain.enums.YnFlag +import com.tripsync.domain.repository.PlaceRepository +import com.tripsync.domain.repository.SatisfactionScoreRepository +import com.tripsync.domain.repository.ScheduleRepository +import com.tripsync.domain.repository.ScheduleSlotRepository +import com.tripsync.domain.repository.TripPhotoRepository +import com.tripsync.domain.repository.TripRoomRepository +import com.tripsync.domain.repository.UserRepository import org.hamcrest.Matchers.notNullValue import org.hamcrest.Matchers.startsWith +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -9,9 +28,12 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post +import java.math.BigDecimal import java.net.URLDecoder +import java.time.Instant import java.time.LocalDate import java.nio.charset.StandardCharsets @@ -20,6 +42,13 @@ import java.nio.charset.StandardCharsets @ActiveProfiles("test") class AuthContractTests( @Autowired private val mockMvc: MockMvc, + @Autowired private val userRepository: UserRepository, + @Autowired private val tripRoomRepository: TripRoomRepository, + @Autowired private val placeRepository: PlaceRepository, + @Autowired private val scheduleRepository: ScheduleRepository, + @Autowired private val scheduleSlotRepository: ScheduleSlotRepository, + @Autowired private val satisfactionScoreRepository: SatisfactionScoreRepository, + @Autowired private val tripPhotoRepository: TripPhotoRepository, ) { @Test fun `guest session returns Nest-compatible user payload and session cookie`() { @@ -348,6 +377,91 @@ class AuthContractTests( } } + @Test + fun `host can delete own room and it disappears from room entry points`() { + val hostSession = registerSession("host-delete-room@example.com", "삭제방장") + val payload = createRoomPayload(hostSession) + + mockMvc.delete("/rooms/${payload.roomId}") { cookie(hostSession) } + .andExpect { + status { isOk() } + jsonPath("$.success") { value(true) } + jsonPath("$.data.roomId") { value(payload.roomId.toInt()) } + jsonPath("$.data.deleted") { value(true) } + } + + mockMvc.get("/rooms/my") { cookie(hostSession) } + .andExpect { + status { isOk() } + jsonPath("$.data.rooms.length()") { value(0) } + } + + mockMvc.get("/rooms/${payload.roomId}") { cookie(hostSession) } + .andExpect { + status { isNotFound() } + jsonPath("$.error.code") { value("ROOM_NOT_FOUND") } + } + + mockMvc.get("/rooms/share/${payload.shareCode}") + .andExpect { + status { isNotFound() } + jsonPath("$.error.code") { value("INVALID_SHARE_CODE") } + } + } + + @Test + fun `joined member delete request leaves room without deleting host room`() { + val hostSession = registerSession("host-delete-forbidden@example.com", "삭제방장2") + val payload = createRoomPayload(hostSession) + val memberSession = registerSession("member-delete-forbidden@example.com", "삭제멤버") + + mockMvc.post("/rooms/${payload.shareCode}/join") { + cookie(memberSession) + contentType = MediaType.APPLICATION_JSON + content = "{}" + }.andExpect { + status { isCreated() } + } + val memberUserId = currentUserId(memberSession) + val archiveFixture = createArchiveFixture(payload.roomId, memberUserId) + + mockMvc.delete("/rooms/${payload.roomId}") { cookie(memberSession) } + .andExpect { + status { isOk() } + jsonPath("$.success") { value(true) } + jsonPath("$.data.roomId") { value(payload.roomId.toInt()) } + jsonPath("$.data.deleted") { value(false) } + jsonPath("$.data.left") { value(true) } + } + + mockMvc.get("/rooms/my") { cookie(memberSession) } + .andExpect { + status { isOk() } + jsonPath("$.data.rooms.length()") { value(0) } + } + + mockMvc.get("/rooms/${payload.roomId}") { cookie(memberSession) } + .andExpect { + status { isForbidden() } + jsonPath("$.error.code") { value("FORBIDDEN") } + } + + mockMvc.get("/rooms/${payload.roomId}") { cookie(hostSession) } + .andExpect { + status { isOk() } + jsonPath("$.data.memberCount") { value(1) } + } + + mockMvc.get("/rooms/share/${payload.shareCode}") + .andExpect { status { isOk() } } + + val photo = tripPhotoRepository.findById(archiveFixture.photoId).orElseThrow() + assertEquals(YnFlag.N, photo.delYn) + assertEquals(PhotoStatus.ACTIVE, photo.status) + val score = satisfactionScoreRepository.findById(archiveFixture.satisfactionScoreId).orElseThrow() + assertEquals(YnFlag.Y, score.delYn) + } + private fun assertOAuthRedirectPath(redirectPath: String, expectedLocation: String) { val start = mockMvc.get("/auth/google") { param("redirectPath", redirectPath) @@ -374,6 +488,83 @@ class AuthContractTests( } } + private fun currentUserId(session: jakarta.servlet.http.Cookie): Long { + val response = mockMvc.get("/auth/me") { cookie(session) } + .andExpect { status { isOk() } } + .andReturn().response.contentAsString + return Regex("\"id\":(\\d+)").find(response)!!.groupValues[1].toLong() + } + + private fun createArchiveFixture(roomId: Long, uploaderUserId: Long): ArchiveFixture { + val room = tripRoomRepository.findById(roomId).orElseThrow() + room.status = TripRoomStatus.COMPLETED + val uploader = userRepository.findById(uploaderUserId).orElseThrow() + val place = placeRepository.save( + Place( + tourApiId = "leave-photo-${System.nanoTime()}", + name = "탈퇴 테스트 장소", + address = "충청남도 보령시", + latitude = BigDecimal("36.5000000"), + longitude = BigDecimal("126.5000000"), + category = "관광지", + mobilityScore = 50, + photoScore = 80, + budgetScore = 60, + themeScore = 40, + metadataTags = mapOf("populationDeclineArea" to true), + ) + ) + val schedule = scheduleRepository.save( + Schedule( + room = room, + version = 1, + optionType = ScheduleOptionType.BALANCED, + isConfirmed = true, + generationInput = mapOf("destination" to room.destination), + summary = "탈퇴 테스트 확정 일정", + groupSatisfaction = 90, + ) + ) + val slot = scheduleSlotRepository.save( + ScheduleSlot( + schedule = schedule, + startTime = Instant.parse("2026-06-01T00:00:00Z"), + endTime = Instant.parse("2026-06-01T01:00:00Z"), + place = place, + slotType = SlotType.COMMON, + reasonAxis = ReasonAxis.COMMON, + reasonText = "탈퇴 테스트 장소", + orderIndex = 1, + ) + ) + val photo = tripPhotoRepository.save( + TripPhoto( + room = room, + schedule = schedule, + scheduleSlot = slot, + place = place, + uploader = uploader, + originalFilename = "leave-photo.jpg", + contentType = "image/jpeg", + fileSize = 1, + content = byteArrayOf(1), + caption = "탈퇴 후 유지될 사진", + status = PhotoStatus.ACTIVE, + ) + ) + val score = satisfactionScoreRepository.save( + SatisfactionScore( + schedule = schedule, + user = uploader, + score = 88, + breakdown = mapOf("overall" to 88), + ) + ) + return ArchiveFixture(photo.id, score.id) + } + + private data class ArchiveFixture(val photoId: Long, val satisfactionScoreId: Long) + private fun registerSession(email: String, nickname: String): jakarta.servlet.http.Cookie { val uniqueEmail = email.replace("@", "+${System.nanoTime()}@") return mockMvc.post("/auth/register") {