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 294f170..f336d6e 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateController.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateController.java @@ -8,6 +8,7 @@ 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.sniptext.domain.template.controller.dto.TemplateCreateRequest; @@ -15,6 +16,7 @@ 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.TemplateShortcutAvailabilityResponse; import com.sniptext.domain.template.controller.dto.TemplateUpdateRequest; import com.sniptext.domain.template.controller.dto.TemplateUpdateResponse; import com.sniptext.domain.template.controller.dto.TemplateUsageRequest; @@ -23,10 +25,12 @@ 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.query.TemplateShortcutAvailabilityQuery; 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.TemplateShortcutAvailabilityResult; import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; import com.sniptext.domain.template.service.dto.result.TemplateUsageResult; import com.sniptext.global.response.ApiResponse; @@ -94,6 +98,23 @@ public ResponseEntity> deleteTemplate( return ApiResponse.success(TemplateSuccessCode.TEMPLATE_DELETED, TemplateDeleteResponse.from(result)); } + /** + * 단축어 확인. 형식 오류·예약어·중복도 200 + reason이라 shortcut에 검증 어노테이션을 붙이지 않는다. + * shortcut 파라미터가 없거나 excludeTemplateId가 숫자가 아니면 스프링이 COMMON400_1로 응답한다. + */ + @GetMapping("/shortcuts/availability") + public ResponseEntity> checkShortcutAvailability( + @LoginUser Long userId, + @RequestParam String shortcut, + @RequestParam(required = false) Long excludeTemplateId) { + + TemplateShortcutAvailabilityResult result = templateQueryService.checkShortcutAvailability( + new TemplateShortcutAvailabilityQuery(userId, shortcut, excludeTemplateId)); + + return ApiResponse.success(TemplateSuccessCode.SHORTCUT_AVAILABILITY_CHECKED, + TemplateShortcutAvailabilityResponse.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 ce402c7..73a4ec1 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java @@ -15,6 +15,7 @@ public enum TemplateSuccessCode implements ResultCode { TEMPLATE_FOUND(HttpStatus.OK, "TEMPLATE200_2", "템플릿 조회에 성공했습니다."), TEMPLATE_UPDATED(HttpStatus.OK, "TEMPLATE200_3", "수정 내용이 저장되었습니다."), TEMPLATE_DELETED(HttpStatus.OK, "TEMPLATE200_4", "템플릿을 휴지통으로 옮겼습니다."), + SHORTCUT_AVAILABILITY_CHECKED(HttpStatus.OK, "TEMPLATE200_5", "단축어 확인에 성공했습니다."), 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/TemplateShortcutAvailabilityResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateShortcutAvailabilityResponse.java new file mode 100644 index 0000000..d6730ce --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateShortcutAvailabilityResponse.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.template.controller.dto; + +import com.sniptext.domain.template.entity.ShortcutUnavailableReason; +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAvailabilityResult; + +/** + * 단축어 확인 결과 (명세 13번). shortcut은 확인한 단축어를 NFC 정규화한 값(대소문자는 입력 그대로)이다. + * 사용 가능하면 reason은 null로 내보낸다. + */ +public record TemplateShortcutAvailabilityResponse( + String shortcut, + boolean available, + ShortcutUnavailableReason reason +) { + + public static TemplateShortcutAvailabilityResponse from(TemplateShortcutAvailabilityResult result) { + return new TemplateShortcutAvailabilityResponse(result.shortcut(), result.available(), result.reason()); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/entity/Shortcut.java b/src/main/java/com/sniptext/domain/template/entity/Shortcut.java index e69c8c9..c3c921a 100644 --- a/src/main/java/com/sniptext/domain/template/entity/Shortcut.java +++ b/src/main/java/com/sniptext/domain/template/entity/Shortcut.java @@ -149,7 +149,11 @@ public int hashCode() { return Objects.hashCode(value); } - private static String normalize(String shortcut) { + /** + * 표시용 값과 같은 방식으로 정규화한다 (NFC, 대소문자 그대로). 규칙을 어긴 입력에도 쓸 수 있다. + * 단축어 확인 API가 형식이 틀린 입력도 정규화한 값으로 돌려줄 때 쓴다. null은 받지 않는다. + */ + public static String normalize(String shortcut) { return Normalizer.normalize(shortcut, Normalizer.Form.NFC); } diff --git a/src/main/java/com/sniptext/domain/template/entity/ShortcutUnavailableReason.java b/src/main/java/com/sniptext/domain/template/entity/ShortcutUnavailableReason.java new file mode 100644 index 0000000..b17eae8 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/entity/ShortcutUnavailableReason.java @@ -0,0 +1,23 @@ +package com.sniptext.domain.template.entity; + +/** + * 단축어 확인 API(명세 13번)의 사용 불가 이유. 상수 이름이 API 값이다 (저장하지 않는 값). + * 확인 API에는 빈 값 사유가 따로 없어 빈 값도 INVALID_FORMAT으로 내려준다. 빈 값 안내는 저장 시점의 TEMPLATE400_3이 한다. + */ +public enum ShortcutUnavailableReason { + + /** 비었거나 "/"만 있거나, "/" 뒤 한글(완성형·자모)·영문·숫자 2~10자가 아님 */ + INVALID_FORMAT, + /** 예약어 (/톤교정) */ + RESERVED, + /** 내 활성 템플릿(휴지통 제외)이 같은 비교 키를 씀 */ + DUPLICATED; + + public static ShortcutUnavailableReason from(ShortcutViolation violation) { + return switch (violation) { + case EMPTY, INVALID_FORMAT -> INVALID_FORMAT; + case RESERVED -> RESERVED; + }; + } + +} diff --git a/src/main/java/com/sniptext/domain/template/entity/ShortcutViolation.java b/src/main/java/com/sniptext/domain/template/entity/ShortcutViolation.java index aa9f3f5..757f8a5 100644 --- a/src/main/java/com/sniptext/domain/template/entity/ShortcutViolation.java +++ b/src/main/java/com/sniptext/domain/template/entity/ShortcutViolation.java @@ -2,7 +2,8 @@ /** * 단축어가 규칙을 어긴 이유. 검사 순서대로 나열한다 (저장하지 않는 값). - * API로 그대로 내보내지 않는 내부 값이다. 단축어 확인 API의 reason과 값의 종류가 다르다 (EMPTY 없음, DUPLICATED 있음). + * API로 그대로 내보내지 않는 내부 값이다. 단축어 확인 API의 reason은 ShortcutUnavailableReason으로 바꿔 내보낸다 + * (EMPTY 없음, DUPLICATED 있음). */ public enum ShortcutViolation { diff --git a/src/main/java/com/sniptext/domain/template/service/TemplateQueryService.java b/src/main/java/com/sniptext/domain/template/service/TemplateQueryService.java index bbd70c5..1c8c600 100644 --- a/src/main/java/com/sniptext/domain/template/service/TemplateQueryService.java +++ b/src/main/java/com/sniptext/domain/template/service/TemplateQueryService.java @@ -1,17 +1,23 @@ package com.sniptext.domain.template.service; import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sniptext.domain.template.entity.Shortcut; +import com.sniptext.domain.template.entity.ShortcutUnavailableReason; +import com.sniptext.domain.template.entity.ShortcutViolation; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.exception.TemplateErrorCode; import com.sniptext.domain.template.repository.TemplateRepository; import com.sniptext.domain.template.service.dto.query.TemplateDetailQuery; +import com.sniptext.domain.template.service.dto.query.TemplateShortcutAvailabilityQuery; import com.sniptext.domain.template.service.dto.result.TemplateDetailResult; import com.sniptext.domain.template.service.dto.result.TemplateListItemResult; import com.sniptext.domain.template.service.dto.result.TemplateListResult; +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAvailabilityResult; import com.sniptext.global.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -42,6 +48,27 @@ public TemplateDetailResult getTemplate(TemplateDetailQuery query) { return TemplateDetailResult.from(getActiveTemplate(query.userId(), query.templateId())); } + /** + * 단축어 확인. 저장과 같은 순서(NFC 정규화 → 형식 → 예약어 → 중복)로 보고, 규칙 위반도 에러 대신 사유로 돌려준다. + * 중복은 내 활성 템플릿(휴지통 제외)의 비교 키로 판정하고, excludeTemplateId가 있으면 그 템플릿만 뺀다. + * 남의 템플릿·휴지통 ID를 넘겨도 내 활성 템플릿에 없는 ID라 결과가 같으므로 따로 확인하지 않는다. + * 저장 순간 다른 요청이 먼저 등록할 수 있어 회원을 잠그지 않는다 (최종 검증은 저장 API의 TEMPLATE409_1). + */ + public TemplateShortcutAvailabilityResult checkShortcutAvailability(TemplateShortcutAvailabilityQuery query) { + Optional violation = Shortcut.findViolation(query.shortcut()); + if (violation.isPresent()) { + return TemplateShortcutAvailabilityResult.unavailable(Shortcut.normalize(query.shortcut()), + ShortcutUnavailableReason.from(violation.get())); + } + + Shortcut shortcut = Shortcut.create(query.shortcut()); + if (isShortcutTaken(query.userId(), shortcut, query.excludeTemplateId())) { + return TemplateShortcutAvailabilityResult.unavailable(shortcut.getValue(), + ShortcutUnavailableReason.DUPLICATED); + } + return TemplateShortcutAvailabilityResult.available(shortcut.getValue()); + } + /** * 휴지통에 없는 본인 템플릿. 수정·사용 기록도 같은 404 구분을 쓴다. * 엔티티를 돌려주므로 template 도메인 안에서만 부른다 (패키지 범위라 service 패키지 밖에서는 부를 수 없다). @@ -55,4 +82,12 @@ Template getActiveTemplate(Long userId, Long templateId) { return template; } + private boolean isShortcutTaken(Long userId, Shortcut shortcut, Long excludeTemplateId) { + if (excludeTemplateId == null) { + return templateRepository.existsByUserIdAndActiveShortcutKey(userId, shortcut.getKey()); + } + return templateRepository.existsByUserIdAndActiveShortcutKeyAndIdNot(userId, shortcut.getKey(), + excludeTemplateId); + } + } diff --git a/src/main/java/com/sniptext/domain/template/service/dto/query/TemplateShortcutAvailabilityQuery.java b/src/main/java/com/sniptext/domain/template/service/dto/query/TemplateShortcutAvailabilityQuery.java new file mode 100644 index 0000000..14efc1b --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/query/TemplateShortcutAvailabilityQuery.java @@ -0,0 +1,9 @@ +package com.sniptext.domain.template.service.dto.query; + +/** 단축어 확인 입력. excludeTemplateId는 편집 중인 자기 자신이고, 없으면 null이다. */ +public record TemplateShortcutAvailabilityQuery( + Long userId, + String shortcut, + Long excludeTemplateId +) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateShortcutAvailabilityResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateShortcutAvailabilityResult.java new file mode 100644 index 0000000..b00ebb8 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateShortcutAvailabilityResult.java @@ -0,0 +1,20 @@ +package com.sniptext.domain.template.service.dto.result; + +import com.sniptext.domain.template.entity.ShortcutUnavailableReason; + +/** 단축어 확인 결과. shortcut은 NFC 정규화한 값이고, 사용 가능하면 reason은 null이다. */ +public record TemplateShortcutAvailabilityResult( + String shortcut, + boolean available, + ShortcutUnavailableReason reason +) { + + public static TemplateShortcutAvailabilityResult available(String shortcut) { + return new TemplateShortcutAvailabilityResult(shortcut, true, null); + } + + public static TemplateShortcutAvailabilityResult unavailable(String shortcut, ShortcutUnavailableReason reason) { + return new TemplateShortcutAvailabilityResult(shortcut, false, reason); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TemplateShortcutAvailabilityControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TemplateShortcutAvailabilityControllerTest.java new file mode 100644 index 0000000..42b2387 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TemplateShortcutAvailabilityControllerTest.java @@ -0,0 +1,279 @@ +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.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.net.URI; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +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 jakarta.persistence.EntityManager; +import tools.jackson.databind.json.JsonMapper; + +/** 명세 13번. 형식 오류·예약어·중복도 에러가 아니라 200 + reason이다. */ +@Transactional +class TemplateShortcutAvailabilityControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/templates/shortcuts/availability"; + private static final String NO_STORE = "no-store"; + private static final LocalDateTime DELETED_AT = LocalDateTime.of(2026, 10, 7, 10, 30); + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private JsonMapper jsonMapper; + + private Long userId; + private Long otherUserId; + + @BeforeEach + void setUp() { + userId = saveUser("owner@sniptext.com"); + otherUserId = saveUser("other@sniptext.com"); + } + + @Test + @DisplayName("쓰지 않는 단축어면 200, available true, reason null을 명세 순서의 필드로 돌려주고 캐시하지 않는다") + void available() throws Exception { + MvcResult result = check(request("/견적서")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TEMPLATE200_5")) + .andExpect(jsonPath("$.message").value("단축어 확인에 성공했습니다.")) + .andExpect(jsonPath("$.result.shortcut").value("/견적서")) + .andExpect(jsonPath("$.result.available").value(true)) + .andExpect(jsonPath("$.result.reason").isEmpty()) + .andReturn(); + + assertThat(jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .get("result").propertyNames()) + .containsExactly("shortcut", "available", "reason"); + } + + @Test + @DisplayName("명세 예시처럼 인코딩한 주소로 보내도 디코딩해 판정한다") + void encodedQuery() throws Exception { + saveTemplate(userId, "/견적"); + + expectUnavailable(check(get(URI.create(PATH + "?shortcut=%2F%EA%B2%AC%EC%A0%81")) + .header(HttpHeaders.AUTHORIZATION, bearer(userId))), "/견적", "DUPLICATED"); + } + + @Test + @DisplayName("내 활성 템플릿이 쓰는 단축어면 DUPLICATED") + void duplicated() throws Exception { + saveTemplate(userId, "/견적"); + + expectUnavailable(check(request("/견적")), "/견적", "DUPLICATED"); + } + + @Test + @DisplayName("대소문자만 달라도 DUPLICATED이고, 응답 단축어는 입력 대소문자 그대로다") + void duplicatedIgnoringCase() throws Exception { + saveTemplate(userId, "/Quote"); + + expectUnavailable(check(request("/QUOTE")), "/QUOTE", "DUPLICATED"); + } + + @Test + @DisplayName("맥 자모 분리형(NFD)을 인코딩해 보내도 완성형과 같게 판정하고, 응답 단축어는 완성형이다") + void nfdSameAsNfc() throws Exception { + saveTemplate(userId, "/견적"); + String nfd = Normalizer.normalize("/견적", Normalizer.Form.NFD); + assertThat(nfd).isNotEqualTo("/견적"); + + expectUnavailable(check(get(PATH + "?shortcut={shortcut}", nfd) + .header(HttpHeaders.AUTHORIZATION, bearer(userId))), "/견적", "DUPLICATED"); + } + + @Test + @DisplayName("자모 분리형으로 쓰면 11자를 넘는 단축어도 완성형으로 합친 뒤 형식을 본다") + void nfdNormalizedBeforeFormat() throws Exception { + String nfd = Normalizer.normalize("/견적요청서작성안내", Normalizer.Form.NFD); + assertThat(nfd.length()).isGreaterThan(11); + + expectAvailable(check(request(nfd)), "/견적요청서작성안내"); + } + + @Test + @DisplayName("휴지통 템플릿의 단축어는 사용 가능이다") + void trashedShortcutAvailable() throws Exception { + saveTrashed(userId, "/견적"); + + expectAvailable(check(request("/견적")), "/견적"); + } + + @Test + @DisplayName("다른 회원이 쓰는 같은 단축어는 중복이 아니다") + void otherUsersShortcutAvailable() throws Exception { + saveTemplate(otherUserId, "/견적"); + + expectAvailable(check(request("/견적")), "/견적"); + } + + @Test + @DisplayName("excludeTemplateId로 편집 중인 자기 자신을 빼면 사용 가능이다") + void excludeSelf() throws Exception { + Template self = saveTemplate(userId, "/견적"); + + expectAvailable(check(request("/견적").param("excludeTemplateId", self.getId().toString())), "/견적"); + } + + @Test + @DisplayName("자기 자신이 아닌 내 템플릿 ID를 빼면 같은 단축어를 쓰는 템플릿은 그대로 남아 DUPLICATED") + void excludeAnotherTemplate() throws Exception { + saveTemplate(userId, "/견적"); + Template another = saveTemplate(userId, "/안내"); + + expectUnavailable(check(request("/견적").param("excludeTemplateId", another.getId().toString())), + "/견적", "DUPLICATED"); + } + + @Test + @DisplayName("다른 회원·휴지통·없는 템플릿 ID를 빼도 에러 없이 그 ID만 빼고 판정한다") + void excludeForeignOrTrashedOrMissing() throws Exception { + saveTemplate(userId, "/견적"); + Template others = saveTemplate(otherUserId, "/견적"); + Template trashed = saveTrashed(userId, "/견적"); + + for (Long excluded : new Long[] {others.getId(), trashed.getId(), Long.MAX_VALUE}) { + expectUnavailable(check(request("/견적").param("excludeTemplateId", excluded.toString())), + "/견적", "DUPLICATED"); + } + } + + @ParameterizedTest + @ValueSource(strings = {"", "/", "/a", "/견적요청서작성안내문서", "견적", "/견 적", " ", "/견적!"}) + @DisplayName("비었거나 \"/\"만 있거나 형식이 틀리면 200 + INVALID_FORMAT") + void invalidFormat(String shortcut) throws Exception { + expectUnavailable(check(request(shortcut)), shortcut, "INVALID_FORMAT"); + } + + @Test + @DisplayName("예약어 /톤교정은 200 + RESERVED") + void reserved() throws Exception { + expectUnavailable(check(request("/톤교정")), "/톤교정", "RESERVED"); + } + + @Test + @DisplayName("excludeTemplateId가 빈 값이면 없는 것으로 보고 판정한다") + void emptyExcludeTemplateId() throws Exception { + saveTemplate(userId, "/견적"); + + expectUnavailable(check(request("/견적").param("excludeTemplateId", "")), "/견적", "DUPLICATED"); + } + + @Test + @DisplayName("shortcut 파라미터가 없으면 COMMON400_1, 캐시 금지") + void missingShortcut() throws Exception { + expectBadRequest(check(get(PATH).header(HttpHeaders.AUTHORIZATION, bearer(userId)))); + expectBadRequest(check(get(PATH).param("excludeTemplateId", "1") + .header(HttpHeaders.AUTHORIZATION, bearer(userId)))); + } + + @Test + @DisplayName("excludeTemplateId가 숫자가 아니면 COMMON400_1") + void nonNumericExcludeTemplateId() throws Exception { + expectBadRequest(check(request("/견적").param("excludeTemplateId", "abc"))); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1, 캐시 금지") + void unauthenticated() throws Exception { + expectError(check(get(PATH).param("shortcut", "/견적")), 401, "COMMON401_1", "로그인이 필요해요."); + } + + private MockHttpServletRequestBuilder request(String shortcut) { + return get(PATH).param("shortcut", shortcut).header(HttpHeaders.AUTHORIZATION, bearer(userId)); + } + + private ResultActions check(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request); + } + + private static void expectAvailable(ResultActions actions, String shortcut) throws Exception { + actions.andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.code").value("TEMPLATE200_5")) + .andExpect(jsonPath("$.result.shortcut").value(shortcut)) + .andExpect(jsonPath("$.result.available").value(true)) + .andExpect(jsonPath("$.result.reason").isEmpty()); + } + + private static void expectUnavailable(ResultActions actions, String shortcut, String reason) throws Exception { + actions.andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TEMPLATE200_5")) + .andExpect(jsonPath("$.result.shortcut").value(shortcut)) + .andExpect(jsonPath("$.result.available").value(false)) + .andExpect(jsonPath("$.result.reason").value(reason)); + } + + private static void expectBadRequest(ResultActions actions) throws Exception { + expectError(actions, 400, "COMMON400_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 saveTemplate(Long ownerId, String shortcut) { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(ownerId, shortcut)); + flushAndClear(); + return template; + } + + private Template saveTrashed(Long ownerId, String shortcut) { + Template template = templateRepository.saveAndFlush( + TemplateFixture.trashedTemplate(ownerId, shortcut, DELETED_AT)); + flushAndClear(); + return template; + } + + 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/entity/ShortcutTest.java b/src/test/java/com/sniptext/domain/template/entity/ShortcutTest.java index 0846328..1b9ec1c 100644 --- a/src/test/java/com/sniptext/domain/template/entity/ShortcutTest.java +++ b/src/test/java/com/sniptext/domain/template/entity/ShortcutTest.java @@ -156,4 +156,19 @@ void notEqualsForDifferentCase() { assertThat(Shortcut.create("/Quote")).isNotEqualTo(Shortcut.create("/quote")); } + @Test + @DisplayName("표시용 정규화는 규칙을 어긴 입력도 NFC로 합치기만 하고 대소문자·공백은 그대로 둔다") + void normalizeKeepsInvalidInput() { + assertThat(Shortcut.normalize(nfd(" /견적 Quote"))).isEqualTo(" /견적 Quote"); + assertThat(Shortcut.normalize("")).isEmpty(); + } + + @Test + @DisplayName("규칙을 지킨 입력의 표시용 정규화 결과는 만든 단축어의 표시용 값과 같다") + void normalizeMatchesValue() { + String input = nfd("/견적Quote"); + + assertThat(Shortcut.normalize(input)).isEqualTo(Shortcut.create(input).getValue()); + } + } diff --git a/src/test/java/com/sniptext/domain/template/entity/ShortcutUnavailableReasonTest.java b/src/test/java/com/sniptext/domain/template/entity/ShortcutUnavailableReasonTest.java new file mode 100644 index 0000000..e51b324 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/entity/ShortcutUnavailableReasonTest.java @@ -0,0 +1,34 @@ +package com.sniptext.domain.template.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class ShortcutUnavailableReasonTest { + + @Test + @DisplayName("상수 이름이 명세 13번 reason 값과 같다") + void namesMatchSpec() { + assertThat(ShortcutUnavailableReason.values()) + .extracting(Enum::name) + .containsExactly("INVALID_FORMAT", "RESERVED", "DUPLICATED"); + } + + @ParameterizedTest + @EnumSource(value = ShortcutViolation.class, names = {"EMPTY", "INVALID_FORMAT"}) + @DisplayName("빈 값과 형식 위반은 INVALID_FORMAT이다 (확인 API에는 빈 값 사유가 따로 없다)") + void emptyAndInvalidFormat(ShortcutViolation violation) { + assertThat(ShortcutUnavailableReason.from(violation)).isEqualTo(ShortcutUnavailableReason.INVALID_FORMAT); + } + + @Test + @DisplayName("예약어 위반은 RESERVED다") + void reserved() { + assertThat(ShortcutUnavailableReason.from(ShortcutViolation.RESERVED)) + .isEqualTo(ShortcutUnavailableReason.RESERVED); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/service/TemplateQueryServiceTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateQueryServiceTest.java index 72ab661..a596709 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateQueryServiceTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateQueryServiceTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verifyNoInteractions; +import java.text.Normalizer; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -11,17 +13,22 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.sniptext.domain.template.entity.ShortcutUnavailableReason; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.exception.TemplateErrorCode; import com.sniptext.domain.template.repository.TemplateRepository; import com.sniptext.domain.template.service.dto.query.TemplateDetailQuery; +import com.sniptext.domain.template.service.dto.query.TemplateShortcutAvailabilityQuery; import com.sniptext.domain.template.service.dto.result.TemplateDetailResult; import com.sniptext.domain.template.service.dto.result.TemplateListItemResult; import com.sniptext.domain.template.service.dto.result.TemplateListResult; +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAvailabilityResult; import com.sniptext.fixture.TemplateFixture; import com.sniptext.global.exception.BusinessException; @@ -114,4 +121,111 @@ void getTemplatesEmpty() { assertThat(result.templates()).isEmpty(); } + @Test + @DisplayName("단축어 확인: 활성 템플릿에 같은 비교 키가 없으면 사용 가능이고 사유는 없다") + void shortcutAvailable() { + given(templateRepository.existsByUserIdAndActiveShortcutKey(USER_ID, "/견적")).willReturn(false); + + TemplateShortcutAvailabilityResult result = checkShortcut("/견적", null); + + assertThat(result.shortcut()).isEqualTo("/견적"); + assertThat(result.available()).isTrue(); + assertThat(result.reason()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"/a", "/견적요청서작성안내문서", "견적", "/견 적", " ", " /견적", "/견적!"}) + @DisplayName("단축어 확인: 형식이 틀리면 INVALID_FORMAT이고 중복은 조회하지 않는다") + void shortcutInvalidFormat(String shortcut) { + TemplateShortcutAvailabilityResult result = checkShortcut(shortcut, null); + + assertUnavailable(result, shortcut, ShortcutUnavailableReason.INVALID_FORMAT); + verifyNoInteractions(templateRepository); + } + + @ParameterizedTest + @ValueSource(strings = {"", "/"}) + @DisplayName("단축어 확인: 비었거나 \"/\"만 있어도 에러가 아니라 INVALID_FORMAT이다") + void shortcutEmpty(String shortcut) { + TemplateShortcutAvailabilityResult result = checkShortcut(shortcut, null); + + assertUnavailable(result, shortcut, ShortcutUnavailableReason.INVALID_FORMAT); + verifyNoInteractions(templateRepository); + } + + @Test + @DisplayName("단축어 확인: 예약어 /톤교정은 RESERVED이고 중복은 조회하지 않는다") + void shortcutReserved() { + TemplateShortcutAvailabilityResult result = checkShortcut("/톤교정", null); + + assertUnavailable(result, "/톤교정", ShortcutUnavailableReason.RESERVED); + verifyNoInteractions(templateRepository); + } + + @Test + @DisplayName("단축어 확인: 활성 템플릿에 같은 비교 키가 있으면 DUPLICATED") + void shortcutDuplicated() { + given(templateRepository.existsByUserIdAndActiveShortcutKey(USER_ID, "/견적")).willReturn(true); + + assertUnavailable(checkShortcut("/견적", null), "/견적", ShortcutUnavailableReason.DUPLICATED); + } + + @Test + @DisplayName("단축어 확인: 대소문자만 다른 단축어는 소문자 비교 키로 찾아 DUPLICATED, 응답 단축어는 입력 대소문자 그대로다") + void shortcutDuplicatedIgnoringCase() { + given(templateRepository.existsByUserIdAndActiveShortcutKey(USER_ID, "/quote")).willReturn(true); + + assertUnavailable(checkShortcut("/QUOTE", null), "/QUOTE", ShortcutUnavailableReason.DUPLICATED); + } + + @Test + @DisplayName("단축어 확인: excludeTemplateId가 있으면 그 템플릿을 빼고 중복을 판정한다") + void shortcutExcludesSelf() { + given(templateRepository.existsByUserIdAndActiveShortcutKeyAndIdNot(USER_ID, "/견적", TEMPLATE_ID)) + .willReturn(false); + + TemplateShortcutAvailabilityResult result = checkShortcut("/견적", TEMPLATE_ID); + + assertThat(result.available()).isTrue(); + assertThat(result.reason()).isNull(); + } + + @Test + @DisplayName("단축어 확인: 자기 자신을 빼도 다른 활성 템플릿이 같은 키를 쓰면 DUPLICATED") + void shortcutDuplicatedExcludingSelf() { + given(templateRepository.existsByUserIdAndActiveShortcutKeyAndIdNot(USER_ID, "/견적", TEMPLATE_ID)) + .willReturn(true); + + assertUnavailable(checkShortcut("/견적", TEMPLATE_ID), "/견적", ShortcutUnavailableReason.DUPLICATED); + } + + @Test + @DisplayName("단축어 확인: 자모 분리형은 완성형으로 정규화해 판정하고, 응답 단축어도 정규화한 값이다") + void shortcutNormalized() { + String nfd = Normalizer.normalize("/견적", Normalizer.Form.NFD); + given(templateRepository.existsByUserIdAndActiveShortcutKey(USER_ID, "/견적")).willReturn(true); + + assertUnavailable(checkShortcut(nfd, null), "/견적", ShortcutUnavailableReason.DUPLICATED); + } + + @Test + @DisplayName("단축어 확인: 형식이 틀린 자모 분리형 입력도 응답 단축어는 정규화한 값이다") + void shortcutInvalidFormatNormalized() { + String nfd = Normalizer.normalize("/견 적", Normalizer.Form.NFD); + + assertUnavailable(checkShortcut(nfd, null), "/견 적", ShortcutUnavailableReason.INVALID_FORMAT); + } + + private TemplateShortcutAvailabilityResult checkShortcut(String shortcut, Long excludeTemplateId) { + return templateQueryService.checkShortcutAvailability( + new TemplateShortcutAvailabilityQuery(USER_ID, shortcut, excludeTemplateId)); + } + + private static void assertUnavailable(TemplateShortcutAvailabilityResult result, String shortcut, + ShortcutUnavailableReason reason) { + assertThat(result.shortcut()).isEqualTo(shortcut); + assertThat(result.available()).isFalse(); + assertThat(result.reason()).isEqualTo(reason); + } + }