diff --git a/src/main/java/com/sniptext/domain/template/controller/TrashController.java b/src/main/java/com/sniptext/domain/template/controller/TrashController.java index 6a1ed53..431ee10 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TrashController.java +++ b/src/main/java/com/sniptext/domain/template/controller/TrashController.java @@ -1,6 +1,7 @@ package com.sniptext.domain.template.controller; 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -8,10 +9,13 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.sniptext.domain.template.controller.dto.TrashEmptyResponse; import com.sniptext.domain.template.controller.dto.TrashListResponse; +import com.sniptext.domain.template.controller.dto.TrashPurgeResponse; import com.sniptext.domain.template.controller.dto.TrashRestoreResponse; import com.sniptext.domain.template.service.TrashCommandService; import com.sniptext.domain.template.service.TrashQueryService; +import com.sniptext.domain.template.service.dto.command.TrashPurgeCommand; import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand; import com.sniptext.domain.template.service.dto.query.TrashListQuery; import com.sniptext.global.response.ApiResponse; @@ -57,4 +61,22 @@ public ResponseEntity> restore( return ApiResponse.success(TrashSuccessCode.TRASH_RESTORED, response); } + @DeleteMapping("/{templateId}") + public ResponseEntity> purge( + @LoginUser Long userId, + @PathVariable Long templateId) { + + TrashPurgeResponse response = TrashPurgeResponse.from( + trashCommandService.purge(new TrashPurgeCommand(userId, templateId))); + + return ApiResponse.success(TrashSuccessCode.TRASH_PURGED, response); + } + + @DeleteMapping + public ResponseEntity> empty(@LoginUser Long userId) { + TrashEmptyResponse response = TrashEmptyResponse.from(trashCommandService.empty(userId)); + + return ApiResponse.success(TrashSuccessCode.TRASH_EMPTIED, response); + } + } diff --git a/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java b/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java index 3f59043..c94b4dc 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java +++ b/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java @@ -12,7 +12,9 @@ public enum TrashSuccessCode implements ResultCode { TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다."), - TRASH_RESTORED(HttpStatus.OK, "TRASH200_2", "템플릿을 복구했습니다."); + TRASH_RESTORED(HttpStatus.OK, "TRASH200_2", "템플릿을 복구했습니다."), + TRASH_PURGED(HttpStatus.OK, "TRASH200_3", "템플릿을 영구 삭제했습니다."), + TRASH_EMPTIED(HttpStatus.OK, "TRASH200_4", "휴지통을 비웠습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TrashEmptyResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TrashEmptyResponse.java new file mode 100644 index 0000000..c56cc3b --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TrashEmptyResponse.java @@ -0,0 +1,12 @@ +package com.sniptext.domain.template.controller.dto; + +import com.sniptext.domain.template.service.dto.result.TrashEmptyResult; + +/** 휴지통 비우기 결과 (명세 18번). 비어 있었으면 0 */ +public record TrashEmptyResponse(int deletedCount) { + + public static TrashEmptyResponse from(TrashEmptyResult result) { + return new TrashEmptyResponse(result.deletedCount()); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TrashPurgeResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TrashPurgeResponse.java new file mode 100644 index 0000000..5d4b352 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TrashPurgeResponse.java @@ -0,0 +1,12 @@ +package com.sniptext.domain.template.controller.dto; + +import com.sniptext.domain.template.service.dto.result.TrashPurgeResult; + +/** 휴지통 템플릿 개별 영구 삭제 결과 (명세 17번) */ +public record TrashPurgeResponse(Long templateId) { + + public static TrashPurgeResponse from(TrashPurgeResult result) { + return new TrashPurgeResponse(result.templateId()); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java index 62c417b..d7c45b1 100644 --- a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java +++ b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -24,7 +25,7 @@ public interface TemplateRepository extends JpaRepository { /** * 본인의 휴지통 템플릿을 비관적 쓰기 잠금으로 찾는다 (SELECT ... FOR UPDATE). 복구가 쓴다. - * 회원을 잠그지 않는 정리 배치·영구 삭제가 먼저 지우고 있으면 기다렸다가 빈 값을 받는다. + * 회원을 잠그지 않는 정리 배치가 먼저 지우고 있으면 기다렸다가 빈 값을 받는다. * 잠금 순서는 회원 행 → 템플릿 행이다. 템플릿을 먼저 잠그고 회원을 잠그는 쪽이 생기면 교착이 날 수 있다 */ @Lock(LockModeType.PESSIMISTIC_WRITE) @@ -78,4 +79,19 @@ public interface TemplateRepository extends JpaRepository { List findTrashAfter(@Param("userId") Long userId, @Param("deletedAt") LocalDateTime deletedAt, @Param("id") Long id, Limit limit); + /** + * 본인의 휴지통 템플릿 하나를 엔티티를 읽지 않고 지운다. 지운 행 수(1 또는 0)를 돌려준다. + * 엔티티를 읽으면 본문을 복호화하므로, 복호화에 실패하는 행도 지울 수 있게 삭제 쿼리로 둔다. + * 지운 행이 1차 캐시에 남아 있으면 같은 트랜잭션의 다음 조회가 이미 없는 행을 돌려주므로 실행 뒤 캐시를 비우고, + * 비우면서 아직 내보내지 않은 변경이 사라지지 않게 실행 전에 내보낸다. 호출 뒤에는 미리 읽어 둔 엔티티를 고쳐도 반영되지 않는다 + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from Template t where t.id = :id and t.userId = :userId and t.deletedAt is not null") + int deleteTrashedByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId); + + /** 본인의 휴지통을 모두 지우고 지운 개수를 돌려준다. 읽지 않고 지우는 이유와 캐시 처리는 deleteTrashedByIdAndUserId와 같다 */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("delete from Template t where t.userId = :userId and t.deletedAt is not null") + int deleteTrashedByUserId(@Param("userId") Long userId); + } diff --git a/src/main/java/com/sniptext/domain/template/service/TrashCommandService.java b/src/main/java/com/sniptext/domain/template/service/TrashCommandService.java index 96bed40..2982bf1 100644 --- a/src/main/java/com/sniptext/domain/template/service/TrashCommandService.java +++ b/src/main/java/com/sniptext/domain/template/service/TrashCommandService.java @@ -7,8 +7,11 @@ import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.exception.TrashErrorCode; import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.service.dto.command.TrashPurgeCommand; import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand; import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; +import com.sniptext.domain.template.service.dto.result.TrashEmptyResult; +import com.sniptext.domain.template.service.dto.result.TrashPurgeResult; import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.global.exception.BusinessException; @@ -30,7 +33,7 @@ public class TrashCommandService { * 회원 잠금이 트랜잭션의 첫 조회여야 한다. MySQL 기본 격리 수준(REPEATABLE READ)은 첫 일반 조회 시점의 스냅샷을 끝까지 쓰므로, * 템플릿을 먼저 읽으면 잠금을 기다리는 사이 커밋된 생성·복구를 개수 검사와 단축어 조회가 보지 못한다. * 같은 템플릿을 동시에 복구해도 잠금으로 줄을 서므로, 뒤에 온 요청은 이미 꺼낸 템플릿을 읽어 TRASH404_1이 된다. - * 템플릿 행도 잠가 읽는다. 정리 배치·영구 삭제는 회원을 잠그지 않아, 일반 조회로 읽으면 저장 전에 행이 지워져 500이 나기 때문이다. + * 템플릿 행도 잠가 읽는다. 정리 배치는 회원을 잠그지 않아, 일반 조회로 읽으면 저장 전에 행이 지워져 500이 나기 때문이다. * 잠금 조회는 스냅샷을 만들지 않으므로 이어지는 개수 검사가 옛 값을 보는 문제는 생기지 않는다. * 엔티티를 읽으므로 본문 복호화에 실패하는 행은 복구도 500이다. */ @@ -49,4 +52,31 @@ public TemplateShortcutAssignResult restore(TrashRestoreCommand command) { return TemplateShortcutAssignResult.of(templateCommandService.save(template), original); } + /** + * 본인 휴지통의 템플릿 하나를 영구 삭제한다. 없는 ID, 다른 회원의 템플릿, 휴지통 밖 템플릿, 이미 지운 템플릿은 TRASH404_1. + * 엔티티를 읽지 않고 삭제 쿼리로 지우므로 본문 복호화에 실패하는 행도 지운다. + * 복구·비우기와 같이 회원 행을 먼저 잠근다. 같은 회원의 휴지통 쓰기가 줄을 서므로, 같은 템플릿을 동시에 지우거나 복구와 겹치면 + * 뒤에 온 쪽은 조건에 맞는 행이 없어 TRASH404_1이 된다. 잠금 순서(회원 행 → 템플릿 행)가 같아 서로 교착하지 않는다. + */ + @Transactional + public TrashPurgeResult purge(TrashPurgeCommand command) { + userCommandService.lockUser(command.userId()); + int deleted = templateRepository.deleteTrashedByIdAndUserId(command.templateId(), command.userId()); + if (deleted == 0) { + throw new BusinessException(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND); + } + return new TrashPurgeResult(command.templateId()); + } + + /** + * 본인 휴지통을 모두 영구 삭제하고 지운 개수를 돌려준다. 비어 있어도 성공이다. 읽지 않고 지우는 이유는 purge와 같다. + * 회원 행을 먼저 잠가야 한다. 비우기는 (user_id, deleted_at) 인덱스 → 기본 키 순서로 행을 잠그고, 복구는 기본 키를 잠근 뒤 + * 저장할 때 같은 인덱스를 고치므로, 둘이 줄을 서지 않으면 교착이 나서 한쪽이 500이 된다. + */ + @Transactional + public TrashEmptyResult empty(Long userId) { + userCommandService.lockUser(userId); + return new TrashEmptyResult(templateRepository.deleteTrashedByUserId(userId)); + } + } diff --git a/src/main/java/com/sniptext/domain/template/service/dto/command/TrashPurgeCommand.java b/src/main/java/com/sniptext/domain/template/service/dto/command/TrashPurgeCommand.java new file mode 100644 index 0000000..0047819 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/command/TrashPurgeCommand.java @@ -0,0 +1,4 @@ +package com.sniptext.domain.template.service.dto.command; + +public record TrashPurgeCommand(Long userId, Long templateId) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TrashEmptyResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashEmptyResult.java new file mode 100644 index 0000000..0f38692 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashEmptyResult.java @@ -0,0 +1,5 @@ +package com.sniptext.domain.template.service.dto.result; + +/** 휴지통 비우기로 지운 개수. 비어 있었으면 0 */ +public record TrashEmptyResult(int deletedCount) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TrashPurgeResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashPurgeResult.java new file mode 100644 index 0000000..22a37cd --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TrashPurgeResult.java @@ -0,0 +1,5 @@ +package com.sniptext.domain.template.service.dto.result; + +/** 영구 삭제한 템플릿 ID */ +public record TrashPurgeResult(Long templateId) { +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TrashPurgeControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TrashPurgeControllerTest.java new file mode 100644 index 0000000..a802af6 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TrashPurgeControllerTest.java @@ -0,0 +1,314 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.user.entity.User; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import jakarta.persistence.EntityManager; +import tools.jackson.databind.json.JsonMapper; + +/** 명세 17·18번. 본인 소유·휴지통 조건과 지운 뒤 목록을 실제 필터·MySQL까지 거쳐 확인한다. */ +@Transactional +@Import(MutableClockConfiguration.class) +class TrashPurgeControllerTest extends IntegrationTestSupport { + + private static final String TRASH_PATH = "/api/v1/trash/templates"; + private static final String PURGE_PATH = TRASH_PATH + "/{templateId}"; + private static final String NO_STORE = "no-store"; + private static final LocalDateTime FIXED = LocalDateTime.of(2026, 10, 25, 10, 0); + private static final LocalDateTime DELETED_AT = FIXED.minusDays(1); + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private MutableClock clock; + + @Autowired + private JsonMapper jsonMapper; + + private Long userId; + private Long otherUserId; + + @BeforeEach + void setUp() { + clock.setTo(FIXED); + userId = saveUser("owner@sniptext.com"); + otherUserId = saveUser("other@sniptext.com"); + } + + @Test + @DisplayName("영구 삭제하면 200과 지운 ID만 돌려주고, 그 행만 사라진다") + void purge() throws Exception { + Long target = saveTrashed(userId, "/견적"); + Long remainingTrashed = saveTrashed(userId, "/회의"); + Long myActive = saveActive(userId, "/보고"); + + MvcResult result = purgeAs(userId, target) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TRASH200_3")) + .andExpect(jsonPath("$.message").value("템플릿을 영구 삭제했습니다.")) + .andExpect(jsonPath("$.result.templateId").value(target)) + .andReturn(); + + assertThat(resultFieldNames(result)).containsExactly("templateId"); + assertThat(templateRepository.existsById(target)).isFalse(); + assertThat(reload(remainingTrashed).isInTrash()).isTrue(); + assertThat(reload(myActive).isInTrash()).isFalse(); + } + + @Test + @DisplayName("없는 ID, 다른 회원의 활성·휴지통 템플릿, 휴지통에 없는 내 템플릿은 TRASH404_1이고 아무것도 지우지 않는다") + void purgeNotFound() throws Exception { + Long othersActive = saveActive(otherUserId, "/견적"); + Long othersTrashed = saveTrashed(otherUserId, "/회의"); + Long myActive = saveActive(userId, "/보고"); + + for (Long templateId : new Long[] {Long.MAX_VALUE, othersActive, othersTrashed, myActive}) { + expectNotFound(purgeAs(userId, templateId)); + } + + assertThat(reload(othersActive).isInTrash()).isFalse(); + assertThat(reload(othersTrashed).isInTrash()).isTrue(); + assertThat(reload(myActive).isInTrash()).isFalse(); + } + + @Test + @DisplayName("이미 영구 삭제한 템플릿을 다시 지우면 TRASH404_1") + void purgeTwice() throws Exception { + Long target = saveTrashed(userId, "/견적"); + + purgeAs(userId, target).andExpect(status().isOk()); + + expectNotFound(purgeAs(userId, target)); + } + + @ParameterizedTest + @ValueSource(strings = {"abc", "99999999999999999999"}) + @DisplayName("templateId가 Long으로 바뀌지 않으면 COMMON400_1") + void purgeInvalidTemplateId(String templateId) throws Exception { + expectError(mockMvc.perform(delete(TRASH_PATH + "/" + templateId) + .header(HttpHeaders.AUTHORIZATION, bearer(userId))), 400, "COMMON400_1", "잘못된 요청입니다."); + } + + @Test + @DisplayName("영구 삭제에 토큰이 없으면 COMMON401_1이고 지우지 않는다") + void purgeUnauthorized() throws Exception { + Long target = saveTrashed(userId, "/견적"); + + expectError(mockMvc.perform(delete(PURGE_PATH, target)), 401, "COMMON401_1", "로그인이 필요해요."); + + assertThat(templateRepository.existsById(target)).isTrue(); + } + + @Test + @DisplayName("영구 삭제한 템플릿은 휴지통 목록과 개수에서 빠진다") + void purgeLeavesTrashList() throws Exception { + Long target = saveTrashed(userId, "/견적"); + Long remaining = saveTrashed(userId, "/회의"); + + purgeAs(userId, target).andExpect(status().isOk()); + + getTrash(userId) + .andExpect(jsonPath("$.result.totalCount").value(1)) + .andExpect(jsonPath("$.result.templates[*].templateId", contains(remaining.intValue()))); + } + + @Test + @DisplayName("영구 삭제한 템플릿의 단축어로 새 템플릿을 만들 수 있다") + void createWithPurgedShortcut() throws Exception { + Long target = saveTrashed(userId, "/견적"); + + purgeAs(userId, target).andExpect(status().isOk()); + + mockMvc.perform(post("/api/v1/templates") + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title": "견적 안내", "shortcut": "/견적", "content": "본문"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.result.shortcut").value("/견적")); + } + + @Test + @DisplayName("비우면 200과 지운 개수를 돌려주고, 내 휴지통만 지운다. 활성 템플릿과 다른 회원의 휴지통은 그대로다") + void empty() throws Exception { + Long first = saveTrashed(userId, "/견적"); + Long second = saveTrashed(userId, "/견적"); + Long third = saveTrashed(userId, "/회의"); + Long myActive = saveActive(userId, "/견적"); + Long othersTrashed = saveTrashed(otherUserId, "/견적"); + + MvcResult result = emptyAs(userId) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TRASH200_4")) + .andExpect(jsonPath("$.message").value("휴지통을 비웠습니다.")) + .andExpect(jsonPath("$.result.deletedCount").value(3)) + .andReturn(); + + assertThat(resultFieldNames(result)).containsExactly("deletedCount"); + for (Long purged : new Long[] {first, second, third}) { + assertThat(templateRepository.existsById(purged)).isFalse(); + } + assertThat(reload(myActive).isInTrash()).isFalse(); + assertThat(reload(othersTrashed).isInTrash()).isTrue(); + } + + @Test + @DisplayName("휴지통이 비어 있어도 에러가 아니라 deletedCount 0이다") + void emptyNothing() throws Exception { + saveActive(userId, "/견적"); + + emptyAs(userId) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("TRASH200_4")) + .andExpect(jsonPath("$.result.deletedCount").value(0)); + + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(1); + } + + @Test + @DisplayName("비운 뒤 휴지통 목록은 비어 있다") + void emptyLeavesTrashList() throws Exception { + saveTrashed(userId, "/견적"); + saveTrashed(userId, "/회의"); + + emptyAs(userId).andExpect(status().isOk()); + + getTrash(userId) + .andExpect(jsonPath("$.result.totalCount").value(0)) + .andExpect(jsonPath("$.result.templates.length()").value(0)); + } + + @Test + @DisplayName("비우기에 토큰이 없으면 COMMON401_1이고 지우지 않는다") + void emptyUnauthorized() throws Exception { + Long trashed = saveTrashed(userId, "/견적"); + + expectError(mockMvc.perform(delete(TRASH_PATH)), 401, "COMMON401_1", "로그인이 필요해요."); + + assertThat(templateRepository.existsById(trashed)).isTrue(); + } + + /** 엔티티를 읽으면 복호화에서 500이 나므로, 읽지 않고 지워야 이런 행도 치울 수 있다 */ + @Test + @DisplayName("본문을 복호화할 수 없는 행도 영구 삭제·비우기로 지운다") + void undecryptableContent() throws Exception { + Long brokenOne = saveTrashed(userId, "/견적"); + Long brokenAll = saveTrashed(userId, "/회의"); + breakContent(brokenOne); + breakContent(brokenAll); + + purgeAs(userId, brokenOne).andExpect(status().isOk()); + emptyAs(userId) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.deletedCount").value(1)); + + assertThat(templateRepository.existsById(brokenOne)).isFalse(); + assertThat(templateRepository.existsById(brokenAll)).isFalse(); + } + + private ResultActions purgeAs(Long requesterId, Long templateId) throws Exception { + return mockMvc.perform(delete(PURGE_PATH, templateId).header(HttpHeaders.AUTHORIZATION, bearer(requesterId))); + } + + private ResultActions emptyAs(Long requesterId) throws Exception { + return mockMvc.perform(delete(TRASH_PATH).header(HttpHeaders.AUTHORIZATION, bearer(requesterId))); + } + + private ResultActions getTrash(Long requesterId) throws Exception { + return mockMvc.perform(get(TRASH_PATH).header(HttpHeaders.AUTHORIZATION, bearer(requesterId))) + .andExpect(status().isOk()); + } + + private Iterable resultFieldNames(MvcResult result) throws Exception { + return jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .get("result").propertyNames(); + } + + private static void expectNotFound(ResultActions actions) throws Exception { + expectError(actions, 404, "TRASH404_1", "휴지통에서 템플릿을 찾을 수 없어요."); + } + + private static void expectError(ResultActions actions, int status, String code, String message) throws Exception { + actions.andExpect(status().is(status)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(code)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.result").isEmpty()); + } + + private void breakContent(Long templateId) { + entityManager.createNativeQuery("update templates set content = 'not-encrypted' where id = :id") + .setParameter("id", templateId) + .executeUpdate(); + entityManager.clear(); + } + + private Long saveActive(Long ownerId, String shortcut) { + Long id = templateRepository.saveAndFlush(TemplateFixture.template(ownerId, shortcut)).getId(); + entityManager.clear(); + return id; + } + + private Long saveTrashed(Long ownerId, String shortcut) { + Long id = templateRepository.saveAndFlush(TemplateFixture.trashedTemplate(ownerId, shortcut, DELETED_AT)) + .getId(); + entityManager.clear(); + return id; + } + + /** 쌓인 변경을 내보내고 1차 캐시를 비워 DB 값을 다시 읽는다 */ + private Template reload(Long templateId) { + entityManager.flush(); + entityManager.clear(); + return entityManager.find(Template.class, templateId); + } + + private Long saveUser(String email) { + User user = UserFixture.user(email); + entityManager.persist(user); + entityManager.flush(); + return user.getId(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/repository/TrashPurgeRepositoryTest.java b/src/test/java/com/sniptext/domain/template/repository/TrashPurgeRepositoryTest.java new file mode 100644 index 0000000..ba6f814 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/repository/TrashPurgeRepositoryTest.java @@ -0,0 +1,164 @@ +package com.sniptext.domain.template.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.user.entity.User; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import jakarta.persistence.EntityManager; + +/** 휴지통 영구 삭제 쿼리의 조건(본인 소유 + 휴지통)과 지운 행 수를 실제 MySQL로 검증한다. */ +@Transactional +@Import(MutableClockConfiguration.class) +class TrashPurgeRepositoryTest extends IntegrationTestSupport { + + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 20, 10, 0); + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private MutableClock clock; + + private Long userId; + private Long otherUserId; + + @BeforeEach + void setUp() { + clock.setTo(LocalDateTime.of(2026, 10, 25, 10, 0)); + userId = saveUser("owner@sniptext.com"); + otherUserId = saveUser("other@sniptext.com"); + } + + @Test + @DisplayName("내 휴지통 템플릿 하나를 지우고 1을 돌려준다. 다른 행은 그대로다") + void deleteOne() { + Long target = saveTrashed(userId, "/견적"); + Long remaining = saveTrashed(userId, "/회의"); + + assertThat(templateRepository.deleteTrashedByIdAndUserId(target, userId)).isEqualTo(1); + + assertThat(templateRepository.existsById(target)).isFalse(); + assertThat(templateRepository.existsById(remaining)).isTrue(); + } + + @Test + @DisplayName("없는 ID, 다른 회원의 휴지통, 휴지통에 없는 내 템플릿은 지우지 않고 0을 돌려준다") + void deleteOneNotMatched() { + Long othersTrashed = saveTrashed(otherUserId, "/견적"); + Long myActive = saveActive(userId, "/회의"); + + assertThat(templateRepository.deleteTrashedByIdAndUserId(Long.MAX_VALUE, userId)).isZero(); + assertThat(templateRepository.deleteTrashedByIdAndUserId(othersTrashed, userId)).isZero(); + assertThat(templateRepository.deleteTrashedByIdAndUserId(myActive, userId)).isZero(); + + assertThat(templateRepository.existsById(othersTrashed)).isTrue(); + assertThat(templateRepository.existsById(myActive)).isTrue(); + } + + @Test + @DisplayName("내 휴지통만 모두 지우고 지운 개수를 돌려준다. 활성 템플릿과 다른 회원의 휴지통은 그대로다") + void deleteAll() { + Long first = saveTrashed(userId, "/견적"); + Long second = saveTrashed(userId, "/견적"); + Long myActive = saveActive(userId, "/견적"); + Long othersTrashed = saveTrashed(otherUserId, "/회의"); + + assertThat(templateRepository.deleteTrashedByUserId(userId)).isEqualTo(2); + + assertThat(templateRepository.existsById(first)).isFalse(); + assertThat(templateRepository.existsById(second)).isFalse(); + assertThat(templateRepository.existsById(myActive)).isTrue(); + assertThat(templateRepository.existsById(othersTrashed)).isTrue(); + } + + @Test + @DisplayName("휴지통이 비어 있으면 0을 돌려준다") + void deleteAllEmpty() { + saveActive(userId, "/견적"); + + assertThat(templateRepository.deleteTrashedByUserId(userId)).isZero(); + } + + /** 엔티티를 읽지 않고 지우므로 본문 복호화를 거치지 않는다 */ + @Test + @DisplayName("본문을 복호화할 수 없는 행도 지운다") + void undecryptableContent() { + Long brokenOne = saveTrashed(userId, "/견적"); + Long brokenAll = saveTrashed(userId, "/회의"); + breakContent(brokenOne); + breakContent(brokenAll); + + assertThat(templateRepository.deleteTrashedByIdAndUserId(brokenOne, userId)).isEqualTo(1); + assertThat(templateRepository.deleteTrashedByUserId(userId)).isEqualTo(1); + } + + /** 지운 뒤에 1차 캐시에 남은 엔티티를 돌려주면 이미 없는 행을 있다고 보게 된다 */ + @Test + @DisplayName("지우기 전에 읽어 둔 엔티티가 있어도 지운 뒤 다시 찾으면 없다") + void clearsPersistenceContext() { + Long one = saveTrashed(userId, "/견적"); + Long all = saveTrashed(userId, "/회의"); + templateRepository.findById(one).orElseThrow(); + templateRepository.findById(all).orElseThrow(); + + templateRepository.deleteTrashedByIdAndUserId(one, userId); + templateRepository.deleteTrashedByUserId(userId); + + assertThat(templateRepository.findById(one)).isEmpty(); + assertThat(templateRepository.findById(all)).isEmpty(); + } + + /** 비우기 전에 1차 캐시를 비우면 아직 내보내지 않은 변경이 사라진다 */ + @Test + @DisplayName("지우기 전에 쌓인 변경은 먼저 DB에 내보낸다") + void flushesPendingChanges() { + Template movedToTrash = templateRepository.saveAndFlush(TemplateFixture.template(userId, "/회의")); + movedToTrash.moveToTrash(DELETED_AT); + + assertThat(templateRepository.deleteTrashedByUserId(userId)).isEqualTo(1); + + assertThat(templateRepository.existsById(movedToTrash.getId())).isFalse(); + } + + private void breakContent(Long templateId) { + entityManager.createNativeQuery("update templates set content = 'not-encrypted' where id = :id") + .setParameter("id", templateId) + .executeUpdate(); + entityManager.clear(); + } + + private Long saveActive(Long ownerId, String shortcut) { + return templateRepository.saveAndFlush(TemplateFixture.template(ownerId, shortcut)).getId(); + } + + private Long saveTrashed(Long ownerId, String shortcut) { + return templateRepository.saveAndFlush(TemplateFixture.trashedTemplate(ownerId, shortcut, DELETED_AT)) + .getId(); + } + + private Long saveUser(String email) { + User user = UserFixture.user(email); + entityManager.persist(user); + entityManager.flush(); + return user.getId(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/service/TrashCommandServicePurgeTest.java b/src/test/java/com/sniptext/domain/template/service/TrashCommandServicePurgeTest.java new file mode 100644 index 0000000..8bf77dc --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/service/TrashCommandServicePurgeTest.java @@ -0,0 +1,115 @@ +package com.sniptext.domain.template.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.inOrder; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.sniptext.domain.template.exception.TrashErrorCode; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.service.dto.command.TrashPurgeCommand; +import com.sniptext.domain.template.service.dto.result.TrashEmptyResult; +import com.sniptext.domain.template.service.dto.result.TrashPurgeResult; +import com.sniptext.domain.user.service.UserCommandService; +import com.sniptext.global.exception.BusinessException; + +/** + * 영구 삭제·비우기는 엔티티를 읽지 않고 삭제 쿼리의 지운 행 수만 본다. + * 본인 소유·휴지통 조건은 쿼리에 있어 TrashPurgeRepositoryTest에서 본다. + * 회원 잠금 순서가 지켜지는지(복구와 교착하지 않는지)는 TrashPurgeConcurrencyTest에서 실제 MySQL로 본다. + */ +@ExtendWith(MockitoExtension.class) +class TrashCommandServicePurgeTest { + + private static final Long USER_ID = 1L; + private static final Long TEMPLATE_ID = 18L; + private static final TrashPurgeCommand COMMAND = new TrashPurgeCommand(USER_ID, TEMPLATE_ID); + + @Mock + private TemplateRepository templateRepository; + + @Mock + private UserCommandService userCommandService; + + @Mock + private TemplateCommandService templateCommandService; + + @InjectMocks + private TrashCommandService trashCommandService; + + @Test + @DisplayName("영구 삭제는 엔티티를 읽지 않고 지우며, 1행을 지웠으면 그 ID를 돌려준다") + void purge() { + given(templateRepository.deleteTrashedByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(1); + + TrashPurgeResult result = trashCommandService.purge(COMMAND); + + assertThat(result).isEqualTo(new TrashPurgeResult(TEMPLATE_ID)); + then(templateRepository).should().deleteTrashedByIdAndUserId(TEMPLATE_ID, USER_ID); + then(templateRepository).shouldHaveNoMoreInteractions(); + } + + /** 복구와 같은 순서(회원 행 → 템플릿 행)로 잠가야 교착이 나지 않는다 */ + @Test + @DisplayName("영구 삭제는 회원을 먼저 잠근 뒤 지운다") + void purgeLocksUserFirst() { + given(templateRepository.deleteTrashedByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(1); + + trashCommandService.purge(COMMAND); + + InOrder order = inOrder(userCommandService, templateRepository); + order.verify(userCommandService).lockUser(USER_ID); + order.verify(templateRepository).deleteTrashedByIdAndUserId(TEMPLATE_ID, USER_ID); + } + + @Test + @DisplayName("지운 행이 없으면 TRASH404_1이다") + void purgeNotFound() { + given(templateRepository.deleteTrashedByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(0); + + assertThatThrownBy(() -> trashCommandService.purge(COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND); + } + + @Test + @DisplayName("비우기는 엔티티를 읽지 않고 지우며, 지운 개수를 돌려준다") + void empty() { + given(templateRepository.deleteTrashedByUserId(USER_ID)).willReturn(4); + + TrashEmptyResult result = trashCommandService.empty(USER_ID); + + assertThat(result).isEqualTo(new TrashEmptyResult(4)); + then(templateRepository).should().deleteTrashedByUserId(USER_ID); + then(templateRepository).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("비우기는 회원을 먼저 잠근 뒤 지운다") + void emptyLocksUserFirst() { + trashCommandService.empty(USER_ID); + + InOrder order = inOrder(userCommandService, templateRepository); + order.verify(userCommandService).lockUser(USER_ID); + order.verify(templateRepository).deleteTrashedByUserId(USER_ID); + } + + @Test + @DisplayName("비울 것이 없어도 에러가 아니라 0개다") + void emptyNothing() { + given(templateRepository.deleteTrashedByUserId(USER_ID)).willReturn(0); + + assertThat(trashCommandService.empty(USER_ID)).isEqualTo(new TrashEmptyResult(0)); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/service/TrashPurgeConcurrencyTest.java b/src/test/java/com/sniptext/domain/template/service/TrashPurgeConcurrencyTest.java new file mode 100644 index 0000000..81bfc8d --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/service/TrashPurgeConcurrencyTest.java @@ -0,0 +1,205 @@ +package com.sniptext.domain.template.service; + +import static com.sniptext.domain.template.service.ConcurrentRunner.runAtOnce; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.willAnswer; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.support.TransactionTemplate; + +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.service.dto.command.TrashPurgeCommand; +import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.global.exception.BusinessException; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClockConfiguration; + +/** + * 영구 삭제·비우기가 같은 템플릿의 다른 영구 삭제·복구와 겹쳐도 500 없이 한쪽만 성공하는지 실제 트랜잭션으로 확인한다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 요청들이 커밋되지 않아 서로를 볼 수 없다. 대신 만든 회원을 직접 지운다. + * 테스트 DB 계정은 잠금 대기를 조회할 권한이 없어서, 1초 안에 끝나지 않으면 잠금을 기다리는 것으로 본다. + */ +@Import(MutableClockConfiguration.class) +class TrashPurgeConcurrencyTest extends IntegrationTestSupport { + + private static final String SUCCESS = "SUCCESS"; + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 20, 10, 0); + + @Autowired + private TrashCommandService trashCommandService; + + /** 복구를 템플릿 잠금 조회 뒤·저장 전에 멈춰 세우는 데만 쓴다. 나머지는 실제 동작이다 */ + @MockitoSpyBean + private TemplateCommandService templateCommandService; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + private Long userId; + + @BeforeEach + void setUp() { + userId = userRepository.save(UserFixture.user("purge-concurrency@sniptext.com")).getId(); + } + + // 회원을 지우면 외래키(ON DELETE CASCADE)로 템플릿도 지워진다 + @AfterEach + void tearDown() { + userRepository.deleteById(userId); + } + + @Test + @DisplayName("같은 템플릿 영구 삭제가 동시에 2건이면 1건만 성공하고 1건은 TRASH404_1") + void samePurgeAtOnce() throws Exception { + Long trashedId = saveTrashed("/견적"); + + List results = runAtOnce(() -> purge(trashedId), () -> purge(trashedId)); + + assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TRASH404_1"); + assertThat(templateRepository.existsById(trashedId)).isFalse(); + } + + @Test + @DisplayName("같은 템플릿 복구와 영구 삭제가 동시에 오면 1건만 성공하고 1건은 TRASH404_1") + void restoreAndPurgeAtOnce() throws Exception { + Long trashedId = saveTrashed("/견적"); + + List results = runAtOnce(() -> restore(trashedId), () -> purge(trashedId)); + + assertThat(results).containsOnlyOnce(SUCCESS).contains("TRASH404_1"); + } + + @Test + @DisplayName("영구 삭제가 먼저 행을 지우고 있으면 복구는 기다렸다가 TRASH404_1이고 행은 지워진다") + void purgeBeforeRestore() throws Exception { + Long trashedId = saveTrashed("/견적"); + CountDownLatch deleted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture purge = CompletableFuture.supplyAsync(() -> transactionTemplate.execute(status -> { + String result = purge(trashedId); + deleted.countDown(); + await(release); + return result; + })); + await(deleted); + + CompletableFuture restore = CompletableFuture.supplyAsync(() -> restore(trashedId)); + assertThatThrownBy(() -> restore.get(1, TimeUnit.SECONDS)).isInstanceOf(TimeoutException.class); + release.countDown(); + + assertThat(purge.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(restore.get(10, TimeUnit.SECONDS)).isEqualTo("TRASH404_1"); + assertThat(templateRepository.existsById(trashedId)).isFalse(); + } + + @Test + @DisplayName("복구가 먼저 행을 잡고 있으면 영구 삭제는 기다렸다가 TRASH404_1이고 템플릿은 복구된다") + void restoreBeforePurge() throws Exception { + Long trashedId = saveTrashed("/견적"); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture restore = restoreHoldingLock(trashedId, release); + + CompletableFuture purge = CompletableFuture.supplyAsync(() -> purge(trashedId)); + assertThatThrownBy(() -> purge.get(1, TimeUnit.SECONDS)).isInstanceOf(TimeoutException.class); + release.countDown(); + + assertThat(restore.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(purge.get(10, TimeUnit.SECONDS)).isEqualTo("TRASH404_1"); + assertThat(templateRepository.findById(trashedId).orElseThrow().isInTrash()).isFalse(); + } + + @Test + @DisplayName("복구가 먼저 행을 잡고 있을 때 비우기가 오면 500 없이 둘 다 끝나고, 복구한 템플릿은 남고 나머지 휴지통은 비워진다") + void restoreBeforeEmpty() throws Exception { + Long restoringId = saveTrashed("/견적"); + Long otherTrashedId = saveTrashed("/회의"); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture restore = restoreHoldingLock(restoringId, release); + + CompletableFuture empty = CompletableFuture.supplyAsync(this::empty); + assertThatThrownBy(() -> empty.get(1, TimeUnit.SECONDS)).isInstanceOf(TimeoutException.class); + release.countDown(); + + assertThat(restore.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(empty.get(10, TimeUnit.SECONDS)).isEqualTo("1"); + assertThat(templateRepository.findById(restoringId).orElseThrow().isInTrash()).isFalse(); + assertThat(templateRepository.existsById(otherTrashedId)).isFalse(); + } + + /** 복구를 출발시켜 템플릿 행을 잠근 뒤(단축어 고르기 직전) release가 올 때까지 멈춰 세운다 */ + private CompletableFuture restoreHoldingLock(Long templateId, CountDownLatch release) { + CountDownLatch locked = new CountDownLatch(1); + willAnswer(invocation -> { + locked.countDown(); + await(release); + return invocation.callRealMethod(); + }).given(templateCommandService).assignShortcut(eq(userId), any()); + CompletableFuture restore = CompletableFuture.supplyAsync(() -> restore(templateId)); + await(locked); + return restore; + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("기다리는 신호가 오지 않았다"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private Long saveTrashed(String shortcut) { + return templateRepository.save(TemplateFixture.trashedTemplate(userId, shortcut, DELETED_AT)).getId(); + } + + private String purge(Long templateId) { + try { + trashCommandService.purge(new TrashPurgeCommand(userId, templateId)); + return SUCCESS; + } catch (BusinessException e) { + return e.getResultCode().getCode(); + } + } + + private String restore(Long templateId) { + try { + trashCommandService.restore(new TrashRestoreCommand(userId, templateId)); + return SUCCESS; + } catch (BusinessException e) { + return e.getResultCode().getCode(); + } + } + + /** 지운 개수를 문자열로 돌려준다 */ + private String empty() { + return String.valueOf(trashCommandService.empty(userId).deletedCount()); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java b/src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java index 6b0126a..cb83971 100644 --- a/src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java @@ -123,7 +123,7 @@ void sameShortcutAtOnce() throws Exception { } /** - * 정리 배치·영구 삭제는 회원을 잠그지 않으므로 복구와 줄을 서지 않는다. 지우는 쪽이 행을 먼저 잡고 있으면 + * 정리 배치는 회원을 잠그지 않으므로 복구와 줄을 서지 않는다 (회원을 잠그는 영구 삭제·비우기는 TrashPurgeConcurrencyTest). 지우는 쪽이 행을 먼저 잡고 있으면 * 복구는 그 행을 잠그며 기다렸다가, 삭제가 커밋된 뒤 행이 없다는 것을 보고 TRASH404_1이어야 한다 (저장 단계의 500이 아니라). * 테스트 DB 계정은 잠금 대기를 조회할 권한이 없어서, 1초 안에 끝나지 않으면 행 잠금을 기다리는 것으로 본다. */