From 6734ffbe2ebd06695cdcfdb7fb33dd3432cf8a47 Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Thu, 5 Nov 2026 13:01:50 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5=20=EB=B3=B5=EA=B5=AC=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/template/entity/Template.java | 13 ++++ .../domain/template/entity/TemplateTest.java | 63 +++++++++++++++++++ 2 files changed, 76 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 7417626..3d1753c 100644 --- a/src/main/java/com/sniptext/domain/template/entity/Template.java +++ b/src/main/java/com/sniptext/domain/template/entity/Template.java @@ -125,6 +125,19 @@ public void moveToTrash(LocalDateTime now) { this.deletedAt = now; } + /** + * 휴지통에서 꺼낸다. shortcut은 삭제 당시 단축어이거나, 겹쳐서 자동 번호를 붙인 단축어다 (고르는 건 서비스가 한다). + * 수정·사용 시각은 삭제 전 값 그대로 둔다. 휴지통이 아닌 템플릿에 부르면 조회 조건을 빠뜨린 코드 실수다. + */ + public void restore(Shortcut shortcut) { + require(shortcut != null, "단축어가 없다"); + if (!isInTrash()) { + throw new IllegalStateException("휴지통에 없는 템플릿은 복구할 수 없다"); + } + this.shortcut = shortcut; + this.deletedAt = null; + } + public boolean isInTrash() { return deletedAt != null; } 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 7f5d24b..4c16b32 100644 --- a/src/test/java/com/sniptext/domain/template/entity/TemplateTest.java +++ b/src/test/java/com/sniptext/domain/template/entity/TemplateTest.java @@ -218,6 +218,69 @@ void nowRequired() { } + @Nested + @DisplayName("휴지통 복구") + class Restore { + + @Test + @DisplayName("원본 단축어로 복구하면 휴지통 시각만 비고 단축어·수정·사용 시각은 그대로다") + void restoreWithOriginal() { + Template template = trashedTemplate(); + + template.restore(Shortcut.create(TemplateFixture.SHORTCUT)); + + assertThat(template.isInTrash()).isFalse(); + assertThat(template.getDeletedAt()).isNull(); + assertThat(template.getShortcut()).isEqualTo(SHORTCUT); + assertThat(template.getEditedAt()).isEqualTo(NOW); + assertThat(template.getLastUsedAt()).isEqualTo(NOW.plusHours(1)); + } + + @Test + @DisplayName("번호가 붙은 단축어로 복구하면 표시값과 비교 키가 함께 바뀌고 이름·본문은 그대로다") + void restoreWithNumbered() { + Template template = trashedTemplate(); + + template.restore(Shortcut.create("/견적2")); + + assertThat(template.getShortcut().getValue()).isEqualTo("/견적2"); + assertThat(template.getShortcut().getKey()).isEqualTo("/견적2"); + assertThat(template.getTitle()).isEqualTo(TITLE); + assertThat(template.getContent()).isEqualTo(CONTENT); + assertThat(template.getEditedAt()).isEqualTo(NOW); + } + + @Test + @DisplayName("휴지통에 없는 템플릿을 복구하면 IllegalStateException이고 단축어가 바뀌지 않는다") + void notInTrash() { + Template template = editedTemplate(); + + assertThatThrownBy(() -> template.restore(Shortcut.create("/견적2"))) + .isInstanceOf(IllegalStateException.class); + + assertThat(template.getShortcut()).isEqualTo(SHORTCUT); + } + + @Test + @DisplayName("단축어가 없으면 IllegalArgumentException이고 휴지통에 그대로 있다") + void shortcutRequired() { + Template template = trashedTemplate(); + + assertThatThrownBy(() -> template.restore(null)).isInstanceOf(IllegalArgumentException.class); + + assertThat(template.isInTrash()).isTrue(); + } + + /** 수정 시각 NOW, 사용 시각 NOW+1시간, 휴지통 시각 NOW+2시간 */ + private static Template trashedTemplate() { + Template template = editedTemplate(); + template.markUsed(NOW.plusHours(1)); + template.moveToTrash(NOW.plusHours(2)); + return template; + } + + } + /** 수정 시각이 NOW인 템플릿. 같은 값 수정은 무시되므로 다른 이름에서 수정해 만든다 */ private static Template editedTemplate() { Template template = Template.create(USER_ID, TemplateTitle.create("처음 이름"), SHORTCUT, CONTENT); From 6bcda8b1fc959ca7242febfc82c896134b5a8e8b Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Thu, 5 Nov 2026 13:01:50 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=C2=B7=EC=9E=90=EB=8F=99=20=EB=B2=88=ED=98=B8?= =?UTF-8?q?=C2=B7=EC=A0=80=EC=9E=A5=EC=9D=84=20template=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=95=88?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B3=B5=EC=9C=A0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TemplateCommandService.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 b3e81b4..7246821 100644 --- a/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java +++ b/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java @@ -83,9 +83,7 @@ public TemplateShortcutAssignResult createFromGallery(TemplateCreateFromGalleryC validateTemplateLimit(command.userId()); Shortcut original = Shortcut.create(command.shortcut()); - Set usedKeys = templateRepository.findActiveShortcutKeys(command.userId(), original.candidateKeys()); - Shortcut assigned = original.findAvailable(usedKeys) - .orElseThrow(() -> new BusinessException(TemplateErrorCode.SHORTCUT_NUMBER_EXHAUSTED)); + Shortcut assigned = assignShortcut(command.userId(), original); Template template = Template.createFromGallery( command.userId(), @@ -125,12 +123,27 @@ private static void requireNoOuterTransaction() { } } - private void validateTemplateLimit(Long userId) { + /* + * 아래 패키지 범위 메서드 3개는 휴지통 복구(TrashCommandService)가 같이 쓴다. 생성·갤러리 저장과 규칙이 같아야 하기 때문이다. + * 셋 다 트랜잭션을 따로 열지 않으므로, 호출자가 lockUser를 첫 조회로 부른 트랜잭션 안에서 불러야 한다. + * save는 엔티티를 받고 돌려주고, 순서를 지키지 않으면 한도를 넘을 수 있어 template 도메인 안에서만 부른다 + * (패키지 범위라 service 패키지 밖에서는 부를 수 없다). + */ + + /** 활성 템플릿(휴지통 제외)이 한도 이상이면 TEMPLATE403_1 */ + void validateTemplateLimit(Long userId) { if (templateRepository.countByUserIdAndDeletedAtIsNull(userId) >= UsageLimits.TEMPLATE_LIMIT) { throw new BusinessException(TemplateErrorCode.TEMPLATE_LIMIT_REACHED); } } + /** 활성 템플릿과 겹치지 않으면 원본, 겹치면 자동 번호를 붙인 단축어. 2~99가 모두 차면 TEMPLATE409_2 */ + Shortcut assignShortcut(Long userId, Shortcut original) { + Set usedKeys = templateRepository.findActiveShortcutKeys(userId, original.candidateKeys()); + return original.findAvailable(usedKeys) + .orElseThrow(() -> new BusinessException(TemplateErrorCode.SHORTCUT_NUMBER_EXHAUSTED)); + } + private void validateShortcutAvailable(Long userId, Shortcut shortcut) { if (templateRepository.existsByUserIdAndActiveShortcutKey(userId, shortcut.getKey())) { throw new BusinessException(TemplateErrorCode.SHORTCUT_DUPLICATED); @@ -149,7 +162,7 @@ private void validateShortcutAvailable(Long userId, Shortcut shortcut, Long excl * 단축어 유니크가 아닌 무결성 위반은 코드 실수라 그대로 던진다 (500). * 위반 예외 메시지에는 겹친 단축어가 들어 있으므로 원인으로 붙이거나 로그에 남기지 않는다. */ - private Template save(Template template) { + Template save(Template template) { try { return templateRepository.saveAndFlush(template); } catch (DataIntegrityViolationException e) { From bc55b38696bc1040097e1f5e2191273b077f852e Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Thu, 5 Nov 2026 13:01:50 +0900 Subject: [PATCH 03/11] =?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=20=EB=B3=B5=EA=B5=AC=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template/exception/TrashErrorCode.java | 4 +- .../template/service/TrashCommandService.java | 52 +++++ .../dto/command/TrashRestoreCommand.java | 4 + .../exception/TrashErrorCodeTest.java | 8 + .../service/TrashCommandServiceTest.java | 181 ++++++++++++++++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sniptext/domain/template/service/TrashCommandService.java create mode 100644 src/main/java/com/sniptext/domain/template/service/dto/command/TrashRestoreCommand.java create mode 100644 src/test/java/com/sniptext/domain/template/service/TrashCommandServiceTest.java diff --git a/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java b/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java index 2e91d14..2243f6a 100644 --- a/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java +++ b/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java @@ -12,7 +12,9 @@ public enum TrashErrorCode implements ResultCode { /** 커서를 해석할 수 없거나 size가 1~50 밖 */ - INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "TRASH400_1", "잘못된 페이지 요청이에요."); + INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "TRASH400_1", "잘못된 페이지 요청이에요."), + /** 휴지통에 없는 템플릿 (없는 ID, 다른 회원의 템플릿, 이미 복구·영구 삭제됨) */ + TRASHED_TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "TRASH404_1", "휴지통에서 템플릿을 찾을 수 없어요."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/sniptext/domain/template/service/TrashCommandService.java b/src/main/java/com/sniptext/domain/template/service/TrashCommandService.java new file mode 100644 index 0000000..e5d1db8 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/TrashCommandService.java @@ -0,0 +1,52 @@ +package com.sniptext.domain.template.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.template.entity.Shortcut; +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.TrashRestoreCommand; +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; +import com.sniptext.domain.user.service.UserCommandService; +import com.sniptext.global.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrashCommandService { + + private final TemplateRepository templateRepository; + + private final UserCommandService userCommandService; + + private final TemplateCommandService templateCommandService; + + /** + * 휴지통 템플릿을 내 템플릿으로 되돌린다. 한도·자동 번호·저장 규칙은 생성·갤러리 저장과 같다. + * 휴지통에 없으면 TRASH404_1, 한도 도달이면 TEMPLATE403_1, 번호 2~99가 모두 차면 TEMPLATE409_2 (앞의 것이 먼저다). + * 회원 잠금이 트랜잭션의 첫 조회여야 한다. MySQL 기본 격리 수준(REPEATABLE READ)은 첫 일반 조회 시점의 스냅샷을 끝까지 쓰므로, + * 템플릿을 먼저 읽으면 잠금을 기다리는 사이 커밋된 생성·복구를 개수 검사와 단축어 조회가 보지 못한다. + * 같은 템플릿을 동시에 복구해도 잠금으로 줄을 서므로, 뒤에 온 요청은 이미 꺼낸 템플릿을 읽어 TRASH404_1이 된다. + * 엔티티를 읽으므로 본문 복호화에 실패하는 행은 복구도 500이다. + */ + @Transactional + public TemplateShortcutAssignResult restore(TrashRestoreCommand command) { + userCommandService.lockUser(command.userId()); + + Template template = templateRepository.findByIdAndUserId(command.templateId(), command.userId()) + .filter(Template::isInTrash) + .orElseThrow(() -> new BusinessException(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND)); + + templateCommandService.validateTemplateLimit(command.userId()); + + Shortcut original = template.getShortcut(); + template.restore(templateCommandService.assignShortcut(command.userId(), original)); + + return TemplateShortcutAssignResult.of(templateCommandService.save(template), original); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/command/TrashRestoreCommand.java b/src/main/java/com/sniptext/domain/template/service/dto/command/TrashRestoreCommand.java new file mode 100644 index 0000000..682e51a --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/command/TrashRestoreCommand.java @@ -0,0 +1,4 @@ +package com.sniptext.domain.template.service.dto.command; + +public record TrashRestoreCommand(Long userId, Long templateId) { +} diff --git a/src/test/java/com/sniptext/domain/template/exception/TrashErrorCodeTest.java b/src/test/java/com/sniptext/domain/template/exception/TrashErrorCodeTest.java index 77dfa73..0085355 100644 --- a/src/test/java/com/sniptext/domain/template/exception/TrashErrorCodeTest.java +++ b/src/test/java/com/sniptext/domain/template/exception/TrashErrorCodeTest.java @@ -16,4 +16,12 @@ void invalidPageRequest() { assertThat(TrashErrorCode.INVALID_PAGE_REQUEST.getMessage()).isEqualTo("잘못된 페이지 요청이에요."); } + @Test + @DisplayName("휴지통에 없는 템플릿은 명세의 결과 코드와 문구다") + void trashedTemplateNotFound() { + assertThat(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND.getCode()).isEqualTo("TRASH404_1"); + assertThat(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND.getMessage()).isEqualTo("휴지통에서 템플릿을 찾을 수 없어요."); + } + } diff --git a/src/test/java/com/sniptext/domain/template/service/TrashCommandServiceTest.java b/src/test/java/com/sniptext/domain/template/service/TrashCommandServiceTest.java new file mode 100644 index 0000000..68cf741 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/service/TrashCommandServiceTest.java @@ -0,0 +1,181 @@ +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.test.util.ReflectionTestUtils; + +import com.sniptext.domain.template.entity.Shortcut; +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.exception.TemplateErrorCode; +import com.sniptext.domain.template.exception.TrashErrorCode; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand; +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; +import com.sniptext.domain.user.service.UserCommandService; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.global.exception.BusinessException; + +/** 한도 검사·자동 번호·저장 자체는 TemplateCommandServiceTest와 TrashRestoreTest(실제 MySQL)에서 본다 */ +@ExtendWith(MockitoExtension.class) +class TrashCommandServiceTest { + + private static final Long USER_ID = 1L; + private static final Long TEMPLATE_ID = 18L; + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 7, 10, 58); + private static final TrashRestoreCommand COMMAND = new TrashRestoreCommand(USER_ID, TEMPLATE_ID); + private static final Shortcut ORIGINAL = Shortcut.create(TemplateFixture.SHORTCUT); + + @Mock + private TemplateRepository templateRepository; + + @Mock + private UserCommandService userCommandService; + + @Mock + private TemplateCommandService templateCommandService; + + @InjectMocks + private TrashCommandService trashCommandService; + + private Template trashed; + + @BeforeEach + void setUp() { + trashed = TemplateFixture.trashedTemplate(USER_ID, TemplateFixture.SHORTCUT, DELETED_AT); + ReflectionTestUtils.setField(trashed, "id", TEMPLATE_ID); + } + + /** 잠금보다 먼저 일반 조회를 하면 그 스냅샷 때문에 잠금 뒤 개수 검사가 옛 값을 봐서 한도를 넘는다 */ + @Test + @DisplayName("회원 잠금 → 템플릿 조회 → 한도 검사 → 단축어 고르기 → 저장 순서다") + void order() { + givenTrashed(); + givenAssigned(ORIGINAL); + givenSaveSucceeds(); + + trashCommandService.restore(COMMAND); + + InOrder order = inOrder(userCommandService, templateRepository, templateCommandService); + order.verify(userCommandService).lockUser(USER_ID); + order.verify(templateRepository).findByIdAndUserId(TEMPLATE_ID, USER_ID); + order.verify(templateCommandService).validateTemplateLimit(USER_ID); + order.verify(templateCommandService).assignShortcut(USER_ID, ORIGINAL); + order.verify(templateCommandService).save(trashed); + } + + @Test + @DisplayName("겹치지 않으면 삭제 당시 단축어 그대로 휴지통에서 꺼내고 바뀌지 않았다고 알려 준다") + void restoreWithOriginal() { + givenTrashed(); + givenAssigned(ORIGINAL); + givenSaveSucceeds(); + + TemplateShortcutAssignResult result = trashCommandService.restore(COMMAND); + + assertThat(trashed.isInTrash()).isFalse(); + assertThat(result).isEqualTo(new TemplateShortcutAssignResult( + TEMPLATE_ID, TemplateFixture.TITLE, "/견적", "/견적", false)); + } + + @Test + @DisplayName("겹쳐서 번호가 붙으면 번호 단축어로 꺼내고 원래 단축어와 바뀐 사실을 알려 준다") + void restoreWithNumbered() { + givenTrashed(); + givenAssigned(Shortcut.create("/견적2")); + givenSaveSucceeds(); + + TemplateShortcutAssignResult result = trashCommandService.restore(COMMAND); + + assertThat(trashed.getShortcut().getKey()).isEqualTo("/견적2"); + assertThat(result).isEqualTo(new TemplateShortcutAssignResult( + TEMPLATE_ID, TemplateFixture.TITLE, "/견적2", "/견적", true)); + } + + @Test + @DisplayName("없거나 다른 회원의 템플릿이면 TRASH404_1이고 한도 검사·저장을 하지 않는다") + void notFound() { + given(templateRepository.findByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(Optional.empty()); + + assertErrorCode(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND); + + then(templateCommandService).should(never()).validateTemplateLimit(anyLong()); + then(templateCommandService).should(never()).save(any()); + } + + @Test + @DisplayName("휴지통에 없는 내 템플릿이면 TRASH404_1이고 저장하지 않는다") + void notInTrash() { + Template active = TemplateFixture.template(USER_ID); + given(templateRepository.findByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(Optional.of(active)); + + assertErrorCode(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND); + + then(templateCommandService).should(never()).save(any()); + } + + @Test + @DisplayName("한도에 도달했으면 TEMPLATE403_1이고 휴지통에 그대로 둔다") + void limitReached() { + givenTrashed(); + willThrow(new BusinessException(TemplateErrorCode.TEMPLATE_LIMIT_REACHED)) + .given(templateCommandService).validateTemplateLimit(USER_ID); + + assertErrorCode(TemplateErrorCode.TEMPLATE_LIMIT_REACHED); + + assertThat(trashed.isInTrash()).isTrue(); + then(templateCommandService).should(never()).assignShortcut(anyLong(), any()); + then(templateCommandService).should(never()).save(any()); + } + + @Test + @DisplayName("번호 2~99가 모두 차면 TEMPLATE409_2이고 휴지통에 그대로 둔다") + void numbersExhausted() { + givenTrashed(); + given(templateCommandService.assignShortcut(USER_ID, ORIGINAL)) + .willThrow(new BusinessException(TemplateErrorCode.SHORTCUT_NUMBER_EXHAUSTED)); + + assertErrorCode(TemplateErrorCode.SHORTCUT_NUMBER_EXHAUSTED); + + assertThat(trashed.isInTrash()).isTrue(); + then(templateCommandService).should(never()).save(any()); + } + + private void givenTrashed() { + given(templateRepository.findByIdAndUserId(TEMPLATE_ID, USER_ID)).willReturn(Optional.of(trashed)); + } + + private void givenAssigned(Shortcut assigned) { + given(templateCommandService.assignShortcut(USER_ID, ORIGINAL)).willReturn(assigned); + } + + private void givenSaveSucceeds() { + given(templateCommandService.save(trashed)).willReturn(trashed); + } + + private void assertErrorCode(Object resultCode) { + assertThatThrownBy(() -> trashCommandService.restore(COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(resultCode); + } + +} From 17906d6337df86073f5c238dab23ae9168f551dd Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Thu, 5 Nov 2026 13:01:50 +0900 Subject: [PATCH 04/11] =?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=20=EB=B3=B5=EA=B5=AC=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template/controller/TrashController.java | 18 ++ .../template/controller/TrashSuccessCode.java | 3 +- .../controller/dto/TrashRestoreResponse.java | 24 ++ .../controller/TrashRestoreConflictTest.java | 108 +++++++ .../TrashRestoreControllerTest.java | 290 ++++++++++++++++++ 5 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sniptext/domain/template/controller/dto/TrashRestoreResponse.java create mode 100644 src/test/java/com/sniptext/domain/template/controller/TrashRestoreConflictTest.java create mode 100644 src/test/java/com/sniptext/domain/template/controller/TrashRestoreControllerTest.java 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 da97640..6a1ed53 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TrashController.java +++ b/src/main/java/com/sniptext/domain/template/controller/TrashController.java @@ -2,12 +2,17 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.sniptext.domain.template.controller.dto.TrashListResponse; +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.TrashRestoreCommand; import com.sniptext.domain.template.service.dto.query.TrashListQuery; import com.sniptext.global.response.ApiResponse; import com.sniptext.global.security.LoginUser; @@ -26,6 +31,8 @@ public class TrashController { private final TrashQueryService trashQueryService; + private final TrashCommandService trashCommandService; + @GetMapping public ResponseEntity> getTrash( @LoginUser Long userId, @@ -39,4 +46,15 @@ public ResponseEntity> getTrash( return ApiResponse.success(TrashSuccessCode.TRASH_LIST_FOUND, response); } + @PostMapping("/{templateId}/restore") + public ResponseEntity> restore( + @LoginUser Long userId, + @PathVariable Long templateId) { + + TrashRestoreResponse response = TrashRestoreResponse.from( + trashCommandService.restore(new TrashRestoreCommand(userId, templateId))); + + return ApiResponse.success(TrashSuccessCode.TRASH_RESTORED, 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 f6f14a3..3f59043 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java +++ b/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum TrashSuccessCode implements ResultCode { - TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다."); + TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다."), + TRASH_RESTORED(HttpStatus.OK, "TRASH200_2", "템플릿을 복구했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TrashRestoreResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TrashRestoreResponse.java new file mode 100644 index 0000000..104d007 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TrashRestoreResponse.java @@ -0,0 +1,24 @@ +package com.sniptext.domain.template.controller.dto; + +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; + +/** 휴지통 복구 결과 (명세 16번). shortcutChanged면 화면이 자동 번호 안내 토스트를 띄운다 */ +public record TrashRestoreResponse( + Long templateId, + String title, + String shortcut, + String originalShortcut, + boolean shortcutChanged +) { + + public static TrashRestoreResponse from(TemplateShortcutAssignResult result) { + return new TrashRestoreResponse( + result.templateId(), + result.title(), + result.shortcut(), + result.originalShortcut(), + result.shortcutChanged() + ); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TrashRestoreConflictTest.java b/src/test/java/com/sniptext/domain/template/controller/TrashRestoreConflictTest.java new file mode 100644 index 0000000..533016e --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TrashRestoreConflictTest.java @@ -0,0 +1,108 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willReturn; +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.time.LocalDateTime; +import java.util.Set; +import java.util.stream.IntStream; + +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.http.HttpHeaders; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.ResultActions; + +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClockConfiguration; + +/** + * 실제 행만으로는 닿을 수 없는 복구 에러를 조회 하나만 바꿔 재현한다. 나머지 조회와 저장은 실제 MySQL이다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 커밋 단계의 롤백 예외가 드러나지 않는다. 대신 만든 회원을 직접 지운다. + */ +@Import(MutableClockConfiguration.class) +class TrashRestoreConflictTest extends IntegrationTestSupport { + + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 20, 10, 0); + + @MockitoSpyBean + private TemplateRepository templateRepository; + + @Autowired + private UserRepository userRepository; + + private Long userId; + + @BeforeEach + void setUp() { + userId = userRepository.save(UserFixture.user("restore-conflict@sniptext.com")).getId(); + } + + // 회원을 지우면 외래키(ON DELETE CASCADE)로 템플릿도 지워진다 + @AfterEach + void tearDown() { + userRepository.deleteById(userId); + } + + /** 원본과 2~99를 실제 행으로 채우면 활성 99개라 한도에 먼저 걸리므로, 개수 조회만 0으로 바꾼다 */ + @Test + @DisplayName("번호 2~99가 모두 차면 TEMPLATE409_2이고 휴지통에 그대로 있다") + void numbersExhausted() throws Exception { + templateRepository.save(TemplateFixture.template(userId, "/견적")); + IntStream.rangeClosed(2, 99) + .forEach(number -> templateRepository.save(TemplateFixture.template(userId, "/견적" + number))); + Long trashedId = saveTrashed(); + willReturn(0L).given(templateRepository).countByUserIdAndDeletedAtIsNull(userId); + + expectError(restore(trashedId), 409, "TEMPLATE409_2", "사용할 수 있는 단축어 번호가 없어요."); + + assertThat(templateRepository.findById(trashedId).orElseThrow().isInTrash()).isTrue(); + } + + /** 잠금을 잡지 않는 수정 요청이 조회와 저장 사이에 같은 단축어로 바꾼 경우다 */ + @Test + @DisplayName("사용 중인 키 조회를 통과해도 저장에서 유니크 위반이 나면 TEMPLATE409_1이고, 500으로 새지 않으며 휴지통에 그대로 있다") + void uniqueViolationBecomesConflict() throws Exception { + templateRepository.save(TemplateFixture.template(userId, "/견적")); + Long trashedId = saveTrashed(); + willReturn(Set.of()).given(templateRepository).findActiveShortcutKeys(anyLong(), anyCollection()); + + expectError(restore(trashedId), 409, "TEMPLATE409_1", "이미 사용 중인 단축어예요."); + + assertThat(templateRepository.findById(trashedId).orElseThrow().isInTrash()).isTrue(); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(1); + } + + private Long saveTrashed() { + return templateRepository.save(TemplateFixture.trashedTemplate(userId, "/견적", DELETED_AT)).getId(); + } + + private ResultActions restore(Long templateId) throws Exception { + return mockMvc.perform(post("/api/v1/trash/templates/{templateId}/restore", templateId) + .header(HttpHeaders.AUTHORIZATION, bearer(userId))); + } + + 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()); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TrashRestoreControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TrashRestoreControllerTest.java new file mode 100644 index 0000000..48a7801 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TrashRestoreControllerTest.java @@ -0,0 +1,290 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +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.text.Normalizer; +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.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; + +/** + * 명세 16번. 한도·자동 번호·시각 컬럼을 실제 필터·MySQL까지 거쳐 확인한다. + * 번호 소진(TEMPLATE409_2)과 저장 시 유니크 위반은 실제 행으로 닿을 수 없어 TrashRestoreConflictTest에서 본다. + */ +@Transactional +@Import(MutableClockConfiguration.class) +class TrashRestoreControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/trash/templates/{templateId}/restore"; + private static final String NO_STORE = "no-store"; + private static final LocalDateTime FIXED = LocalDateTime.of(2026, 10, 7, 10, 30); + private static final LocalDateTime USED_AT = FIXED.plusMinutes(5); + private static final LocalDateTime DELETED_AT = FIXED.plusMinutes(10); + private static final LocalDateTime LATER = FIXED.plusHours(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과 명세 순서의 필드를 돌려주고, deleted_at만 비우고 updated_at은 지금 시각, 나머지 시각은 그대로다") + void restore() throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId, "/회의")); + template.markUsed(USED_AT); + template.moveToTrash(DELETED_AT); + flushAndClear(); + clock.setTo(LATER); + + MvcResult result = restoreAs(userId, template.getId()) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TRASH200_2")) + .andExpect(jsonPath("$.message").value("템플릿을 복구했습니다.")) + .andExpect(jsonPath("$.result.templateId").value(template.getId())) + .andExpect(jsonPath("$.result.title").value(TemplateFixture.TITLE)) + .andExpect(jsonPath("$.result.shortcut").value("/회의")) + .andExpect(jsonPath("$.result.originalShortcut").value("/회의")) + .andExpect(jsonPath("$.result.shortcutChanged").value(false)) + .andReturn(); + + assertThat(jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .get("result").propertyNames()) + .containsExactly("templateId", "title", "shortcut", "originalShortcut", "shortcutChanged"); + + Template found = reload(template.getId()); + assertThat(found.getDeletedAt()).isNull(); + assertThat(found.getActiveShortcutKey()).isEqualTo("/회의"); + assertThat(found.getUpdatedAt()).isEqualTo(LATER); + assertThat(found.getEditedAt()).isEqualTo(FIXED); + assertThat(found.getLastUsedAt()).isEqualTo(USED_AT); + assertThat(found.getCreatedAt()).isEqualTo(FIXED); + assertThat(found.getContent().getValue()).isEqualTo(TemplateFixture.CONTENT); + } + + @Test + @DisplayName("활성 템플릿과 단축어가 겹치면 /견적 → /견적2로 복구하고 바뀌었다고 알려 준다") + void numbered() throws Exception { + saveActive(userId, "/견적"); + Long trashedId = saveTrashed(userId, "/견적"); + + expectRestored(restoreAs(userId, trashedId), "/견적2", "/견적", true); + + Template found = reload(trashedId); + assertThat(found.getShortcut().getValue()).isEqualTo("/견적2"); + assertThat(found.getShortcut().getKey()).isEqualTo("/견적2"); + assertThat(found.getActiveShortcutKey()).isEqualTo("/견적2"); + } + + @Test + @DisplayName("대소문자만 달라도 겹치므로 번호를 붙이고, 삭제 당시의 대소문자는 그대로 둔다") + void caseInsensitiveConflict() throws Exception { + saveActive(userId, "/Quote"); + Long trashedId = saveTrashed(userId, "/quote"); + + expectRestored(restoreAs(userId, trashedId), "/quote2", "/quote", true); + } + + @Test + @DisplayName("자모 분리형으로 입력한 활성 단축어도 같은 글자라 겹친다") + void decomposedConflict() throws Exception { + saveActive(userId, Normalizer.normalize("/견적", Normalizer.Form.NFD)); + Long trashedId = saveTrashed(userId, "/견적"); + + expectRestored(restoreAs(userId, trashedId), "/견적2", "/견적", true); + } + + @Test + @DisplayName("/ 뒤가 10자를 넘으면 원본을 뒤에서 잘라 맞춘다") + void truncated() throws Exception { + saveActive(userId, "/견적요청서작성안내문"); + Long trashedId = saveTrashed(userId, "/견적요청서작성안내문"); + + expectRestored(restoreAs(userId, trashedId), "/견적요청서작성안내2", "/견적요청서작성안내문", true); + } + + @Test + @DisplayName("휴지통에 남은 같은 단축어는 충돌이 아니라 원본 그대로 복구한다") + void trashedShortcutIsNotConflict() throws Exception { + saveTrashed(userId, "/견적"); + Long trashedId = saveTrashed(userId, "/견적"); + + expectRestored(restoreAs(userId, trashedId), "/견적", "/견적", false); + } + + @Test + @DisplayName("활성 49개에서 복구하면 50개가 된다") + void belowLimit() throws Exception { + saveActiveCount(49); + Long trashedId = saveTrashed(userId, "/견적"); + + restoreAs(userId, trashedId).andExpect(status().isOk()); + + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); + } + + @Test + @DisplayName("활성 50개면 TEMPLATE403_1이고 휴지통에 그대로 있다") + void limitReached() throws Exception { + saveActiveCount(50); + Long trashedId = saveTrashed(userId, "/견적"); + + expectError(restoreAs(userId, trashedId), 403, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."); + + assertThat(reload(trashedId).isInTrash()).isTrue(); + } + + /** 원본과 2~99를 모두 채우면 활성 99개라 한도도 이미 넘는다. 실제 행 그대로 한도가 먼저 걸린다 */ + @Test + @DisplayName("한도와 자동 번호 소진이 겹치면 한도가 먼저다") + void limitBeforeExhausted() throws Exception { + saveActive(userId, "/견적"); + IntStream.rangeClosed(2, 99).forEach(number -> saveActive(userId, "/견적" + number)); + Long trashedId = saveTrashed(userId, "/견적"); + + expectError(restoreAs(userId, trashedId), 403, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."); + } + + @Test + @DisplayName("없는 ID, 다른 회원의 활성·휴지통 템플릿, 휴지통에 없는 내 템플릿은 TRASH404_1이고 아무것도 바뀌지 않는다") + void notFound() 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}) { + expectError(restoreAs(userId, templateId), 404, "TRASH404_1", "휴지통에서 템플릿을 찾을 수 없어요."); + } + + assertThat(reload(othersTrashed).isInTrash()).isTrue(); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(1); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1") + void unauthorized() throws Exception { + Long trashedId = saveTrashed(userId, "/견적"); + + expectError(mockMvc.perform(post(PATH, trashedId)), 401, "COMMON401_1", "로그인이 필요해요."); + } + + @Test + @DisplayName("복구한 템플릿은 휴지통 목록과 개수에서 빠지고 활성 개수에 들어간다") + void leavesTrashList() throws Exception { + Long restoredId = saveTrashed(userId, "/견적"); + Long remainingId = saveTrashed(userId, "/회의"); + + restoreAs(userId, restoredId).andExpect(status().isOk()); + + 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.length()").value(1)) + .andExpect(jsonPath("$.result.templates[0].templateId").value(remainingId)); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(1); + } + + private ResultActions restoreAs(Long userId, Long templateId) throws Exception { + return mockMvc.perform(post(PATH, templateId).header(HttpHeaders.AUTHORIZATION, bearer(userId))); + } + + private static void expectRestored(ResultActions actions, String shortcut, String originalShortcut, + boolean shortcutChanged) throws Exception { + actions.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("TRASH200_2")) + .andExpect(jsonPath("$.result.shortcut").value(shortcut)) + .andExpect(jsonPath("$.result.originalShortcut").value(originalShortcut)) + .andExpect(jsonPath("$.result.shortcutChanged").value(shortcutChanged)); + } + + 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 Long saveActive(Long ownerId, String shortcut) { + Long id = templateRepository.saveAndFlush(TemplateFixture.template(ownerId, shortcut)).getId(); + entityManager.clear(); + return id; + } + + /** 단축어 /t1, /t2 ... 로 활성 템플릿을 채운다 */ + private void saveActiveCount(int count) { + IntStream.rangeClosed(1, count).forEach(number -> saveActive(userId, "/t" + number)); + } + + 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) { + flushAndClear(); + return entityManager.find(Template.class, templateId); + } + + 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(); + } + +} From c6f842e7a6c71ef8edeecc9bb23dd9023595342a Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Thu, 5 Nov 2026 13:01:50 +0900 Subject: [PATCH 05/11] =?UTF-8?q?test:=20=ED=9C=B4=EC=A7=80=ED=86=B5=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateCreateConcurrencyTest.java | 32 +--- .../service/TrashRestoreConcurrencyTest.java | 140 ++++++++++++++++++ .../sniptext/support/ConcurrentRunner.java | 41 +++++ 3 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java create mode 100644 src/test/java/com/sniptext/support/ConcurrentRunner.java diff --git a/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java index fd059e3..51b8781 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateCreateConcurrencyTest.java @@ -1,15 +1,9 @@ package com.sniptext.domain.template.service; +import static com.sniptext.support.ConcurrentRunner.runAtOnce; import static org.assertj.core.api.Assertions.assertThat; -import java.util.ArrayList; import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -157,28 +151,4 @@ private String saveFromGallery(String shortcut) { } } - /** 두 작업을 같은 순간에 출발시키고 결과를 모은다 */ - @SafeVarargs - private static List runAtOnce(Callable... tasks) throws Exception { - ExecutorService executor = Executors.newFixedThreadPool(tasks.length); - CountDownLatch start = new CountDownLatch(1); - try { - List> futures = new ArrayList<>(); - for (Callable task : tasks) { - futures.add(executor.submit(() -> { - start.await(); - return task.call(); - })); - } - start.countDown(); - List results = new ArrayList<>(); - for (Future future : futures) { - results.add(future.get(10, TimeUnit.SECONDS)); - } - return results; - } finally { - executor.shutdownNow(); - } - } - } diff --git a/src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java b/src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java new file mode 100644 index 0000000..582a3fc --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/service/TrashRestoreConcurrencyTest.java @@ -0,0 +1,140 @@ +package com.sniptext.domain.template.service; + +import static com.sniptext.support.ConcurrentRunner.runAtOnce; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +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 com.sniptext.domain.template.entity.Shortcut; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.template.service.dto.command.TemplateCreateCommand; +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; + +/** + * 같은 회원의 복구가 다른 복구·생성과 동시에 와도 한도와 단축어가 지켜지는지 실제 트랜잭션으로 확인한다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 두 요청이 커밋되지 않아 서로를 볼 수 없다. 대신 만든 회원을 직접 지운다. + */ +@Import(MutableClockConfiguration.class) +class TrashRestoreConcurrencyTest 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; + + @Autowired + private TemplateCommandService templateCommandService; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private UserRepository userRepository; + + private Long userId; + + @BeforeEach + void setUp() { + userId = userRepository.save(UserFixture.user("restore-concurrency@sniptext.com")).getId(); + } + + // 회원을 지우면 외래키(ON DELETE CASCADE)로 템플릿도 지워진다 + @AfterEach + void tearDown() { + userRepository.deleteById(userId); + } + + @Test + @DisplayName("49개 상태에서 복구가 동시에 2건이면 1건만 복구되고 1건은 TEMPLATE403_1, 최종 50개") + void limitAtOnce() throws Exception { + saveActiveCount(49); + Long first = saveTrashed("/다섯째"); + Long second = saveTrashed("/여섯째"); + + List results = runAtOnce(() -> restore(first), () -> restore(second)); + + assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TEMPLATE403_1"); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); + } + + @Test + @DisplayName("49개 상태에서 새 템플릿 저장과 복구가 동시에 오면 1건만 성공하고 1건은 TEMPLATE403_1, 최종 50개") + void createAndRestoreLimitAtOnce() throws Exception { + saveActiveCount(49); + Long trashedId = saveTrashed("/다섯째"); + + List results = runAtOnce(() -> create("/여섯째"), () -> restore(trashedId)); + + assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TEMPLATE403_1"); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); + } + + @Test + @DisplayName("같은 템플릿 복구가 동시에 2건이면 1건만 복구되고 1건은 TRASH404_1") + void sameTemplateAtOnce() throws Exception { + Long trashedId = saveTrashed("/견적"); + + List results = runAtOnce(() -> restore(trashedId), () -> restore(trashedId)); + + assertThat(results).containsExactlyInAnyOrder(SUCCESS, "TRASH404_1"); + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(1); + } + + @Test + @DisplayName("같은 단축어를 가진 두 템플릿을 동시에 복구하면 잠금으로 줄을 서서 둘 다 복구되고 하나는 번호가 붙는다") + void sameShortcutAtOnce() throws Exception { + Long first = saveTrashed("/견적"); + Long second = saveTrashed("/견적"); + + List results = runAtOnce(() -> restore(first), () -> restore(second)); + + assertThat(results).containsExactly(SUCCESS, SUCCESS); + assertThat(templateRepository.findActiveShortcutKeys(userId, Shortcut.create("/견적").candidateKeys())) + .containsExactlyInAnyOrder("/견적", "/견적2"); + } + + /** 단축어 /t1, /t2 ... 로 활성 템플릿을 미리 채운다 */ + private void saveActiveCount(int count) { + IntStream.rangeClosed(1, count) + .forEach(number -> templateRepository.save(TemplateFixture.template(userId, "/t" + number))); + } + + private Long saveTrashed(String shortcut) { + return templateRepository.save(TemplateFixture.trashedTemplate(userId, shortcut, DELETED_AT)).getId(); + } + + private String restore(Long templateId) { + try { + trashCommandService.restore(new TrashRestoreCommand(userId, templateId)); + return SUCCESS; + } catch (BusinessException e) { + return e.getResultCode().getCode(); + } + } + + private String create(String shortcut) { + try { + templateCommandService.create(new TemplateCreateCommand(userId, "견적 안내", shortcut, "본문")); + return SUCCESS; + } catch (BusinessException e) { + return e.getResultCode().getCode(); + } + } + +} diff --git a/src/test/java/com/sniptext/support/ConcurrentRunner.java b/src/test/java/com/sniptext/support/ConcurrentRunner.java new file mode 100644 index 0000000..1887113 --- /dev/null +++ b/src/test/java/com/sniptext/support/ConcurrentRunner.java @@ -0,0 +1,41 @@ +package com.sniptext.support; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** 동시성 테스트용. 여러 작업을 같은 순간에 출발시키고 넘긴 순서대로 결과를 모은다 */ +public final class ConcurrentRunner { + + private ConcurrentRunner() { + } + + @SafeVarargs + public static List runAtOnce(Callable... tasks) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(tasks.length); + CountDownLatch start = new CountDownLatch(1); + try { + List> futures = new ArrayList<>(); + for (Callable task : tasks) { + futures.add(executor.submit(() -> { + start.await(); + return task.call(); + })); + } + start.countDown(); + List results = new ArrayList<>(); + for (Future future : futures) { + results.add(future.get(10, TimeUnit.SECONDS)); + } + return results; + } finally { + executor.shutdownNow(); + } + } + +} From 5be43852017cf7046bd1df9356bf69fa7914cf69 Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Thu, 5 Nov 2026 13:26:49 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=EB=B3=B5=EA=B5=AC=ED=95=A0=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=EC=9D=84=20=EC=9E=A0=EA=B0=80=20?= =?UTF-8?q?=EC=9D=BD=EC=96=B4=20=EB=8F=99=EC=8B=9C=20=EC=98=81=EA=B5=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20500=20=EB=8C=80=EC=8B=A0=20TRA?= =?UTF-8?q?SH404=5F1=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TemplateRepository.java | 12 +++++ .../template/service/TrashCommandService.java | 5 +- .../TrashTemplateRepositoryTest.java | 13 +++++ .../service/TrashCommandServiceTest.java | 26 ++++------ .../service/TrashRestoreConcurrencyTest.java | 51 +++++++++++++++++++ 5 files changed, 88 insertions(+), 19 deletions(-) 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 583a079..62c417b 100644 --- a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java +++ b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java @@ -8,17 +8,29 @@ 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.Query; import org.springframework.data.repository.query.Param; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.repository.dto.TrashTemplateRow; +import jakarta.persistence.LockModeType; + public interface TemplateRepository extends JpaRepository { /** 본인 소유 조회. 휴지통 템플릿도 찾으므로 내 휴지통과 다른 회원 것을 호출자가 구분할 수 있다 */ Optional