From 42ecdd7b0f52c906b81c96f340e0ff5435b1f454 Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Tue, 10 Nov 2026 09:37:39 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=9C=B4=EC=A7=80=ED=86=B5=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5=EC=9C=BC=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EC=A7=80=20=EB=AA=BB=ED=95=98=EA=B2=8C=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sniptext/domain/template/entity/Template.java | 7 +++++++ .../sniptext/domain/template/entity/TemplateTest.java | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/main/java/com/sniptext/domain/template/entity/Template.java b/src/main/java/com/sniptext/domain/template/entity/Template.java index 3d1753c..8f31d71 100644 --- a/src/main/java/com/sniptext/domain/template/entity/Template.java +++ b/src/main/java/com/sniptext/domain/template/entity/Template.java @@ -120,8 +120,15 @@ public void markUsed(LocalDateTime now) { this.lastUsedAt = now; } + /** + * 휴지통으로 옮긴다. 수정·사용 시각은 그대로 둔다. + * 이미 휴지통인 템플릿에 부르면 휴지통 시각을 덮어써 30일 기한이 다시 시작되므로 막는다 (서비스가 먼저 404로 막으니 코드 실수다). + */ public void moveToTrash(LocalDateTime now) { require(now != null, "휴지통 이동 시각이 없다"); + if (isInTrash()) { + throw new IllegalStateException("이미 휴지통에 있는 템플릿은 다시 옮길 수 없다"); + } this.deletedAt = now; } diff --git a/src/test/java/com/sniptext/domain/template/entity/TemplateTest.java b/src/test/java/com/sniptext/domain/template/entity/TemplateTest.java index 4c16b32..0bf321a 100644 --- a/src/test/java/com/sniptext/domain/template/entity/TemplateTest.java +++ b/src/test/java/com/sniptext/domain/template/entity/TemplateTest.java @@ -216,6 +216,17 @@ void nowRequired() { assertThatThrownBy(() -> template.moveToTrash(null)).isInstanceOf(IllegalArgumentException.class); } + @Test + @DisplayName("이미 휴지통에 있으면 IllegalStateException이고 휴지통 시각이 그대로다 (30일 기한이 다시 시작되지 않는다)") + void alreadyInTrash() { + Template template = editedTemplate(); + template.moveToTrash(NOW.plusHours(2)); + + assertThatThrownBy(() -> template.moveToTrash(NOW.plusDays(1))).isInstanceOf(IllegalStateException.class); + + assertThat(template.getDeletedAt()).isEqualTo(NOW.plusHours(2)); + } + } @Nested From 98c42e52c4b1fa9fe4fda16c89302747ca65b63a Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Tue, 10 Nov 2026 09:37:39 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C(=ED=9C=B4=EC=A7=80=ED=86=B5=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99)=20API=20=EA=B5=AC=ED=98=84=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TemplateController.java | 14 + .../controller/TemplateSuccessCode.java | 1 + .../dto/TemplateDeleteResponse.java | 19 ++ .../template/exception/TemplateErrorCode.java | 2 +- .../service/TemplateCommandService.java | 24 ++ .../dto/command/TemplateDeleteCommand.java | 8 + .../dto/result/TemplateDeleteResult.java | 25 ++ .../TemplateDeleteControllerTest.java | 313 ++++++++++++++++++ .../service/TemplateCommandServiceTest.java | 77 +++++ 9 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sniptext/domain/template/controller/dto/TemplateDeleteResponse.java create mode 100644 src/main/java/com/sniptext/domain/template/service/dto/command/TemplateDeleteCommand.java create mode 100644 src/main/java/com/sniptext/domain/template/service/dto/result/TemplateDeleteResult.java create mode 100644 src/test/java/com/sniptext/domain/template/controller/TemplateDeleteControllerTest.java diff --git a/src/main/java/com/sniptext/domain/template/controller/TemplateController.java b/src/main/java/com/sniptext/domain/template/controller/TemplateController.java index db9c5f9..294f170 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateController.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateController.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; @@ -11,6 +12,7 @@ import com.sniptext.domain.template.controller.dto.TemplateCreateRequest; import com.sniptext.domain.template.controller.dto.TemplateCreateResponse; +import com.sniptext.domain.template.controller.dto.TemplateDeleteResponse; import com.sniptext.domain.template.controller.dto.TemplateDetailResponse; import com.sniptext.domain.template.controller.dto.TemplateListResponse; import com.sniptext.domain.template.controller.dto.TemplateUpdateRequest; @@ -19,8 +21,10 @@ import com.sniptext.domain.template.controller.dto.TemplateUsageResponse; import com.sniptext.domain.template.service.TemplateCommandService; import com.sniptext.domain.template.service.TemplateQueryService; +import com.sniptext.domain.template.service.dto.command.TemplateDeleteCommand; import com.sniptext.domain.template.service.dto.query.TemplateDetailQuery; import com.sniptext.domain.template.service.dto.result.TemplateCreateResult; +import com.sniptext.domain.template.service.dto.result.TemplateDeleteResult; import com.sniptext.domain.template.service.dto.result.TemplateDetailResult; import com.sniptext.domain.template.service.dto.result.TemplateListResult; import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; @@ -80,6 +84,16 @@ public ResponseEntity> updateTemplate( return ApiResponse.success(TemplateSuccessCode.TEMPLATE_UPDATED, TemplateUpdateResponse.from(result)); } + @DeleteMapping("/{templateId}") + public ResponseEntity> deleteTemplate( + @LoginUser Long userId, + @PathVariable Long templateId) { + + TemplateDeleteResult result = templateCommandService.delete(new TemplateDeleteCommand(userId, templateId)); + + return ApiResponse.success(TemplateSuccessCode.TEMPLATE_DELETED, TemplateDeleteResponse.from(result)); + } + @PostMapping("/{templateId}/usages") public ResponseEntity> recordUsage( @LoginUser Long userId, diff --git a/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java b/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java index ecef3ad..ce402c7 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java @@ -14,6 +14,7 @@ public enum TemplateSuccessCode implements ResultCode { TEMPLATE_LIST_FOUND(HttpStatus.OK, "TEMPLATE200_1", "템플릿 목록 조회에 성공했습니다."), TEMPLATE_FOUND(HttpStatus.OK, "TEMPLATE200_2", "템플릿 조회에 성공했습니다."), TEMPLATE_UPDATED(HttpStatus.OK, "TEMPLATE200_3", "수정 내용이 저장되었습니다."), + TEMPLATE_DELETED(HttpStatus.OK, "TEMPLATE200_4", "템플릿을 휴지통으로 옮겼습니다."), TEMPLATE_USAGE_RECORDED(HttpStatus.OK, "TEMPLATE200_6", "사용 기록이 저장되었습니다."), TEMPLATE_CREATED(HttpStatus.CREATED, "TEMPLATE201_1", "템플릿이 저장되었습니다."); diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TemplateDeleteResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateDeleteResponse.java new file mode 100644 index 0000000..6e162cd --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateDeleteResponse.java @@ -0,0 +1,19 @@ +package com.sniptext.domain.template.controller.dto; + +import java.time.LocalDateTime; + +import com.sniptext.domain.template.service.dto.result.TemplateDeleteResult; + +/** 템플릿 삭제(휴지통 이동) 결과 (명세 12번). 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */ +public record TemplateDeleteResponse( + Long templateId, + String title, + LocalDateTime deletedAt, + LocalDateTime purgeAt +) { + + public static TemplateDeleteResponse from(TemplateDeleteResult result) { + return new TemplateDeleteResponse(result.templateId(), result.title(), result.deletedAt(), result.purgeAt()); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java b/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java index 6310b7b..848cbe2 100644 --- a/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java +++ b/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java @@ -32,7 +32,7 @@ public enum TemplateErrorCode implements ResultCode { USAGE_CHANNEL_UNSUPPORTED(HttpStatus.BAD_REQUEST, "TEMPLATE400_9", "지원하지 않는 사용 채널이에요."), /** 활성 템플릿 수가 플랜 한도 이상 (화면은 한도 모달) */ TEMPLATE_LIMIT_REACHED(HttpStatus.FORBIDDEN, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."), - /** 없는 템플릿, 다른 회원의 템플릿 (다른 회원의 휴지통 템플릿 포함) */ + /** 없는 템플릿, 다른 회원의 템플릿 (다른 회원의 휴지통 템플릿 포함). 삭제(12번)는 내 휴지통 템플릿도 이 코드다 */ TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "TEMPLATE404_1", "템플릿을 찾을 수 없어요."), /** 내 템플릿인데 휴지통에 있음 */ TEMPLATE_IN_TRASH(HttpStatus.NOT_FOUND, "TEMPLATE404_2", "이미 삭제된 템플릿이에요."), diff --git a/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java b/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java index 229d6d5..affab47 100644 --- a/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java +++ b/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java @@ -20,9 +20,11 @@ import com.sniptext.domain.template.repository.TemplateRepository; import com.sniptext.domain.template.service.dto.command.TemplateCreateCommand; import com.sniptext.domain.template.service.dto.command.TemplateCreateFromGalleryCommand; +import com.sniptext.domain.template.service.dto.command.TemplateDeleteCommand; import com.sniptext.domain.template.service.dto.command.TemplateUpdateCommand; import com.sniptext.domain.template.service.dto.command.TemplateUsageCommand; import com.sniptext.domain.template.service.dto.result.TemplateCreateResult; +import com.sniptext.domain.template.service.dto.result.TemplateDeleteResult; import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; import com.sniptext.domain.template.service.dto.result.TemplateUsageResult; @@ -135,6 +137,28 @@ public TemplateUsageResult recordUsage(TemplateUsageCommand command) { return TemplateUsageResult.from(template); } + /** + * 템플릿을 휴지통으로 옮긴다 (소프트 삭제). deleted_at만 Clock 시각으로 바꾸고 수정·사용 시각은 그대로 둔다. + * 없거나 다른 회원 것이면 TEMPLATE404_1이고, 수정·사용 기록과 달리 내 휴지통 템플릿도 TEMPLATE404_1이다 (명세 12번). + * 개수를 줄이기만 해서 한도 검사는 없지만 회원 행을 먼저 잠근다. 잠그지 않고 일반 조회로 읽으면, 같은 템플릿을 동시에 지울 때 + * 뒤 요청이 커밋 전 스냅샷에서 활성 템플릿을 보고 행 잠금만 기다렸다가 deleted_at을 덮어써 둘 다 200이 되고 30일 기한이 다시 시작된다. + * 복구·영구 삭제·비우기와 같은 순서(회원 행 → 템플릿 행)로 잠가 서로 줄을 서게 한다. 잠금 뒤의 첫 일반 조회라 앞 요청의 커밋을 본다. + * 변경 감지로 쓴다. @DynamicUpdate라 UPDATE에는 deleted_at과 감사 기능이 채우는 updated_at만 들어간다. + * 엔티티를 읽으므로 본문 복호화에 실패하는 행이면 500이다. + */ + @Transactional + public TemplateDeleteResult delete(TemplateDeleteCommand command) { + userCommandService.lockUser(command.userId()); + + Template template = templateRepository.findByIdAndUserId(command.templateId(), command.userId()) + .filter(found -> !found.isInTrash()) + .orElseThrow(() -> new BusinessException(TemplateErrorCode.TEMPLATE_NOT_FOUND)); + + template.moveToTrash(LocalDateTime.now(clock)); + + return TemplateDeleteResult.from(template); + } + private static void requireNoOuterTransaction() { if (TransactionSynchronizationManager.isActualTransactionActive()) { throw new IllegalStateException("갤러리 저장은 호출자 트랜잭션 밖에서 불러야 한다"); diff --git a/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateDeleteCommand.java b/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateDeleteCommand.java new file mode 100644 index 0000000..857349c --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateDeleteCommand.java @@ -0,0 +1,8 @@ +package com.sniptext.domain.template.service.dto.command; + +/** 템플릿 삭제(휴지통 이동) 입력. */ +public record TemplateDeleteCommand( + Long userId, + Long templateId +) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateDeleteResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateDeleteResult.java new file mode 100644 index 0000000..40f1196 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateDeleteResult.java @@ -0,0 +1,25 @@ +package com.sniptext.domain.template.service.dto.result; + +import java.time.LocalDateTime; + +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.support.TrashRetention; + +/** 휴지통으로 옮긴 템플릿. 이름은 삭제 토스트용이다. */ +public record TemplateDeleteResult( + Long templateId, + String title, + LocalDateTime deletedAt, + LocalDateTime purgeAt +) { + + public static TemplateDeleteResult from(Template template) { + return new TemplateDeleteResult( + template.getId(), + template.getTitle().getValue(), + template.getDeletedAt(), + TrashRetention.purgeAt(template.getDeletedAt()) + ); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TemplateDeleteControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TemplateDeleteControllerTest.java new file mode 100644 index 0000000..66d7bca --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TemplateDeleteControllerTest.java @@ -0,0 +1,313 @@ +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.request.MockMvcRequestBuilders.put; +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 java.util.stream.IntStream; + +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.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.global.policy.UsageLimits; +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; + +/** + * 명세 12번. 삭제는 deleted_at만 지금 시각으로 바꿔 휴지통으로 옮긴다 (소프트 삭제). + * 9·11·14번과 달리 내 휴지통 템플릿도 TEMPLATE404_1이다. + * 클래스 트랜잭션 안에서는 서비스의 변경이 커밋되지 않으므로, 감사 기능이 updated_at을 채우도록 시계를 옮기기 전에 내보낸다. + */ +@Transactional +@Import(MutableClockConfiguration.class) +class TemplateDeleteControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/templates/{templateId}"; + private static final String NO_STORE = "no-store"; + private static final LocalDateTime FIXED = LocalDateTime.of(2026, 10, 11, 19, 15); + private static final LocalDateTime USED_AT = FIXED.plusMinutes(10); + private static final LocalDateTime LATER = FIXED.plusHours(1); + private static final String LATER_TEXT = "2026-10-11T20:15:00+09:00"; + /** LATER + 30일. 계산하지 않고 명세 예시처럼 글자로 둔다 */ + private static final String PURGE_AT_TEXT = "2026-11-10T20:15:00+09:00"; + + @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과 명세 순서의 필드를 +09:00 시각으로 돌려주고 캐시하지 않는다. 영구 삭제 예정은 30일 뒤다") + void deleteTemplate() throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + clock.setTo(LATER); + + MvcResult result = deleteAs(userId, template.getId()) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TEMPLATE200_4")) + .andExpect(jsonPath("$.message").value("템플릿을 휴지통으로 옮겼습니다.")) + .andExpect(jsonPath("$.result.templateId").value(template.getId())) + .andExpect(jsonPath("$.result.title").value(TemplateFixture.TITLE)) + .andExpect(jsonPath("$.result.deletedAt").value(LATER_TEXT)) + .andExpect(jsonPath("$.result.purgeAt").value(PURGE_AT_TEXT)) + .andReturn(); + + assertThat(jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .get("result").propertyNames()) + .containsExactly("templateId", "title", "deletedAt", "purgeAt"); + } + + @Test + @DisplayName("deleted_at·updated_at은 지금 시각이 되고 edited_at·last_used_at·created_at·이름·단축어·본문은 그대로, 단축어 점유가 풀린다") + void onlyTrashColumnsChange() throws Exception { + Template template = saveUsedTemplate(userId); + clock.setTo(LATER); + + deleteAs(userId, template.getId()).andExpect(status().isOk()); + + Template found = reload(template); + assertThat(found.getDeletedAt()).isEqualTo(LATER); + assertThat(found.getUpdatedAt()).isEqualTo(LATER); + assertThat(found.getEditedAt()).isEqualTo(FIXED); + assertThat(found.getLastUsedAt()).isEqualTo(USED_AT); + assertThat(found.getCreatedAt()).isEqualTo(FIXED); + assertThat(found.getTitle().getValue()).isEqualTo(TemplateFixture.TITLE); + assertThat(found.getShortcut().getValue()).isEqualTo(TemplateFixture.SHORTCUT); + assertThat(found.getContent().getValue()).isEqualTo(TemplateFixture.CONTENT); + assertThat(found.getActiveShortcutKey()).isNull(); + } + + @Test + @DisplayName("삭제한 템플릿은 휴지통 목록(15번)에 같은 시각으로 나오고 내 템플릿 목록(8번)에서 빠지며 활성 개수가 1 준다") + void movesFromListToTrash() throws Exception { + Template kept = saveTemplate(userId, "/남김"); + Template deleted = saveTemplate(userId, TemplateFixture.SHORTCUT); + clock.setTo(LATER); + + deleteAs(userId, deleted.getId()).andExpect(status().isOk()); + flushAndClear(); + + mockMvc.perform(get("/api/v1/trash/templates").header(HttpHeaders.AUTHORIZATION, bearer(userId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.totalCount").value(1)) + .andExpect(jsonPath("$.result.templates[0].templateId").value(deleted.getId())) + .andExpect(jsonPath("$.result.templates[0].deletedAt").value(LATER_TEXT)) + .andExpect(jsonPath("$.result.templates[0].purgeAt").value(PURGE_AT_TEXT)); + mockMvc.perform(get("/api/v1/templates").header(HttpHeaders.AUTHORIZATION, bearer(userId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.templates[*].templateId").value(contains(kept.getId().intValue()))); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(1); + } + + @Test + @DisplayName("삭제하면 같은 단축어(대소문자만 달라도)로 새 템플릿을 만들 수 있다") + void shortcutReleased() throws Exception { + Template template = saveTemplate(userId, "/Quote"); + clock.setTo(LATER); + + deleteAs(userId, template.getId()).andExpect(status().isOk()); + + createAs(userId, "/quote") + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value("TEMPLATE201_1")); + } + + @Test + @DisplayName("한도에 도달해 저장이 막혀 있어도 하나를 삭제하면 다시 저장할 수 있다 (50 → 49 → 50)") + void limitReleased() throws Exception { + IntStream.rangeClosed(1, UsageLimits.TEMPLATE_LIMIT - 1) + .forEach(number -> templateRepository.save(TemplateFixture.template(userId, "/t" + number))); + Template target = templateRepository.save(TemplateFixture.template(userId, "/대상")); + flushAndClear(); + createAs(userId, "/새것").andExpect(jsonPath("$.code").value("TEMPLATE403_1")); + + deleteAs(userId, target.getId()).andExpect(status().isOk()); + + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(UsageLimits.TEMPLATE_LIMIT - 1); + createAs(userId, "/새것").andExpect(status().isCreated()); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(UsageLimits.TEMPLATE_LIMIT); + } + + @Test + @DisplayName("삭제한 뒤에는 상세(9번)·수정(11번)·사용 기록(14번)이 TEMPLATE404_2") + void otherApisSeeTrash() throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + clock.setTo(LATER); + deleteAs(userId, template.getId()).andExpect(status().isOk()); + flushAndClear(); + + expectInTrash(mockMvc.perform(get(PATH, template.getId()) + .header(HttpHeaders.AUTHORIZATION, bearer(userId)))); + expectInTrash(mockMvc.perform(put(PATH, template.getId()) + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\": \"새 이름\", \"shortcut\": \"/견적\", \"content\": \"본문\"}"))); + expectInTrash(mockMvc.perform(post(PATH + "/usages", template.getId()) + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"channel\": \"DASHBOARD_COPY\"}"))); + } + + @Test + @DisplayName("없는 템플릿이면 TEMPLATE404_1") + void notFound() throws Exception { + expectNotFound(deleteAs(userId, Long.MAX_VALUE)); + } + + @Test + @DisplayName("다른 회원의 템플릿이면 TEMPLATE404_1이고 휴지통으로 옮기지 않는다") + void otherUsersTemplate() throws Exception { + Template template = saveTemplate(otherUserId, TemplateFixture.SHORTCUT); + + expectNotFound(deleteAs(userId, template.getId())); + + assertThat(reload(template).isInTrash()).isFalse(); + } + + @Test + @DisplayName("내 템플릿이 이미 휴지통에 있으면 TEMPLATE404_2가 아니라 TEMPLATE404_1이고 휴지통 시각·변경 시각이 그대로다") + void alreadyInTrash() throws Exception { + Template template = saveTrashed(userId); + clock.setTo(LATER); + + expectNotFound(deleteAs(userId, template.getId())); + + Template found = reload(template); + assertThat(found.getDeletedAt()).isEqualTo(FIXED); + assertThat(found.getUpdatedAt()).isEqualTo(FIXED); + } + + @Test + @DisplayName("다른 회원의 휴지통 템플릿이면 TEMPLATE404_1") + void otherUsersTrashed() throws Exception { + Template template = saveTrashed(otherUserId); + + expectNotFound(deleteAs(userId, template.getId())); + } + + @Test + @DisplayName("templateId가 숫자가 아니면 COMMON400_1") + void nonNumericTemplateId() throws Exception { + expectError(mockMvc.perform(delete("/api/v1/templates/abc") + .header(HttpHeaders.AUTHORIZATION, bearer(userId))), 400, "COMMON400_1", "잘못된 요청입니다."); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1, 캐시 금지") + void unauthenticated() throws Exception { + expectError(mockMvc.perform(delete(PATH, 1L)), 401, "COMMON401_1", "로그인이 필요해요."); + } + + private ResultActions deleteAs(Long userId, Long templateId) throws Exception { + return mockMvc.perform(delete(PATH, templateId).header(HttpHeaders.AUTHORIZATION, bearer(userId))); + } + + private ResultActions createAs(Long userId, String shortcut) throws Exception { + return mockMvc.perform(post("/api/v1/templates") + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\": \"새 견적\", \"shortcut\": \"" + shortcut + "\", \"content\": \"본문\"}")); + } + + private static void expectNotFound(ResultActions actions) throws Exception { + expectError(actions, 404, "TEMPLATE404_1", "템플릿을 찾을 수 없어요."); + } + + private static void expectInTrash(ResultActions actions) throws Exception { + expectError(actions, 404, "TEMPLATE404_2", "이미 삭제된 템플릿이에요."); + } + + 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 Template saveTemplate(Long ownerId, String shortcut) { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(ownerId, shortcut)); + flushAndClear(); + return template; + } + + /** 최근 사용 시각이 USED_AT인 템플릿. 수정·생성·변경 시각은 FIXED다 */ + private Template saveUsedTemplate(Long ownerId) { + Template template = TemplateFixture.template(ownerId); + template.markUsed(USED_AT); + templateRepository.saveAndFlush(template); + flushAndClear(); + return template; + } + + private Template saveTrashed(Long ownerId) { + Template template = templateRepository.saveAndFlush( + TemplateFixture.trashedTemplate(ownerId, TemplateFixture.SHORTCUT, FIXED)); + flushAndClear(); + return template; + } + + /** 쌓인 변경을 내보내고 1차 캐시를 비워 DB 값을 다시 읽는다 */ + private Template reload(Template template) { + flushAndClear(); + return entityManager.find(Template.class, template.getId()); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } + + 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/TemplateCommandServiceTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java index b60b8ec..7f3ab82 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java @@ -13,6 +13,7 @@ import java.time.Clock; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.Set; import org.hibernate.exception.ConstraintViolationException; @@ -42,12 +43,15 @@ import com.sniptext.domain.template.repository.TemplateRepository; import com.sniptext.domain.template.service.dto.command.TemplateCreateCommand; import com.sniptext.domain.template.service.dto.command.TemplateCreateFromGalleryCommand; +import com.sniptext.domain.template.service.dto.command.TemplateDeleteCommand; import com.sniptext.domain.template.service.dto.command.TemplateUpdateCommand; import com.sniptext.domain.template.service.dto.command.TemplateUsageCommand; import com.sniptext.domain.template.service.dto.result.TemplateCreateResult; +import com.sniptext.domain.template.service.dto.result.TemplateDeleteResult; import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; import com.sniptext.domain.template.service.dto.result.TemplateUsageResult; +import com.sniptext.domain.template.support.TrashRetention; import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.fixture.TemplateFixture; import com.sniptext.global.exception.BusinessException; @@ -427,6 +431,79 @@ private void givenUpdateSaved() { } + @Nested + @DisplayName("삭제 (휴지통 이동)") + class Delete { + + private static final TemplateDeleteCommand DELETE_COMMAND = new TemplateDeleteCommand(USER_ID, TEMPLATE_ID); + private static final LocalDateTime EDITED_AT = NOW.minusDays(2); + private static final LocalDateTime LAST_USED_AT = NOW.minusDays(1); + + private Template template; + + @BeforeEach + void setUp() { + template = TemplateFixture.template(USER_ID); + ReflectionTestUtils.setField(template, "id", TEMPLATE_ID); + ReflectionTestUtils.setField(template, "editedAt", EDITED_AT); + template.markUsed(LAST_USED_AT); + } + + @Test + @DisplayName("Clock 시각을 휴지통 시각으로 쓰고 ID·이름·휴지통 시각·영구 삭제 예정 시각을 돌려준다. 수정·사용 시각은 그대로다") + void delete() { + given(templateRepository.findByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(Optional.of(template)); + + TemplateDeleteResult result = templateCommandService.delete(DELETE_COMMAND); + + assertThat(result).isEqualTo( + new TemplateDeleteResult(TEMPLATE_ID, TemplateFixture.TITLE, NOW, TrashRetention.purgeAt(NOW))); + assertThat(template.getDeletedAt()).isEqualTo(NOW); + assertThat(template.getEditedAt()).isEqualTo(EDITED_AT); + assertThat(template.getLastUsedAt()).isEqualTo(LAST_USED_AT); + } + + @Test + @DisplayName("회원 행을 먼저 잠근 뒤 템플릿을 찾고, 한도 개수를 세거나 직접 저장하지 않는다 (변경 감지)") + void locksUserFirst() { + given(templateRepository.findByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(Optional.of(template)); + + templateCommandService.delete(DELETE_COMMAND); + + InOrder order = inOrder(userCommandService, templateRepository); + order.verify(userCommandService).lockUser(USER_ID); + order.verify(templateRepository).findByIdAndUserId(TEMPLATE_ID, USER_ID); + then(templateRepository).shouldHaveNoMoreInteractions(); + then(templateQueryService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("없거나 다른 회원의 템플릿이면 TEMPLATE404_1") + void notFound() { + given(templateRepository.findByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> templateCommandService.delete(DELETE_COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(TemplateErrorCode.TEMPLATE_NOT_FOUND); + } + + @Test + @DisplayName("내 템플릿이 이미 휴지통에 있으면 TEMPLATE404_2가 아니라 TEMPLATE404_1이고 휴지통 시각이 그대로다") + void alreadyInTrash() { + LocalDateTime deletedAt = NOW.minusHours(3); + template.moveToTrash(deletedAt); + given(templateRepository.findByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(Optional.of(template)); + + assertThatThrownBy(() -> templateCommandService.delete(DELETE_COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(TemplateErrorCode.TEMPLATE_NOT_FOUND); + assertThat(template.getDeletedAt()).isEqualTo(deletedAt); + } + + } + @Nested @DisplayName("사용 기록") class RecordUsage { From 6d69494c28627b63ed5e50d91e56743891fcd070 Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Tue, 10 Nov 2026 09:37:39 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=EC=82=AD=EC=A0=9C=EA=B0=80=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=82=AD=EC=A0=9C=C2=B7=EB=B9=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=C2=B7=EB=B3=B5=EA=B5=AC=EC=99=80=20=EA=B2=B9=EC=B3=90?= =?UTF-8?q?=EB=8F=84=20500=20=EC=97=86=EC=9D=B4=20=EB=81=9D=EB=82=A8?= =?UTF-8?q?=EC=9D=84=20=EA=B2=80=EC=A6=9D=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateDeleteConcurrencyTest.java | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java diff --git a/src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java new file mode 100644 index 0000000..9ef4c7f --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java @@ -0,0 +1,283 @@ +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 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 java.util.function.Supplier; + +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.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.service.dto.command.TemplateDeleteCommand; +import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.domain.user.service.UserCommandService; +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.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +/** + * 삭제가 같은 템플릿의 다른 삭제, 같은 회원의 휴지통 비우기·복구와 겹쳐도 500 없이 끝나는지 실제 트랜잭션으로 확인한다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 요청들이 커밋되지 않아 서로를 볼 수 없다. 대신 만든 회원을 직접 지운다. + * "먼저 잡고 있으면" 테스트는 앞 요청을 바깥 트랜잭션으로 감싸 커밋 직전에 멈춰 세운다. + * 테스트 DB 계정은 잠금 대기를 조회할 권한이 없어서, 1초 안에 끝나지 않으면 잠금을 기다리는 것으로 본다. + */ +@Import(MutableClockConfiguration.class) +class TemplateDeleteConcurrencyTest extends IntegrationTestSupport { + + private static final String SUCCESS = "SUCCESS"; + private static final LocalDateTime TRASHED_AT = LocalDateTime.of(2026, 10, 20, 10, 0); + private static final LocalDateTime FIRST = LocalDateTime.of(2026, 10, 25, 10, 0); + private static final LocalDateTime SECOND = FIRST.plusHours(1); + + @Autowired + private TemplateCommandService templateCommandService; + + @Autowired + private TrashCommandService trashCommandService; + + @Autowired + private UserCommandService userCommandService; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private MutableClock clock; + + private Long userId; + + @BeforeEach + void setUp() { + clock.setTo(FIRST); + userId = userRepository.save(UserFixture.user("delete-concurrency@sniptext.com")).getId(); + } + + // 회원을 지우면 외래키(ON DELETE CASCADE)로 템플릿도 지워진다 + @AfterEach + void tearDown() { + userRepository.deleteById(userId); + } + + @Test + @DisplayName("같은 템플릿 삭제가 동시에 2건이면 1건만 성공하고 1건은 TEMPLATE404_1") + void sameTemplateAtOnce() throws Exception { + Long templateId = saveActive("/견적"); + + List results = runAtOnce(() -> delete(templateId), () -> delete(templateId)); + + assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TEMPLATE404_1"); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isZero(); + } + + /** + * 회원을 잠그지 않고 일반 조회로 읽으면, 뒤 요청은 커밋 전 스냅샷에서 활성 템플릿을 보고 UPDATE에서 행 잠금만 기다린다. + * 앞 요청이 커밋되면 그대로 deleted_at을 덮어써 둘 다 200이 되고 30일 기한이 다시 시작된다. + */ + @Test + @DisplayName("앞 삭제가 커밋 전이면 뒤 삭제는 기다렸다가 TEMPLATE404_1이고, 휴지통 시각은 앞 삭제 시각 그대로다") + void deleteBeforeDelete() throws Exception { + Long templateId = saveActive("/견적"); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture first = holding(() -> delete(templateId), release); + clock.setTo(SECOND); + + CompletableFuture second = CompletableFuture.supplyAsync(() -> delete(templateId)); + assertWaiting(second); + release.countDown(); + + assertThat(first.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(second.get(10, TimeUnit.SECONDS)).isEqualTo("TEMPLATE404_1"); + assertThat(find(templateId).getDeletedAt()).isEqualTo(FIRST); + } + + @Test + @DisplayName("삭제가 커밋 전일 때 비우기가 오면 500 없이 기다렸다가, 방금 옮긴 템플릿까지 비운다") + void deleteBeforeEmpty() throws Exception { + Long deletingId = saveActive("/견적"); + Long trashedId = saveTrashed("/회의"); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture delete = holding(() -> delete(deletingId), release); + + CompletableFuture empty = CompletableFuture.supplyAsync(this::emptyTrash); + assertWaiting(empty); + release.countDown(); + + assertThat(delete.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(empty.get(10, TimeUnit.SECONDS)).isEqualTo(2); + assertThat(templateRepository.existsById(deletingId)).isFalse(); + assertThat(templateRepository.existsById(trashedId)).isFalse(); + } + + @Test + @DisplayName("비우기가 커밋 전일 때 삭제가 오면 500 없이 기다렸다가 휴지통으로 옮기고, 비우기는 원래 휴지통만 지운다") + void emptyBeforeDelete() throws Exception { + Long deletingId = saveActive("/견적"); + Long trashedId = saveTrashed("/회의"); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture empty = holding(this::emptyTrash, release); + + CompletableFuture delete = CompletableFuture.supplyAsync(() -> delete(deletingId)); + assertWaiting(delete); + release.countDown(); + + assertThat(empty.get(10, TimeUnit.SECONDS)).isEqualTo(1); + assertThat(delete.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(find(deletingId).isInTrash()).isTrue(); + assertThat(templateRepository.existsById(trashedId)).isFalse(); + } + + @Test + @DisplayName("삭제가 커밋 전일 때 복구가 오면 500 없이 기다렸다가 둘 다 성공하고, 같은 단축어는 번호 없이 복구된다") + void deleteBeforeRestore() throws Exception { + Long deletingId = saveActive("/견적"); + Long restoringId = saveTrashed("/견적"); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture delete = holding(() -> delete(deletingId), release); + + CompletableFuture restore = CompletableFuture.supplyAsync(() -> restore(restoringId)); + assertWaiting(restore); + release.countDown(); + + assertThat(delete.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(restore.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(find(deletingId).isInTrash()).isTrue(); + assertThat(find(restoringId).getShortcut().getValue()).isEqualTo("/견적"); + } + + @Test + @DisplayName("복구가 커밋 전일 때 삭제가 오면 500 없이 기다렸다가 둘 다 성공한다") + void restoreBeforeDelete() throws Exception { + Long deletingId = saveActive("/견적"); + Long restoringId = saveTrashed("/회의"); + CountDownLatch release = new CountDownLatch(1); + CompletableFuture restore = holding(() -> restore(restoringId), release); + + CompletableFuture delete = CompletableFuture.supplyAsync(() -> delete(deletingId)); + assertWaiting(delete); + release.countDown(); + + assertThat(restore.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(delete.get(10, TimeUnit.SECONDS)).isEqualTo(SUCCESS); + assertThat(find(deletingId).isInTrash()).isTrue(); + assertThat(find(restoringId).isInTrash()).isFalse(); + } + + @Test + @DisplayName("삭제·비우기·복구가 같은 순간에 와도 500 없이 모두 끝나고 삭제한 템플릿은 남지 않거나 휴지통에 있다") + void deleteEmptyRestoreAtOnce() throws Exception { + Long deletingId = saveActive("/견적"); + Long restoringId = saveTrashed("/회의"); + saveTrashed("/보고"); + + List results = runAtOnce( + () -> delete(deletingId), + () -> String.valueOf(emptyTrash()), + () -> restore(restoringId)); + + assertThat(results.get(0)).isEqualTo(SUCCESS); + assertThat(results.get(2)).isIn(SUCCESS, "TRASH404_1"); + assertThat(templateRepository.findById(deletingId).map(Template::isInTrash).orElse(true)).isTrue(); + } + + /** + * 작업을 바깥 트랜잭션 안에서 실행하고, 변경을 DB에 내보낸 뒤 커밋하기 전에 release가 올 때까지 멈춰 세운다. + * 삭제는 변경 감지로 쓰므로 내보내지 않으면 UPDATE가 커밋 때 나가 실제 요청의 "SQL 실행 뒤·커밋 전" 틈을 흉내 내지 못한다. + */ + private CompletableFuture holding(Supplier task, CountDownLatch release) { + CountDownLatch done = new CountDownLatch(1); + CompletableFuture future = CompletableFuture.supplyAsync(() -> transactionTemplate.execute(status -> { + T result = task.get(); + templateRepository.flush(); + done.countDown(); + await(release); + return result; + })); + await(done); + return future; + } + + private static void assertWaiting(CompletableFuture future) { + assertThatThrownBy(() -> future.get(1, TimeUnit.SECONDS)).isInstanceOf(TimeoutException.class); + } + + 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 saveActive(String shortcut) { + return templateRepository.save(TemplateFixture.template(userId, shortcut)).getId(); + } + + private Long saveTrashed(String shortcut) { + return templateRepository.save(TemplateFixture.trashedTemplate(userId, shortcut, TRASHED_AT)).getId(); + } + + private Template find(Long templateId) { + return templateRepository.findById(templateId).orElseThrow(); + } + + private String delete(Long templateId) { + try { + templateCommandService.delete(new TemplateDeleteCommand(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(); + } + } + + /** + * 휴지통 비우기(18번, develop의 TrashCommandService.empty)와 같은 잠금 순서·쿼리로 지운 개수를 돌려준다. + * 이 브랜치의 기준(#80)에는 아직 18번이 없어 흉내 낸다. #80이 develop을 받으면 실제 메서드로 바꾼다. + */ + private int emptyTrash() { + return transactionTemplate.execute(status -> { + userCommandService.lockUser(userId); + return jdbcTemplate.update("delete from templates where user_id = ? and deleted_at is not null", userId); + }); + } + +} From e3154a3369f461d7b1b6eb5c1cdd17459f273f65 Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Fri, 13 Nov 2026 00:34:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EC=82=AD=EC=A0=9C=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EB=B9=84=EC=9A=B0=EA=B8=B0=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5=20=EB=B9=84=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TemplateDeleteConcurrencyTest.java | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java index 9ef4c7f..c88febf 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateDeleteConcurrencyTest.java @@ -18,7 +18,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.support.TransactionTemplate; import com.sniptext.domain.template.entity.Template; @@ -26,7 +25,6 @@ import com.sniptext.domain.template.service.dto.command.TemplateDeleteCommand; import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand; import com.sniptext.domain.user.repository.UserRepository; -import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.fixture.TemplateFixture; import com.sniptext.fixture.UserFixture; import com.sniptext.global.exception.BusinessException; @@ -54,9 +52,6 @@ class TemplateDeleteConcurrencyTest extends IntegrationTestSupport { @Autowired private TrashCommandService trashCommandService; - @Autowired - private UserCommandService userCommandService; - @Autowired private TemplateRepository templateRepository; @@ -66,9 +61,6 @@ class TemplateDeleteConcurrencyTest extends IntegrationTestSupport { @Autowired private TransactionTemplate transactionTemplate; - @Autowired - private JdbcTemplate jdbcTemplate; - @Autowired private MutableClock clock; @@ -269,15 +261,9 @@ private String restore(Long templateId) { } } - /** - * 휴지통 비우기(18번, develop의 TrashCommandService.empty)와 같은 잠금 순서·쿼리로 지운 개수를 돌려준다. - * 이 브랜치의 기준(#80)에는 아직 18번이 없어 흉내 낸다. #80이 develop을 받으면 실제 메서드로 바꾼다. - */ + /** 휴지통 비우기(18번)로 지운 개수 */ private int emptyTrash() { - return transactionTemplate.execute(status -> { - userCommandService.lockUser(userId); - return jdbcTemplate.update("delete from templates where user_id = ? and deleted_at is not null", userId); - }); + return trashCommandService.empty(userId).deletedCount(); } }