From 2c13cc5e099fba55033e6451e30de6cf24721a2f Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Mon, 9 Nov 2026 12:21:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EC=97=B4=EA=B1=B0=ED=98=95=EA=B3=BC=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EA=B2=80=EC=A6=9D=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UsageChannelConstraintValidator.java | 16 +++++ .../annotation/ValidUsageChannel.java | 29 +++++++++ .../domain/template/entity/UsageChannel.java | 23 ++++++++ .../template/exception/TemplateErrorCode.java | 2 + .../annotation/ValidUsageChannelTest.java | 59 +++++++++++++++++++ .../template/entity/UsageChannelTest.java | 37 ++++++++++++ .../exception/TemplateErrorCodeTest.java | 8 +++ 7 files changed, 174 insertions(+) create mode 100644 src/main/java/com/sniptext/domain/template/annotation/UsageChannelConstraintValidator.java create mode 100644 src/main/java/com/sniptext/domain/template/annotation/ValidUsageChannel.java create mode 100644 src/main/java/com/sniptext/domain/template/entity/UsageChannel.java create mode 100644 src/test/java/com/sniptext/domain/template/annotation/ValidUsageChannelTest.java create mode 100644 src/test/java/com/sniptext/domain/template/entity/UsageChannelTest.java diff --git a/src/main/java/com/sniptext/domain/template/annotation/UsageChannelConstraintValidator.java b/src/main/java/com/sniptext/domain/template/annotation/UsageChannelConstraintValidator.java new file mode 100644 index 0000000..2dec613 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/annotation/UsageChannelConstraintValidator.java @@ -0,0 +1,16 @@ +package com.sniptext.domain.template.annotation; + +import com.sniptext.domain.template.entity.UsageChannel; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** UsageChannel의 상수 이름과 정확히 같은지만 본다. 위반 메시지는 어노테이션 기본값(TEMPLATE400_9) 그대로다. */ +public class UsageChannelConstraintValidator implements ConstraintValidator { + + @Override + public boolean isValid(String channel, ConstraintValidatorContext context) { + return UsageChannel.isSupported(channel); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/annotation/ValidUsageChannel.java b/src/main/java/com/sniptext/domain/template/annotation/ValidUsageChannel.java new file mode 100644 index 0000000..dfa3a9d --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/annotation/ValidUsageChannel.java @@ -0,0 +1,29 @@ +package com.sniptext.domain.template.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * 요청 객체의 사용 채널 검증. 없거나(null 포함) 지원하지 않으면 TEMPLATE400_9. + * 필드를 열거형으로 받으면 틀린 값이 JSON 역직렬화 실패(COMMON400_1)가 되므로 문자열로 받아 여기서 검사한다. + * UsageChannel(entity)을 쓰므로 controller 패키지 밖에 둔다 (ArchUnit 의존 규칙). + */ +@Documented +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UsageChannelConstraintValidator.class) +public @interface ValidUsageChannel { + + String message() default "TEMPLATE400_9"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/com/sniptext/domain/template/entity/UsageChannel.java b/src/main/java/com/sniptext/domain/template/entity/UsageChannel.java new file mode 100644 index 0000000..611347b --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/entity/UsageChannel.java @@ -0,0 +1,23 @@ +package com.sniptext.domain.template.entity; + +import java.util.Arrays; + +/** + * 템플릿 사용 채널 (명세 공통 규칙 6장). 상수 이름이 API 값이다. + * 저장하지 않는 값이다. 채널별 통계는 화면이 분석 도구 이벤트로 따로 보낸다. + */ +public enum UsageChannel { + + /** 대시보드에서 복사 */ + DASHBOARD_COPY, + /** 확장 팝업에서 복사 */ + POPUP_COPY, + /** 메일 작성 창에 인라인 삽입 */ + EMAIL_INLINE; + + /** 상수 이름과 정확히 같을 때만 지원한다. 대소문자·앞뒤 공백을 고쳐 주지 않는다 */ + public static boolean isSupported(String value) { + return Arrays.stream(values()).anyMatch(channel -> channel.name().equals(value)); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java b/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java index 36345df..6310b7b 100644 --- a/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java +++ b/src/main/java/com/sniptext/domain/template/exception/TemplateErrorCode.java @@ -28,6 +28,8 @@ public enum TemplateErrorCode implements ResultCode { CONTENT_EMPTY(HttpStatus.BAD_REQUEST, "TEMPLATE400_6", "템플릿 내용을 입력해주세요."), /** 본문 3000자 초과 */ CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "TEMPLATE400_7", "템플릿 내용은 3,000자까지 입력할 수 있어요."), + /** 사용 기록의 채널이 없거나 지원하지 않는 값 */ + USAGE_CHANNEL_UNSUPPORTED(HttpStatus.BAD_REQUEST, "TEMPLATE400_9", "지원하지 않는 사용 채널이에요."), /** 활성 템플릿 수가 플랜 한도 이상 (화면은 한도 모달) */ TEMPLATE_LIMIT_REACHED(HttpStatus.FORBIDDEN, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."), /** 없는 템플릿, 다른 회원의 템플릿 (다른 회원의 휴지통 템플릿 포함) */ diff --git a/src/test/java/com/sniptext/domain/template/annotation/ValidUsageChannelTest.java b/src/test/java/com/sniptext/domain/template/annotation/ValidUsageChannelTest.java new file mode 100644 index 0000000..57f5ee6 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/annotation/ValidUsageChannelTest.java @@ -0,0 +1,59 @@ +package com.sniptext.domain.template.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class ValidUsageChannelTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + record Request(@ValidUsageChannel String channel) { + } + + @BeforeAll + static void setUp() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void tearDown() { + validatorFactory.close(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "dashboard_copy", "1", "SLACK_COPY"}) + @DisplayName("없거나 지원하지 않는 채널이면 TEMPLATE400_9 하나만 남긴다") + void unsupported(String channel) { + assertThat(messagesOf(channel)).containsExactly("TEMPLATE400_9"); + } + + @ParameterizedTest + @ValueSource(strings = {"DASHBOARD_COPY", "POPUP_COPY", "EMAIL_INLINE"}) + @DisplayName("명세의 채널이면 위반 없음") + void supported(String channel) { + assertThat(messagesOf(channel)).isEmpty(); + } + + private static List messagesOf(String channel) { + return validator.validate(new Request(channel)).stream() + .map(ConstraintViolation::getMessage) + .toList(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/entity/UsageChannelTest.java b/src/test/java/com/sniptext/domain/template/entity/UsageChannelTest.java new file mode 100644 index 0000000..26f070e --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/entity/UsageChannelTest.java @@ -0,0 +1,37 @@ +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.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class UsageChannelTest { + + @Test + @DisplayName("상수 이름이 명세 공통 규칙 6장의 사용 채널 값과 같다") + void namesMatchSpec() { + assertThat(UsageChannel.values()) + .extracting(Enum::name) + .containsExactly("DASHBOARD_COPY", "POPUP_COPY", "EMAIL_INLINE"); + } + + @ParameterizedTest + @ValueSource(strings = {"DASHBOARD_COPY", "POPUP_COPY", "EMAIL_INLINE"}) + @DisplayName("명세의 값은 지원한다") + void supported(String value) { + assertThat(UsageChannel.isSupported(value)).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "dashboard_copy", "Dashboard_Copy", " DASHBOARD_COPY", "DASHBOARD_COPY ", "1", + "0", "COPY", "dashboard"}) + @DisplayName("없거나 대소문자·공백이 다르거나 순번 숫자면 지원하지 않는다") + void unsupported(String value) { + assertThat(UsageChannel.isSupported(value)).isFalse(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/exception/TemplateErrorCodeTest.java b/src/test/java/com/sniptext/domain/template/exception/TemplateErrorCodeTest.java index 159c941..51048e7 100644 --- a/src/test/java/com/sniptext/domain/template/exception/TemplateErrorCodeTest.java +++ b/src/test/java/com/sniptext/domain/template/exception/TemplateErrorCodeTest.java @@ -22,6 +22,14 @@ void shortcutNumberExhausted() { assertThat(TemplateErrorCode.SHORTCUT_NUMBER_EXHAUSTED.getMessage()).isEqualTo("사용할 수 있는 단축어 번호가 없어요."); } + @Test + @DisplayName("지원하지 않는 사용 채널은 명세의 결과 코드와 문구다") + void unsupportedUsageChannel() { + assertThat(TemplateErrorCode.USAGE_CHANNEL_UNSUPPORTED.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(TemplateErrorCode.USAGE_CHANNEL_UNSUPPORTED.getCode()).isEqualTo("TEMPLATE400_9"); + assertThat(TemplateErrorCode.USAGE_CHANNEL_UNSUPPORTED.getMessage()).isEqualTo("지원하지 않는 사용 채널이에요."); + } + @Test @DisplayName("템플릿 없음과 휴지통은 명세의 결과 코드와 문구다") void notFound() { From 35e19312eb403d28209430a4cd807cd3af276dda Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Mon, 9 Nov 2026 12:21:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EA=B8=B0=EB=A1=9D=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TemplateController.java | 14 + .../controller/TemplateSuccessCode.java | 1 + .../controller/dto/TemplateUsageRequest.java | 18 + .../controller/dto/TemplateUsageResponse.java | 17 + .../service/TemplateCommandService.java | 18 + .../dto/command/TemplateUsageCommand.java | 8 + .../dto/result/TemplateUsageResult.java | 17 + .../TemplateUsageControllerTest.java | 310 ++++++++++++++++++ .../service/TemplateCommandServiceTest.java | 57 ++++ 9 files changed, 460 insertions(+) create mode 100644 src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageRequest.java create mode 100644 src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageResponse.java create mode 100644 src/main/java/com/sniptext/domain/template/service/dto/command/TemplateUsageCommand.java create mode 100644 src/main/java/com/sniptext/domain/template/service/dto/result/TemplateUsageResult.java create mode 100644 src/test/java/com/sniptext/domain/template/controller/TemplateUsageControllerTest.java diff --git a/src/main/java/com/sniptext/domain/template/controller/TemplateController.java b/src/main/java/com/sniptext/domain/template/controller/TemplateController.java index 9da8b0c..db9c5f9 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateController.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateController.java @@ -15,6 +15,8 @@ import com.sniptext.domain.template.controller.dto.TemplateListResponse; import com.sniptext.domain.template.controller.dto.TemplateUpdateRequest; import com.sniptext.domain.template.controller.dto.TemplateUpdateResponse; +import com.sniptext.domain.template.controller.dto.TemplateUsageRequest; +import com.sniptext.domain.template.controller.dto.TemplateUsageResponse; import com.sniptext.domain.template.service.TemplateCommandService; import com.sniptext.domain.template.service.TemplateQueryService; import com.sniptext.domain.template.service.dto.query.TemplateDetailQuery; @@ -22,6 +24,7 @@ import com.sniptext.domain.template.service.dto.result.TemplateDetailResult; import com.sniptext.domain.template.service.dto.result.TemplateListResult; import com.sniptext.domain.template.service.dto.result.TemplateUpdateResult; +import com.sniptext.domain.template.service.dto.result.TemplateUsageResult; import com.sniptext.global.response.ApiResponse; import com.sniptext.global.security.LoginUser; @@ -77,4 +80,15 @@ public ResponseEntity> updateTemplate( return ApiResponse.success(TemplateSuccessCode.TEMPLATE_UPDATED, TemplateUpdateResponse.from(result)); } + @PostMapping("/{templateId}/usages") + public ResponseEntity> recordUsage( + @LoginUser Long userId, + @PathVariable Long templateId, + @Valid @RequestBody TemplateUsageRequest request) { + + TemplateUsageResult result = templateCommandService.recordUsage(request.toCommand(userId, templateId)); + + return ApiResponse.success(TemplateSuccessCode.TEMPLATE_USAGE_RECORDED, TemplateUsageResponse.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 7bb5b67..ecef3ad 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java +++ b/src/main/java/com/sniptext/domain/template/controller/TemplateSuccessCode.java @@ -14,6 +14,7 @@ public enum TemplateSuccessCode implements ResultCode { TEMPLATE_LIST_FOUND(HttpStatus.OK, "TEMPLATE200_1", "템플릿 목록 조회에 성공했습니다."), TEMPLATE_FOUND(HttpStatus.OK, "TEMPLATE200_2", "템플릿 조회에 성공했습니다."), TEMPLATE_UPDATED(HttpStatus.OK, "TEMPLATE200_3", "수정 내용이 저장되었습니다."), + TEMPLATE_USAGE_RECORDED(HttpStatus.OK, "TEMPLATE200_6", "사용 기록이 저장되었습니다."), TEMPLATE_CREATED(HttpStatus.CREATED, "TEMPLATE201_1", "템플릿이 저장되었습니다."); private final HttpStatus httpStatus; diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageRequest.java b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageRequest.java new file mode 100644 index 0000000..1c56591 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageRequest.java @@ -0,0 +1,18 @@ +package com.sniptext.domain.template.controller.dto; + +import com.sniptext.domain.template.annotation.ValidUsageChannel; +import com.sniptext.domain.template.service.dto.command.TemplateUsageCommand; + +/** + * 템플릿 사용 기록 (명세 14번). 채널은 열거형 대신 문자열로 받아 @ValidUsageChannel로 검사한다 + * (틀린 값이 COMMON400_1이 아니라 TEMPLATE400_9가 되도록). + */ +public record TemplateUsageRequest( + @ValidUsageChannel String channel +) { + + public TemplateUsageCommand toCommand(Long userId, Long templateId) { + return new TemplateUsageCommand(userId, templateId); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageResponse.java new file mode 100644 index 0000000..54e2e30 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TemplateUsageResponse.java @@ -0,0 +1,17 @@ +package com.sniptext.domain.template.controller.dto; + +import java.time.LocalDateTime; + +import com.sniptext.domain.template.service.dto.result.TemplateUsageResult; + +/** 템플릿 사용 기록 결과 (명세 14번). 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */ +public record TemplateUsageResponse( + Long templateId, + LocalDateTime lastUsedAt +) { + + public static TemplateUsageResponse from(TemplateUsageResult result) { + return new TemplateUsageResponse(result.templateId(), result.lastUsedAt()); + } + +} 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 7246821..229d6d5 100644 --- a/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java +++ b/src/main/java/com/sniptext/domain/template/service/TemplateCommandService.java @@ -21,9 +21,11 @@ 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.command.TemplateUsageCommand; 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.template.service.dto.result.TemplateUsageResult; import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.global.exception.BusinessException; import com.sniptext.global.policy.UsageLimits; @@ -117,6 +119,22 @@ public TemplateUpdateResult update(TemplateUpdateCommand command) { return TemplateUpdateResult.from(save(template)); } + /** + * 템플릿 사용 기록. 최근 사용 시각(last_used_at)만 Clock 시각으로 바꾸고 수정 시각(edited_at)은 그대로 둔다. + * 없거나 다른 회원 것이면 TEMPLATE404_1, 휴지통에 있으면 TEMPLATE404_2. 개수가 바뀌지 않으므로 회원을 잠그지 않는다. + * 변경 감지로 쓴다. @DynamicUpdate라 UPDATE에는 last_used_at과 감사 기능이 채우는 updated_at만 들어가고, + * 읽은 뒤 다른 요청이 고친 이름·본문을 덮어쓰지 않는다. 엔티티를 읽으므로 본문 복호화에 실패하는 행이면 500이다. + * 조회 뒤 커밋 전에 휴지통으로 옮겨지면 휴지통 행의 last_used_at이 바뀐다. 표시하지 않는 값이고 복구 시 그대로 남는 정도라 허용한다. + */ + @Transactional + public TemplateUsageResult recordUsage(TemplateUsageCommand command) { + Template template = templateQueryService.getActiveTemplate(command.userId(), command.templateId()); + + template.markUsed(LocalDateTime.now(clock)); + + return TemplateUsageResult.from(template); + } + private static void requireNoOuterTransaction() { if (TransactionSynchronizationManager.isActualTransactionActive()) { throw new IllegalStateException("갤러리 저장은 호출자 트랜잭션 밖에서 불러야 한다"); diff --git a/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateUsageCommand.java b/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateUsageCommand.java new file mode 100644 index 0000000..ff22840 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/command/TemplateUsageCommand.java @@ -0,0 +1,8 @@ +package com.sniptext.domain.template.service.dto.command; + +/** 템플릿 사용 기록 입력. 채널은 요청 단계에서 검증만 하고 저장하지 않으므로 넘기지 않는다. */ +public record TemplateUsageCommand( + Long userId, + Long templateId +) { +} diff --git a/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateUsageResult.java b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateUsageResult.java new file mode 100644 index 0000000..412bf42 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/service/dto/result/TemplateUsageResult.java @@ -0,0 +1,17 @@ +package com.sniptext.domain.template.service.dto.result; + +import java.time.LocalDateTime; + +import com.sniptext.domain.template.entity.Template; + +/** 사용 기록을 남긴 템플릿과 갱신된 최근 사용 시각. */ +public record TemplateUsageResult( + Long templateId, + LocalDateTime lastUsedAt +) { + + public static TemplateUsageResult from(Template template) { + return new TemplateUsageResult(template.getId(), template.getLastUsedAt()); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/controller/TemplateUsageControllerTest.java b/src/test/java/com/sniptext/domain/template/controller/TemplateUsageControllerTest.java new file mode 100644 index 0000000..53a9d80 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TemplateUsageControllerTest.java @@ -0,0 +1,310 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.template.entity.Template; +import com.sniptext.domain.template.repository.TemplateRepository; +import com.sniptext.domain.user.entity.User; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import jakarta.persistence.EntityManager; +import tools.jackson.databind.json.JsonMapper; + +/** 명세 14번. 사용 기록은 last_used_at만 바꾸고 edited_at·목록 순서는 그대로 둔다. */ +@Transactional +@Import(MutableClockConfiguration.class) +class TemplateUsageControllerTest extends IntegrationTestSupport { + + private static final String PATH = "/api/v1/templates/{templateId}/usages"; + private static final String NO_STORE = "no-store"; + private static final LocalDateTime FIXED = LocalDateTime.of(2026, 10, 11, 19, 15); + private static final LocalDateTime LATER = FIXED.plusHours(1); + private static final String LATER_TEXT = "2026-10-11T20:15:00+09:00"; + private static final String DASHBOARD_COPY = "{\"channel\": \"DASHBOARD_COPY\"}"; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private MutableClock clock; + + @Autowired + private JsonMapper jsonMapper; + + private Long userId; + private Long otherUserId; + + @BeforeEach + void setUp() { + clock.setTo(FIXED); + userId = saveUser("owner@sniptext.com"); + otherUserId = saveUser("other@sniptext.com"); + } + + @Test + @DisplayName("기록하면 200과 명세 순서의 필드를 +09:00 시각으로 돌려주고 캐시하지 않는다") + void recordUsage() throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + clock.setTo(LATER); + + MvcResult result = postAs(userId, template.getId(), DASHBOARD_COPY) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("TEMPLATE200_6")) + .andExpect(jsonPath("$.message").value("사용 기록이 저장되었습니다.")) + .andExpect(jsonPath("$.result.templateId").value(template.getId())) + .andExpect(jsonPath("$.result.lastUsedAt").value(LATER_TEXT)) + .andReturn(); + + assertThat(jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .get("result").propertyNames()) + .containsExactly("templateId", "lastUsedAt"); + } + + @ParameterizedTest + @ValueSource(strings = {"DASHBOARD_COPY", "POPUP_COPY", "EMAIL_INLINE"}) + @DisplayName("명세의 세 채널 모두 기록된다") + void everyChannel(String channel) throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + clock.setTo(LATER); + + postAs(userId, template.getId(), "{\"channel\": \"" + channel + "\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("TEMPLATE200_6")); + + assertThat(reload(template).getLastUsedAt()).isEqualTo(LATER); + } + + @Test + @DisplayName("last_used_at·updated_at은 지금 시각이 되고 edited_at·created_at·이름·단축어·본문은 그대로다") + void onlyUsageColumnsChange() throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + clock.setTo(LATER); + + postAs(userId, template.getId(), DASHBOARD_COPY).andExpect(status().isOk()); + + Template found = reload(template); + assertThat(found.getLastUsedAt()).isEqualTo(LATER); + assertThat(found.getUpdatedAt()).isEqualTo(LATER); + assertThat(found.getEditedAt()).isEqualTo(FIXED); + assertThat(found.getCreatedAt()).isEqualTo(FIXED); + assertThat(found.getTitle().getValue()).isEqualTo(TemplateFixture.TITLE); + assertThat(found.getShortcut().getValue()).isEqualTo(TemplateFixture.SHORTCUT); + assertThat(found.getContent().getValue()).isEqualTo(TemplateFixture.CONTENT); + } + + @Test + @DisplayName("두 번 기록하면 나중 시각으로 갱신된다") + void recordTwice() throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + clock.setTo(LATER); + postAs(userId, template.getId(), DASHBOARD_COPY).andExpect(status().isOk()); + flushAndClear(); + LocalDateTime second = LATER.plusMinutes(30); + clock.setTo(second); + + postAs(userId, template.getId(), "{\"channel\": \"POPUP_COPY\"}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.lastUsedAt").value("2026-10-11T20:45:00+09:00")); + + Template found = reload(template); + assertThat(found.getLastUsedAt()).isEqualTo(second); + assertThat(found.getUpdatedAt()).isEqualTo(second); + assertThat(found.getEditedAt()).isEqualTo(FIXED); + } + + @Test + @DisplayName("사용 기록 후에도 내 템플릿 목록 순서는 바뀌지 않는다") + void listOrderUnchanged() throws Exception { + Template first = saveTemplate(userId, "/첫째"); + Template second = saveTemplate(userId, "/둘째"); + Template third = saveTemplate(userId, "/셋째"); + clock.setTo(LATER); + + postAs(userId, third.getId(), DASHBOARD_COPY).andExpect(status().isOk()); + flushAndClear(); + + mockMvc.perform(get("/api/v1/templates").header(HttpHeaders.AUTHORIZATION, bearer(userId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.templates[*].templateId").value( + contains(first.getId().intValue(), second.getId().intValue(), third.getId().intValue()))); + } + + @ParameterizedTest + @ValueSource(strings = { + "{}", + "{\"channel\": null}", + "{\"channel\": \"\"}", + "{\"channel\": \" \"}", + "{\"channel\": \"SLACK_COPY\"}", + "{\"channel\": \"dashboard_copy\"}", + "{\"channel\": \" DASHBOARD_COPY \"}", + "{\"channel\": \"1\"}", + "{\"channel\": 1}", + "{\"channel\": 0}", + "{\"channel\": true}" + }) + @DisplayName("채널이 없거나 지원하지 않는 값이면 COMMON400_1이 아니라 TEMPLATE400_9이고 기록하지 않는다") + void unsupportedChannel(String body) throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + + expectError(postAs(userId, template.getId(), body), 400, "TEMPLATE400_9", "지원하지 않는 사용 채널이에요."); + + assertThat(reload(template).getLastUsedAt()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"", "{\"channel\": ", "null", "[]", "{\"channel\": [\"DASHBOARD_COPY\"]}", + "{\"channel\": {}}"}) + @DisplayName("본문이 없거나 JSON 형식·타입이 깨지면 전역 처리대로 COMMON400_1이고 기록하지 않는다") + void unreadableBody(String body) throws Exception { + Template template = saveTemplate(userId, TemplateFixture.SHORTCUT); + + expectError(postAs(userId, template.getId(), body), 400, "COMMON400_1", "잘못된 요청입니다."); + + assertThat(reload(template).getLastUsedAt()).isNull(); + } + + @Test + @DisplayName("채널이 틀리고 템플릿도 없으면 형식 검증이 먼저라 TEMPLATE400_9") + void channelCheckedBeforeOwnership() throws Exception { + expectError(postAs(userId, Long.MAX_VALUE, "{\"channel\": \"SLACK_COPY\"}"), 400, "TEMPLATE400_9", + "지원하지 않는 사용 채널이에요."); + } + + @Test + @DisplayName("없는 템플릿이면 TEMPLATE404_1") + void notFound() throws Exception { + expectNotFound(postAs(userId, Long.MAX_VALUE, DASHBOARD_COPY)); + } + + @Test + @DisplayName("다른 회원의 템플릿이면 TEMPLATE404_1이고 기록하지 않는다") + void otherUsersTemplate() throws Exception { + Template template = saveTemplate(otherUserId, TemplateFixture.SHORTCUT); + + expectNotFound(postAs(userId, template.getId(), DASHBOARD_COPY)); + + assertThat(reload(template).getLastUsedAt()).isNull(); + } + + @Test + @DisplayName("내 템플릿이 휴지통에 있으면 TEMPLATE404_2이고 기록하지 않는다") + void trashed() throws Exception { + Template template = saveTrashed(userId); + clock.setTo(LATER); + + expectError(postAs(userId, template.getId(), DASHBOARD_COPY), 404, "TEMPLATE404_2", "이미 삭제된 템플릿이에요."); + + Template found = reload(template); + assertThat(found.getLastUsedAt()).isNull(); + assertThat(found.getUpdatedAt()).isEqualTo(FIXED); + } + + @Test + @DisplayName("다른 회원의 휴지통 템플릿이면 TEMPLATE404_1이다 (존재 여부를 노출하지 않는다)") + void otherUsersTrashed() throws Exception { + Template template = saveTrashed(otherUserId); + + expectNotFound(postAs(userId, template.getId(), DASHBOARD_COPY)); + } + + @Test + @DisplayName("templateId가 숫자가 아니면 COMMON400_1") + void nonNumericTemplateId() throws Exception { + expectError(mockMvc.perform(post("/api/v1/templates/abc/usages") + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content(DASHBOARD_COPY)), 400, "COMMON400_1", "잘못된 요청입니다."); + } + + @Test + @DisplayName("토큰이 없으면 COMMON401_1, 캐시 금지") + void unauthenticated() throws Exception { + expectError(mockMvc.perform(post(PATH, 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(DASHBOARD_COPY)), 401, "COMMON401_1", "로그인이 필요해요."); + } + + private ResultActions postAs(Long userId, Long templateId, String body) throws Exception { + return mockMvc.perform(post(PATH, templateId) + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } + + 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 saveTemplate(Long ownerId, String shortcut) { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(ownerId, shortcut)); + flushAndClear(); + return template; + } + + private Template saveTrashed(Long ownerId) { + Template template = templateRepository.saveAndFlush(TemplateFixture.template(ownerId)); + template.moveToTrash(FIXED); + flushAndClear(); + return template; + } + + /** 쌓인 변경을 내보내고 1차 캐시를 비워 DB 값을 다시 읽는다 */ + private Template reload(Template template) { + flushAndClear(); + return entityManager.find(Template.class, template.getId()); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } + + private Long saveUser(String email) { + User user = UserFixture.user(email); + entityManager.persist(user); + entityManager.flush(); + return user.getId(); + } + +} diff --git a/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java b/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java index 0944c9f..b60b8ec 100644 --- a/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java +++ b/src/test/java/com/sniptext/domain/template/service/TemplateCommandServiceTest.java @@ -43,9 +43,11 @@ 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.command.TemplateUsageCommand; 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.template.service.dto.result.TemplateUsageResult; import com.sniptext.domain.user.service.UserCommandService; import com.sniptext.fixture.TemplateFixture; import com.sniptext.global.exception.BusinessException; @@ -425,6 +427,61 @@ private void givenUpdateSaved() { } + @Nested + @DisplayName("사용 기록") + class RecordUsage { + + private static final TemplateUsageCommand USAGE_COMMAND = new TemplateUsageCommand(USER_ID, TEMPLATE_ID); + private static final LocalDateTime EDITED_AT = NOW.minusDays(1); + + private Template template; + + @BeforeEach + void setUp() { + template = TemplateFixture.template(USER_ID); + ReflectionTestUtils.setField(template, "id", TEMPLATE_ID); + ReflectionTestUtils.setField(template, "editedAt", EDITED_AT); + } + + @Test + @DisplayName("Clock 시각을 최근 사용 시각으로 쓰고 ID와 그 시각을 돌려준다. 수정 시각은 그대로다") + void recordUsage() { + given(templateQueryService.getActiveTemplate(USER_ID, TEMPLATE_ID)).willReturn(template); + + TemplateUsageResult result = templateCommandService.recordUsage(USAGE_COMMAND); + + assertThat(result).isEqualTo(new TemplateUsageResult(TEMPLATE_ID, NOW)); + assertThat(template.getLastUsedAt()).isEqualTo(NOW); + assertThat(template.getEditedAt()).isEqualTo(EDITED_AT); + } + + @Test + @DisplayName("개수가 바뀌지 않으므로 회원을 잠그지 않고, 변경 감지로 쓰므로 레포지토리를 직접 부르지 않는다") + void doesNotLockUserOrCallRepository() { + given(templateQueryService.getActiveTemplate(USER_ID, TEMPLATE_ID)).willReturn(template); + + templateCommandService.recordUsage(USAGE_COMMAND); + + then(userCommandService).shouldHaveNoInteractions(); + then(templateRepository).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource(value = TemplateErrorCode.class, names = {"TEMPLATE_NOT_FOUND", "TEMPLATE_IN_TRASH"}) + @DisplayName("사용 기록할 수 없는 템플릿이면 그 404를 그대로 던진다") + void notUsable(TemplateErrorCode errorCode) { + given(templateQueryService.getActiveTemplate(USER_ID, TEMPLATE_ID)) + .willThrow(new BusinessException(errorCode)); + + assertThatThrownBy(() -> templateCommandService.recordUsage(USAGE_COMMAND)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(errorCode); + then(templateRepository).shouldHaveNoInteractions(); + } + + } + private void givenActiveCount(long activeCount) { given(templateRepository.countByUserIdAndDeletedAtIsNull(USER_ID)).willReturn(activeCount); } From 86f39411f27c78ac1f19d5d09b027e13aa3cd6b6 Mon Sep 17 00:00:00 2001 From: oTAETAEo Date: Mon, 9 Nov 2026 12:21:55 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=A9=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EC=9D=BD=EC=9D=80=20=EB=92=A4=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EC=9D=8C=EC=9D=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateUsageDynamicUpdateTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/test/java/com/sniptext/domain/template/controller/TemplateUsageDynamicUpdateTest.java diff --git a/src/test/java/com/sniptext/domain/template/controller/TemplateUsageDynamicUpdateTest.java b/src/test/java/com/sniptext/domain/template/controller/TemplateUsageDynamicUpdateTest.java new file mode 100644 index 0000000..df4a5d5 --- /dev/null +++ b/src/test/java/com/sniptext/domain/template/controller/TemplateUsageDynamicUpdateTest.java @@ -0,0 +1,115 @@ +package com.sniptext.domain.template.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.Mockito.mockingDetails; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; + +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.mockito.stubbing.Answer; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +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.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +/** + * 사용 기록은 엔티티를 읽은 뒤 변경 감지로 쓴다. UPDATE에 last_used_at·updated_at만 들어가야 + * 읽은 뒤 다른 요청(다른 탭의 수정 등)이 커밋한 값을 옛 값으로 되돌리지 않는다 (@DynamicUpdate). + * 조회 직후 별도 트랜잭션에서 이름을 바꿔 그 경쟁을 재현한다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 별도 트랜잭션의 커밋과 요청의 UPDATE가 실제로 일어나지 않는다. 대신 만든 회원을 직접 지운다. + */ +@Import(MutableClockConfiguration.class) +class TemplateUsageDynamicUpdateTest extends IntegrationTestSupport { + + private static final LocalDateTime FIXED = LocalDateTime.of(2026, 10, 11, 19, 15); + private static final LocalDateTime LATER = FIXED.plusHours(1); + private static final String TITLE_FROM_OTHER_TAB = "다른 탭에서 바꾼 이름"; + + @MockitoSpyBean + private TemplateRepository templateRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + private MutableClock clock; + + private Long userId; + + @BeforeEach + void setUp() { + clock.setTo(FIXED); + userId = userRepository.save(UserFixture.user("usage-dynamic@sniptext.com")).getId(); + } + + // 회원을 지우면 외래키(ON DELETE CASCADE)로 템플릿도 지워진다 + @AfterEach + void tearDown() { + userRepository.deleteById(userId); + } + + @Test + @DisplayName("읽은 뒤 다른 트랜잭션이 바꾼 이름을 되돌리지 않고 last_used_at·updated_at만 쓴다") + void writesOnlyUsageColumns() throws Exception { + Template template = templateRepository.save(TemplateFixture.template(userId)); + Long templateId = template.getId(); + renameInOtherTransactionAfterRead(); + clock.setTo(LATER); + + mockMvc.perform(post("/api/v1/templates/{templateId}/usages", templateId) + .header(HttpHeaders.AUTHORIZATION, bearer(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"channel\": \"DASHBOARD_COPY\"}")) + .andExpect(status().isOk()); + + Template found = templateRepository.findById(templateId).orElseThrow(); + assertThat(found.getTitle().getValue()).isEqualTo(TITLE_FROM_OTHER_TAB); + assertThat(found.getLastUsedAt()).isEqualTo(LATER); + assertThat(found.getUpdatedAt()).isEqualTo(LATER); + assertThat(found.getEditedAt()).isEqualTo(FIXED); + } + + /** + * 사용 기록 요청이 템플릿을 읽은 직후, 요청 트랜잭션과 별개로 이름을 바꿔 커밋한다. + * 레포지토리는 인터페이스라 callRealMethod를 쓸 수 없어, 실제 빈으로 위임하는 스파이의 기본 응답으로 읽는다. + */ + private void renameInOtherTransactionAfterRead() { + Answer realRead = mockingDetails(templateRepository).getMockCreationSettings().getDefaultAnswer(); + TransactionTemplate requiresNew = new TransactionTemplate(transactionManager); + requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + willAnswer(invocation -> { + Object read = realRead.answer(invocation); + requiresNew.executeWithoutResult(status -> jdbcTemplate.update( + "update templates set title = ? where id = ?", TITLE_FROM_OTHER_TAB, invocation.getArgument(0))); + return read; + }).given(templateRepository).findByIdAndUserId(anyLong(), anyLong()); + } + +}