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 30ef031..66b4497 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateController.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateController.java @@ -4,6 +4,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -11,11 +12,14 @@ import com.sniptext.domain.template.controller.dto.TemplateCreateRequest; import com.sniptext.domain.template.controller.dto.TemplateCreateResponse; import com.sniptext.domain.template.controller.dto.TemplateDetailResponse; +import com.sniptext.domain.template.controller.dto.TemplateUpdateRequest; +import com.sniptext.domain.template.controller.dto.TemplateUpdateResponse; import com.sniptext.domain.template.service.TemplateCommandService; import com.sniptext.domain.template.service.TemplateQueryService; 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.TemplateDetailResult; +import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; import com.sniptext.global.response.ApiResponse; import com.sniptext.global.security.LoginUser; @@ -53,4 +57,15 @@ public ResponseEntity> createTemplate( return ApiResponse.success(TemplateSuccessCode.TEMPLATE_CREATED, TemplateCreateResponse.from(result)); } + @PutMapping("/{templateId}") + public ResponseEntity> updateTemplate( + @LoginUser Long userId, + @PathVariable Long templateId, + @Valid @RequestBody TemplateUpdateRequest request) { + + TemplateUpdateResult result = templateCommandService.update(request.toCommand(userId, templateId)); + + return ApiResponse.success(TemplateSuccessCode.TEMPLATE_UPDATED, TemplateUpdateResponse.from(result)); + } + } 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 2b38d7a..02844c9 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java @@ -12,6 +12,7 @@ public enum TemplateSuccessCode implements ResultCode { TEMPLATE_FOUND(HttpStatus.OK, "TEMPLATE200_2", "템플릿 조회에 성공했습니다."), + TEMPLATE_UPDATED(HttpStatus.OK, "TEMPLATE200_3", "수정 내용이 저장되었습니다."), TEMPLATE_CREATED(HttpStatus.CREATED, "TEMPLATE201_1", "템플릿이 저장되었습니다."); private final HttpStatus httpStatus; diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUpdateRequest.java b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUpdateRequest.java new file mode 100644 index 0000000..10e74df --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUpdateRequest.java @@ -0,0 +1,23 @@ +package com.sniptext.domain.template.controller.dto; + +import com.sniptext.domain.template.annotation.ValidShortcut; +import com.sniptext.domain.template.annotation.ValidTemplateContent; +import com.sniptext.domain.template.annotation.ValidTemplateTitle; +import com.sniptext.domain.template.service.dto.command.TemplateUpdateCommand; + +/** + * 템플릿 수정 (명세 11번). 세 필드를 모두 보내는 전체 수정이다. + * 여러 개가 틀리면 선언 순서상 첫 에러 하나만 내려주므로 이름 → 단축어 → 내용 순서를 지킨다. + * 공백·길이 기준이 화면과 같도록 @NotBlank·@Size 대신 값 객체 규칙을 쓰는 검증만 붙인다. + */ +public record TemplateUpdateRequest( + @ValidTemplateTitle String title, + @ValidShortcut String shortcut, + @ValidTemplateContent String content +) { + + public TemplateUpdateCommand toCommand(Long userId, Long templateId) { + return new TemplateUpdateCommand(userId, templateId, title, shortcut, content); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUpdateResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUpdateResponse.java new file mode 100644 index 0000000..3fe66be --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUpdateResponse.java @@ -0,0 +1,33 @@ +package com.sniptext.domain.template.controller.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; + +/** 템플릿 수정 결과 (명세 11번). 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */ +public record TemplateUpdateResponse( + Long templateId, + String title, + String shortcut, + String content, + List variables, + LocalDateTime lastUsedAt, + LocalDateTime createdAt, + LocalDateTime editedAt +) { + + public static TemplateUpdateResponse from(TemplateUpdateResult result) { + return new TemplateUpdateResponse( + result.templateId(), + result.title(), + result.shortcut(), + result.content(), + result.variables(), + result.lastUsedAt(), + result.createdAt(), + result.editedAt() + ); + } + +} 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 c5bf4f8..7417626 100644 --- a/src/main/java/com/sniptext/domain/template/entity/Template.java +++ b/src/main/java/com/sniptext/domain/template/entity/Template.java @@ -2,6 +2,8 @@ import java.time.LocalDateTime; +import org.hibernate.annotations.DynamicUpdate; + import com.sniptext.global.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -22,9 +24,11 @@ * edited_at은 이름·단축어·본문 수정, last_used_at은 사용 기록, deleted_at은 휴지통 이동에서만 바뀐다. * updated_at은 모든 변경에서 감사 기능이 기록한다 (확장 동기화 기준). * 이름·단축어·본문의 규칙은 각 값 객체가 갖고, 여기서는 필수 값만 검사한다. + * UPDATE는 바뀐 컬럼만 쓴다. 전체 컬럼을 쓰면 수정 요청이 읽은 뒤 기록된 휴지통 이동·사용 시각을 옛 값으로 되돌린다. */ @Getter @Entity +@DynamicUpdate @Table(name = "templates") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Template extends BaseTimeEntity { 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 0000b76..583a079 100644 --- a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java +++ b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java @@ -28,6 +28,9 @@ public interface TemplateRepository extends JpaRepository { */ boolean existsByUserIdAndActiveShortcutKey(Long userId, String activeShortcutKey); + /** existsByUserIdAndActiveShortcutKey에서 한 템플릿을 뺀 것. 자기 자신의 단축어는 중복이 아니므로 수정이 쓴다 */ + boolean existsByUserIdAndActiveShortcutKeyAndIdNot(Long userId, String activeShortcutKey, Long id); + /** * 넘긴 비교 키 중 활성 템플릿(휴지통 제외)이 이미 쓰는 키. 자동 번호 후보를 한 번에 확인할 때 쓴다. * existsByUserIdAndActiveShortcutKey와 같이 생성 컬럼이라 휴지통 조건이 따로 없고, 유니크 인덱스를 그대로 탄다 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 015de77..b3e81b4 100644 --- a/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java +++ b/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java @@ -1,5 +1,7 @@ package com.sniptext.domain.template.service; +import java.time.Clock; +import java.time.LocalDateTime; import java.util.Set; import org.hibernate.exception.ConstraintViolationException; @@ -18,8 +20,10 @@ 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.TemplateUpdateCommand; import com.sniptext.domain.template.service.dto.result.TemplateCreateResult; import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; +import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.global.exception.BusinessException; import com.sniptext.global.policy.UsageLimits; @@ -36,7 +40,10 @@ public class TemplateCommandService { private final UserCommandService userCommandService; + private final TemplateQueryService templateQueryService; + private final TransactionTemplate transactionTemplate; + private final Clock clock; /** * 새 템플릿 저장. 회원 행을 먼저 잠가 같은 회원의 생성·복구·갤러리 저장을 한 줄로 세운 뒤 한도를 검사한다. @@ -91,6 +98,27 @@ public TemplateShortcutAssignResult createFromGallery(TemplateCreateFromGalleryC }); } + /** + * 템플릿 수정. 없거나 다른 회원 것이면 TEMPLATE404_1, 휴지통에 있으면 TEMPLATE404_2, 내 다른 활성 템플릿과 단축어가 겹치면 TEMPLATE409_1. + * 개수가 바뀌지 않으므로 회원을 잠그지 않고, 동시에 고치면 나중 저장이 덮어쓴다. + * 조회 뒤 저장 전에 휴지통으로 옮겨지면 TEMPLATE404_2 대신 200이고 수정은 휴지통 행에 남는다. + * 휴지통은 단축어를 점유하지 않아 충돌이 없고 복구하면 고친 내용이 남는 정도라 허용한다. + * 막아야 하면 수정용 조회만 FOR UPDATE로 잠근다. 변경 감지는 그대로 두고, 휴지통 이동이 먼저 끝난 경우 TEMPLATE404_2를 낼 수 있다. + */ + @Transactional + public TemplateUpdateResult update(TemplateUpdateCommand command) { + Template template = templateQueryService.getActiveTemplate(command.userId(), command.templateId()); + + TemplateTitle title = TemplateTitle.create(command.title()); + Shortcut shortcut = Shortcut.create(command.shortcut()); + validateShortcutAvailable(command.userId(), shortcut, template.getId()); + TemplateContent content = TemplateContent.create(command.content()); + + template.edit(title, shortcut, content, LocalDateTime.now(clock)); + + return TemplateUpdateResult.from(save(template)); + } + private static void requireNoOuterTransaction() { if (TransactionSynchronizationManager.isActualTransactionActive()) { throw new IllegalStateException("갤러리 저장은 호출자 트랜잭션 밖에서 불러야 한다"); @@ -109,6 +137,12 @@ private void validateShortcutAvailable(Long userId, Shortcut shortcut) { } } + private void validateShortcutAvailable(Long userId, Shortcut shortcut, Long excludeTemplateId) { + if (templateRepository.existsByUserIdAndActiveShortcutKeyAndIdNot(userId, shortcut.getKey(), excludeTemplateId)) { + throw new BusinessException(TemplateErrorCode.SHORTCUT_DUPLICATED); + } + } + /** * 미리 조회를 통과해도 유니크 제약 위반은 날 수 있다. 회원 잠금은 생성·복구·갤러리 저장끼리만 줄을 세우므로, * 잠금을 잡지 않는 수정 요청이 같은 단축어로 바꾸면 조회와 저장 사이에 끼어든다. 그 경우도 TEMPLATE409_1로 바꾼다. diff --git a/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateUpdateCommand.java b/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateUpdateCommand.java new file mode 100644 index 0000000..3f20c59 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateUpdateCommand.java @@ -0,0 +1,11 @@ +package com.sniptext.domain.template.service.dto.command; + +/** 템플릿 수정 입력. 값은 요청 단계에서 형식 검증을 마친 입력 그대로이고, 정규화는 값 객체가 한다. */ +public record TemplateUpdateCommand( + Long userId, + Long templateId, + String title, + String shortcut, + String content +) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateUpdateResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateUpdateResult.java new file mode 100644 index 0000000..42f6b33 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateUpdateResult.java @@ -0,0 +1,35 @@ +package com.sniptext.domain.template.service.dto.result; + +import java.time.LocalDateTime; +import java.util.List; + +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.support.TemplateVariableExtractor; + +/** 수정한 템플릿. 변수 목록은 저장하지 않고 본문에서 뽑는다. */ +public record TemplateUpdateResult( + Long templateId, + String title, + String shortcut, + String content, + List variables, + LocalDateTime lastUsedAt, + LocalDateTime createdAt, + LocalDateTime editedAt +) { + + public static TemplateUpdateResult from(Template template) { + String content = template.getContent().getValue(); + return new TemplateUpdateResult( + template.getId(), + template.getTitle().getValue(), + template.getShortcut().getValue(), + content, + TemplateVariableExtractor.extract(content), + template.getLastUsedAt(), + template.getCreatedAt(), + template.getEditedAt() + ); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TemplateUpdateControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TemplateUpdateControllerTest.java new file mode 100644 index 0000000..496334e --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TemplateUpdateControllerTest.java @@ -0,0 +1,349 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.junit.jupiter.params.provider.Arguments.arguments; +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.text.Normalizer; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.user.entity.User; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import jakarta.persistence.EntityManager; +import tools.jackson.databind.json.JsonMapper; + +/** 명세 11번. 수정 규칙과 시각 컬럼을 실제 필터·MySQL까지 거쳐 확인한다. */ +@Transactional +@Import(MutableClockConfiguration.class) +class TemplateUpdateControllerTest 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, 7, 10, 30); + private static final LocalDateTime LATER = FIXED.plusHours(1); + private static final String LATER_TEXT = "2026-10-07T11:30:00+09:00"; + private static final String FIXED_TEXT = "2026-10-07T10:30:00+09:00"; + + private static final String TITLE = "프로젝트 견적 안내 (수정)"; + private static final String SHORTCUT = "/새견적"; + private static final String CONTENT = "{{금액}}원입니다.\n{{고객명}}님, {{금액}}원을 확인해주세요."; + + @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과 명세 순서의 필드를 돌려주고 DB에 저장한다. edited_at·updated_at은 지금 시각, last_used_at은 그대로다") + void update() throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + template.markUsed(FIXED.plusMinutes(5)); + flushAndClear(); + clock.setTo(LATER); + + MvcResult result = putAs(userId, template.getId(), TITLE, SHORTCUT, CONTENT) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TEMPLATE200_3")) + .andExpect(jsonPath("$.message").value("수정 내용이 저장되었습니다.")) + .andExpect(jsonPath("$.result.templateId").value(template.getId())) + .andExpect(jsonPath("$.result.title").value(TITLE)) + .andExpect(jsonPath("$.result.shortcut").value(SHORTCUT)) + .andExpect(jsonPath("$.result.content").value(CONTENT)) + .andExpect(jsonPath("$.result.variables", contains("금액", "고객명"))) + .andExpect(jsonPath("$.result.lastUsedAt").value("2026-10-07T10:35:00+09:00")) + .andExpect(jsonPath("$.result.createdAt").value(FIXED_TEXT)) + .andExpect(jsonPath("$.result.editedAt").value(LATER_TEXT)) + .andReturn(); + + assertThat(jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .get("result").propertyNames()) + .containsExactly("templateId", "title", "shortcut", "content", "variables", "lastUsedAt", "createdAt", + "editedAt"); + + Template found = reload(template); + assertThat(found.getTitle().getValue()).isEqualTo(TITLE); + assertThat(found.getShortcut().getValue()).isEqualTo(SHORTCUT); + assertThat(found.getContent().getValue()).isEqualTo(CONTENT); + assertThat(found.getEditedAt()).isEqualTo(LATER); + assertThat(found.getUpdatedAt()).isEqualTo(LATER); + assertThat(found.getLastUsedAt()).isEqualTo(FIXED.plusMinutes(5)); + assertThat(found.getCreatedAt()).isEqualTo(FIXED); + } + + @Test + @DisplayName("정리한 뒤 세 값이 기존과 같으면 UPDATE가 나가지 않아 edited_at·updated_at·암호문이 그대로다") + void sameValuesChangeNothing() throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + String storedBefore = storedContent(template.getId()); + entityManager.clear(); + clock.setTo(LATER); + + putAs(userId, template.getId(), " " + TemplateFixture.TITLE + " ", + Normalizer.normalize(TemplateFixture.SHORTCUT, Normalizer.Form.NFD), + TemplateFixture.CONTENT.replace("\n", "\r\n")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("TEMPLATE200_3")) + .andExpect(jsonPath("$.result.editedAt").value(FIXED_TEXT)); + + Template found = reload(template); + assertThat(found.getEditedAt()).isEqualTo(FIXED); + assertThat(found.getUpdatedAt()).isEqualTo(FIXED); + assertThat(storedContent(template.getId())).isEqualTo(storedBefore); + } + + @Test + @DisplayName("단축어 대소문자만 바꿔도 수정이고, 비교 키는 그대로다") + void caseOnlyShortcutChange() throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId, "/Quote")); + flushAndClear(); + clock.setTo(LATER); + + putAs(userId, template.getId(), TemplateFixture.TITLE, "/quote", TemplateFixture.CONTENT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.shortcut").value("/quote")) + .andExpect(jsonPath("$.result.editedAt").value(LATER_TEXT)); + + Template found = reload(template); + assertThat(found.getShortcut().getValue()).isEqualTo("/quote"); + assertThat(found.getShortcut().getKey()).isEqualTo("/quote"); + assertThat(found.getEditedAt()).isEqualTo(LATER); + } + + @Test + @DisplayName("자기 자신의 단축어를 그대로 두고 이름만 바꾸면 중복이 아니다") + void ownShortcutIsNotDuplicate() throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + flushAndClear(); + + putAs(userId, template.getId(), TITLE, TemplateFixture.SHORTCUT, TemplateFixture.CONTENT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.title").value(TITLE)); + } + + @ParameterizedTest + @MethodSource("duplicateShortcuts") + @DisplayName("내 다른 활성 템플릿과 비교 키가 같으면 TEMPLATE409_1이고 아무것도 바뀌지 않는다 (자모 분리형·대소문자 포함)") + void duplicateOfOtherActiveTemplate(String shortcut) throws Exception { + templateRepository.saveAndFlush(TemplateFixture.template(userId, "/견적")); + templateRepository.saveAndFlush(TemplateFixture.template(userId, "/Quote")); + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId, "/내단축어")); + flushAndClear(); + + expectError(putAs(userId, template.getId(), TITLE, shortcut, CONTENT), 409, "TEMPLATE409_1", "이미 사용 중인 단축어예요."); + + Template found = reload(template); + assertThat(found.getTitle().getValue()).isEqualTo(TemplateFixture.TITLE); + assertThat(found.getShortcut().getValue()).isEqualTo("/내단축어"); + } + + private static Stream duplicateShortcuts() { + return Stream.of("/견적", Normalizer.normalize("/견적", Normalizer.Form.NFD), "/QUOTE", "/quote"); + } + + @Test + @DisplayName("다른 회원이 쓰는 단축어는 중복이 아니다") + void otherUsersShortcutIsNotDuplicate() throws Exception { + templateRepository.saveAndFlush(TemplateFixture.template(otherUserId, SHORTCUT)); + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + flushAndClear(); + + putAs(userId, template.getId(), TITLE, SHORTCUT, CONTENT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.shortcut").value(SHORTCUT)); + } + + @Test + @DisplayName("휴지통 템플릿이 쓰던 단축어로 바꿀 수 있다") + void shortcutOfTrashedTemplateIsAvailable() throws Exception { + Template trashed = templateRepository.saveAndFlush(TemplateFixture.template(userId, SHORTCUT)); + trashed.moveToTrash(FIXED); + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + flushAndClear(); + + putAs(userId, template.getId(), TITLE, SHORTCUT, CONTENT) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.shortcut").value(SHORTCUT)); + } + + @Test + @DisplayName("수정한 본문도 DB에는 암호문으로 저장한다") + void editedContentStaysEncrypted() throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + flushAndClear(); + + putAs(userId, template.getId(), TITLE, SHORTCUT, CONTENT).andExpect(status().isOk()); + flushAndClear(); + + assertThat(storedContent(template.getId())).startsWith("v1:").doesNotContain("고객명"); + } + + @Test + @DisplayName("없는 템플릿이면 TEMPLATE404_1") + void notFound() throws Exception { + expectNotFound(putAs(userId, Long.MAX_VALUE, TITLE, SHORTCUT, CONTENT)); + } + + @Test + @DisplayName("다른 회원의 템플릿이면 TEMPLATE404_1이고 바뀌지 않는다") + void otherUsersTemplate() throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(otherUserId)); + flushAndClear(); + + expectNotFound(putAs(userId, template.getId(), TITLE, SHORTCUT, CONTENT)); + + assertThat(reload(template).getTitle().getValue()).isEqualTo(TemplateFixture.TITLE); + } + + @Test + @DisplayName("내 템플릿이 휴지통에 있으면 TEMPLATE404_2") + void trashed() throws Exception { + Template template = saveTrashed(userId); + + expectError(putAs(userId, template.getId(), TITLE, SHORTCUT, CONTENT), 404, "TEMPLATE404_2", "이미 삭제된 템플릿이에요."); + } + + @Test + @DisplayName("다른 회원의 휴지통 템플릿이면 TEMPLATE404_1이다 (존재 여부를 노출하지 않는다)") + void otherUsersTrashed() throws Exception { + Template template = saveTrashed(otherUserId); + + expectNotFound(putAs(userId, template.getId(), TITLE, SHORTCUT, CONTENT)); + } + + @ParameterizedTest + @MethodSource("invalidBodies") + @DisplayName("형식이 틀리면 명세의 에러를 돌려주고, 여러 개가 틀리면 이름 → 단축어 → 내용 순서로 첫 에러만 내려준다") + void invalidBody(String title, String shortcut, String content, String code, String message) throws Exception { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + flushAndClear(); + + expectError(putAs(userId, template.getId(), title, shortcut, content), 400, code, message); + + assertThat(reload(template).getTitle().getValue()).isEqualTo(TemplateFixture.TITLE); + } + + private static Stream invalidBodies() { + return Stream.of( + arguments(" ", "/", " ", "TEMPLATE400_1", "템플릿 이름을 입력해주세요."), + arguments("가".repeat(51), SHORTCUT, CONTENT, "TEMPLATE400_2", "템플릿 이름은 50자까지 입력할 수 있어요."), + arguments(TITLE, "/", " ", "TEMPLATE400_3", "호출 단축어를 입력해주세요."), + arguments(TITLE, "/가", CONTENT, "TEMPLATE400_4", "한글·영문·숫자 2~10자로 입력해주세요."), + arguments(TITLE, "/톤교정", CONTENT, "TEMPLATE400_5", "사용할 수 없는 단축어예요."), + arguments(TITLE, SHORTCUT, " ", "TEMPLATE400_6", "템플릿 내용을 입력해주세요."), + arguments(TITLE, SHORTCUT, "가".repeat(3001), "TEMPLATE400_7", "템플릿 내용은 3,000자까지 입력할 수 있어요.") + ); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1, 캐시 금지") + void unauthenticated() throws Exception { + expectError(mockMvc.perform(put(PATH, 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(body(TITLE, SHORTCUT, CONTENT))), 401, "COMMON401_1", "로그인이 필요해요."); + } + + private ResultActions putAs(Long userId, Long templateId, String title, String shortcut, String content) + throws Exception { + return mockMvc.perform(put(PATH, templateId) + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content(body(title, shortcut, content))); + } + + private String body(String title, String shortcut, String content) { + return jsonMapper.writeValueAsString(Map.of("title", title, "shortcut", shortcut, "content", content)); + } + + private static void expectNotFound(ResultActions actions) throws Exception { + expectError(actions, 404, "TEMPLATE404_1", "템플릿을 찾을 수 없어요."); + } + + private static void expectError(ResultActions actions, int status, String code, String message) throws Exception { + actions.andExpect(status().is(status)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(code)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.result").isEmpty()); + } + + private Template saveTrashed(Long ownerId) { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(ownerId)); + template.moveToTrash(FIXED); + flushAndClear(); + return template; + } + + private String storedContent(Long templateId) { + return (String)entityManager.createNativeQuery("select content from templates where id = :id") + .setParameter("id", templateId) + .getSingleResult(); + } + + /** 쌓인 변경을 내보내고 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/controller/TemplateUpdateUniqueViolationTest.java b/src/test/java/com/sniptext/domain/template/controller/TemplateUpdateUniqueViolationTest.java new file mode 100644 index 0000000..05c1a1e --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TemplateUpdateUniqueViolationTest.java @@ -0,0 +1,83 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.willReturn; +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 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.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import com.sniptext.domain.template.entity.Template; +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; + +/** + * 수정은 회원 잠금을 잡지 않으므로 중복 조회와 저장 사이에 다른 요청이 같은 단축어를 차지할 수 있다. + * 중복 조회만 false로 바꾸면 실제 MySQL 유니크 위반까지 가서 이 경쟁을 재현할 수 있다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 커밋 단계의 롤백 예외가 드러나지 않는다. 대신 만든 회원을 직접 지운다. + */ +@Import(MutableClockConfiguration.class) +class TemplateUpdateUniqueViolationTest extends IntegrationTestSupport { + + private static final String TAKEN_SHORTCUT = "/견적"; + + @MockitoSpyBean + private TemplateRepository templateRepository; + + @Autowired + private UserRepository userRepository; + + private Long userId; + + @BeforeEach + void setUp() { + userId = userRepository.save(UserFixture.user("update-unique@sniptext.com")).getId(); + templateRepository.save(TemplateFixture.template(userId, TAKEN_SHORTCUT)); + willReturn(false).given(templateRepository) + .existsByUserIdAndActiveShortcutKeyAndIdNot(anyLong(), anyString(), anyLong()); + } + + // 회원을 지우면 외래키(ON DELETE CASCADE)로 템플릿도 지워진다 + @AfterEach + void tearDown() { + userRepository.deleteById(userId); + } + + @Test + @DisplayName("중복 조회를 통과해도 저장에서 유니크 위반이 나면 TEMPLATE409_1이고, 500·롤백 예외로 새지 않으며 수정이 남지 않는다") + void uniqueViolationBecomesConflict() throws Exception { + Template template = templateRepository.save(TemplateFixture.template(userId, "/내단축어")); + + mockMvc.perform(put("/api/v1/templates/{templateId}", template.getId()) + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title": "새 이름", "shortcut": "%s", "content": "새 본문"} + """.formatted(TAKEN_SHORTCUT))) + .andExpect(status().isConflict()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.code").value("TEMPLATE409_1")) + .andExpect(jsonPath("$.result").isEmpty()); + + Template found = templateRepository.findById(template.getId()).orElseThrow(); + assertThat(found.getTitle().getValue()).isEqualTo(TemplateFixture.TITLE); + assertThat(found.getShortcut().getValue()).isEqualTo("/내단축어"); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/repository/TemplateRepositoryTest.java b/src/test/java/com/sniptext/domain/template/repository/TemplateRepositoryTest.java index 266df65..97d1420 100644 --- a/src/test/java/com/sniptext/domain/template/repository/TemplateRepositoryTest.java +++ b/src/test/java/com/sniptext/domain/template/repository/TemplateRepositoryTest.java @@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional; import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.entity.TemplateTitle; import com.sniptext.domain.user.entity.User; import com.sniptext.fixture.TemplateFixture; import com.sniptext.fixture.UserFixture; @@ -154,6 +155,25 @@ void sameValuesEditDoesNotUpdate() { assertThat(found.getUpdatedAt()).isEqualTo(FIXED); } + @Test + @DisplayName("수정 UPDATE는 바뀐 컬럼만 써서, 읽은 뒤 다른 요청이 기록한 사용 시각·휴지통 이동을 되돌리지 않는다") + void editKeepsColumnsChangedByOtherRequests() { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId)); + entityManager.createNativeQuery("update templates set last_used_at = :time, deleted_at = :time where id = :id") + .setParameter("time", FIXED.plusNanos(1_000)) + .setParameter("id", template.getId()) + .executeUpdate(); + + template.edit(TemplateTitle.create("새 이름"), template.getShortcut(), template.getContent(), + LocalDateTime.now(clock)); + templateRepository.flush(); + + Template found = reload(template); + assertThat(found.getTitle().getValue()).isEqualTo("새 이름"); + assertThat(found.getLastUsedAt()).isEqualTo(FIXED.plusNanos(1_000)); + assertThat(found.getDeletedAt()).isEqualTo(FIXED.plusNanos(1_000)); + } + @Test @DisplayName("저장 후 다시 읽은 시각이 저장 전 엔티티 값과 같다") void timesSurviveRoundTrip() { 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 6cecddf..0944c9f 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java @@ -10,6 +10,7 @@ import java.sql.SQLException; import java.text.Normalizer; +import java.time.Clock; import java.time.LocalDateTime; import java.util.List; import java.util.Set; @@ -23,10 +24,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; @@ -39,11 +42,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.TemplateUpdateCommand; import com.sniptext.domain.template.service.dto.result.TemplateCreateResult; import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; +import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; import com.sniptext.domain.user.service.UserCommandService; +import com.sniptext.fixture.TemplateFixture; import com.sniptext.global.exception.BusinessException; import com.sniptext.global.policy.UsageLimits; +import com.sniptext.support.MutableClock; @ExtendWith(MockitoExtension.class) class TemplateCommandServiceTest { @@ -64,6 +71,12 @@ class TemplateCommandServiceTest { @Mock private TransactionTemplate transactionTemplate; + @Mock + private TemplateQueryService templateQueryService; + + @Spy + private Clock clock = new MutableClock(NOW); + @InjectMocks private TemplateCommandService templateCommandService; @@ -302,6 +315,116 @@ private void givenUsedKeys(String shortcut, String... usedKeys) { } + @Nested + @DisplayName("수정") + class Update { + + private static final String NEW_SHORTCUT = "/Offer"; + private static final String NEW_SHORTCUT_KEY = "/offer"; + private static final TemplateUpdateCommand UPDATE_COMMAND = + new TemplateUpdateCommand(USER_ID, TEMPLATE_ID, " 새 이름 ", NEW_SHORTCUT, CONTENT); + + private Template template; + + @BeforeEach + void setUp() { + template = TemplateFixture.template(USER_ID, "/Quote"); + ReflectionTestUtils.setField(template, "id", TEMPLATE_ID); + ReflectionTestUtils.setField(template, "createdAt", NOW.minusDays(1)); + ReflectionTestUtils.setField(template, "editedAt", NOW.minusDays(1)); + } + + @Test + @DisplayName("값 객체 규칙을 거쳐 고치고, Clock 시각을 수정 시각으로 쓴 결과를 돌려준다") + void update() { + givenActiveTemplate(); + givenUpdateSaved(); + + TemplateUpdateResult result = templateCommandService.update(UPDATE_COMMAND); + + assertThat(result).isEqualTo(new TemplateUpdateResult(TEMPLATE_ID, "새 이름", NEW_SHORTCUT, CONTENT, + List.of("고객명", "금액"), null, NOW.minusDays(1), NOW)); + } + + @Test + @DisplayName("본인 소유를 확인한 뒤 자기 자신을 뺀 중복을 확인하고 저장한다") + void checksOwnerThenDuplicateThenSaves() { + givenActiveTemplate(); + givenUpdateSaved(); + + templateCommandService.update(UPDATE_COMMAND); + + InOrder inOrder = inOrder(templateQueryService, templateRepository); + inOrder.verify(templateQueryService).getActiveTemplate(USER_ID, TEMPLATE_ID); + inOrder.verify(templateRepository) + .existsByUserIdAndActiveShortcutKeyAndIdNot(USER_ID, NEW_SHORTCUT_KEY, TEMPLATE_ID); + inOrder.verify(templateRepository).saveAndFlush(template); + } + + @Test + @DisplayName("개수가 바뀌지 않으므로 회원을 잠그지 않는다") + void doesNotLockUser() { + givenActiveTemplate(); + givenUpdateSaved(); + + templateCommandService.update(UPDATE_COMMAND); + + then(userCommandService).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource(value = TemplateErrorCode.class, names = {"TEMPLATE_NOT_FOUND", "TEMPLATE_IN_TRASH"}) + @DisplayName("수정할 수 없는 템플릿이면 그 404를 그대로 던지고 단축어 조회와 저장을 하지 않는다") + void notEditable(TemplateErrorCode errorCode) { + given(templateQueryService.getActiveTemplate(USER_ID, TEMPLATE_ID)) + .willThrow(new BusinessException(errorCode)); + + assertThatThrownBy(() -> templateCommandService.update(UPDATE_COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(errorCode); + then(templateRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("내 다른 활성 템플릿에 같은 단축어 키가 있으면 TEMPLATE409_1이고 값을 고치거나 저장하지 않는다") + void duplicateShortcutBeforeSave() { + givenActiveTemplate(); + given(templateRepository.existsByUserIdAndActiveShortcutKeyAndIdNot(USER_ID, NEW_SHORTCUT_KEY, TEMPLATE_ID)) + .willReturn(true); + + assertThatThrownBy(() -> templateCommandService.update(UPDATE_COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(TemplateErrorCode.SHORTCUT_DUPLICATED); + assertThat(template.getShortcut().getValue()).isEqualTo("/Quote"); + then(templateRepository).should(never()).saveAndFlush(any()); + } + + @Test + @DisplayName("미리 조회를 통과했어도 저장에서 활성 단축어 유니크 위반이 나면 TEMPLATE409_1이고, 예외 메시지에 겹친 단축어가 남지 않는다") + void duplicateShortcut() { + givenActiveTemplate(); + givenSaveFails(ConstraintKind.UNIQUE, "templates.uk_templates_user_active_shortcut"); + + assertThatThrownBy(() -> templateCommandService.update(UPDATE_COMMAND)) + .isInstanceOf(BusinessException.class) + .hasMessage("TEMPLATE409_1") + .hasNoCause() + .extracting("resultCode") + .isEqualTo(TemplateErrorCode.SHORTCUT_DUPLICATED); + } + + private void givenActiveTemplate() { + given(templateQueryService.getActiveTemplate(USER_ID, TEMPLATE_ID)).willReturn(template); + } + + private void givenUpdateSaved() { + given(templateRepository.saveAndFlush(template)).willReturn(template); + } + + } + private void givenActiveCount(long activeCount) { given(templateRepository.countByUserIdAndDeletedAtIsNull(USER_ID)).willReturn(activeCount); }