Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package com.sniptext.domain.template.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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.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.global.response.ApiResponse;
import com.sniptext.global.security.LoginUser;

Expand All @@ -25,6 +31,17 @@
public class TemplateController {

private final TemplateCommandService templateCommandService;
private final TemplateQueryService templateQueryService;

@GetMapping("/{templateId}")
public ResponseEntity<ApiResponse<TemplateDetailResponse>> getTemplate(
@LoginUser Long userId,
@PathVariable Long templateId) {

TemplateDetailResult result = templateQueryService.getTemplate(new TemplateDetailQuery(userId, templateId));

return ApiResponse.success(TemplateSuccessCode.TEMPLATE_FOUND, TemplateDetailResponse.from(result));
}

@PostMapping
public ResponseEntity<ApiResponse<TemplateCreateResponse>> createTemplate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
@RequiredArgsConstructor
public enum TemplateSuccessCode implements ResultCode {

TEMPLATE_FOUND(HttpStatus.OK, "TEMPLATE200_2", "템플릿 조회에 성공했습니다."),
TEMPLATE_CREATED(HttpStatus.CREATED, "TEMPLATE201_1", "템플릿이 저장되었습니다.");

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.TemplateDetailResult;

/** 내 템플릿 상세 (명세 9번). 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다. */
public record TemplateDetailResponse(
Long templateId,
String title,
String shortcut,
String content,
List<String> variables,
LocalDateTime lastUsedAt,
LocalDateTime createdAt,
LocalDateTime editedAt
) {

public static TemplateDetailResponse from(TemplateDetailResult result) {
return new TemplateDetailResponse(
result.templateId(),
result.title(),
result.shortcut(),
result.content(),
result.variables(),
result.lastUsedAt(),
result.createdAt(),
result.editedAt()
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ public enum TemplateErrorCode implements ResultCode {
CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "TEMPLATE400_7", "템플릿 내용은 3,000자까지 입력할 수 있어요."),
/** 활성 템플릿 수가 플랜 한도 이상 (화면은 한도 모달) */
TEMPLATE_LIMIT_REACHED(HttpStatus.FORBIDDEN, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."),
/** 없는 템플릿, 다른 회원의 템플릿 (다른 회원의 휴지통 템플릿 포함) */
TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "TEMPLATE404_1", "템플릿을 찾을 수 없어요."),
/** 내 템플릿인데 휴지통에 있음 */
TEMPLATE_IN_TRASH(HttpStatus.NOT_FOUND, "TEMPLATE404_2", "이미 삭제된 템플릿이에요."),
/** 내 템플릿(휴지통 제외)에 같은 단축어 키가 있음. 대소문자 무시 */
SHORTCUT_DUPLICATED(HttpStatus.CONFLICT, "TEMPLATE409_1", "이미 사용 중인 단축어예요."),
/** 자동 번호 2~99가 모두 사용 중 (갤러리 저장·휴지통 복구) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import org.springframework.data.domain.Limit;
Expand All @@ -15,6 +16,9 @@

public interface TemplateRepository extends JpaRepository<Template, Long> {

/** 본인 소유 조회. 휴지통 템플릿도 찾으므로 내 휴지통과 다른 회원 것을 호출자가 구분할 수 있다 */
Optional<Template> findByIdAndUserId(Long id, Long userId);

/** 휴지통을 제외한 보유 개수. idx_templates_user_deleted (user_id, deleted_at)를 탄다 */
long countByUserIdAndDeletedAtIsNull(Long userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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.result.TemplateDetailResult;
import com.sniptext.global.exception.BusinessException;

import lombok.RequiredArgsConstructor;

Expand All @@ -19,4 +24,22 @@ public long countActive(Long userId) {
return templateRepository.countByUserIdAndDeletedAtIsNull(userId);
}

/** 내 템플릿 상세. 없거나 다른 회원 것이면 TEMPLATE404_1, 휴지통에 있으면 TEMPLATE404_2 */
public TemplateDetailResult getTemplate(TemplateDetailQuery query) {
return TemplateDetailResult.from(getActiveTemplate(query.userId(), query.templateId()));
}

/**
* 휴지통에 없는 본인 템플릿. 수정·사용 기록도 같은 404 구분을 쓴다.
* 엔티티를 돌려주므로 template 도메인 안에서만 부른다 (패키지 범위라 service 패키지 밖에서는 부를 수 없다).
*/
Template getActiveTemplate(Long userId, Long templateId) {
Template template = templateRepository.findByIdAndUserId(templateId, userId)
.orElseThrow(() -> new BusinessException(TemplateErrorCode.TEMPLATE_NOT_FOUND));
if (template.isInTrash()) {
throw new BusinessException(TemplateErrorCode.TEMPLATE_IN_TRASH);
}
return template;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.sniptext.domain.template.service.dto.query;

public record TemplateDetailQuery(
Long userId,
Long templateId
) {
}
Original file line number Diff line number Diff line change
@@ -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 TemplateDetailResult(
Long templateId,
String title,
String shortcut,
String content,
List<String> variables,
LocalDateTime lastUsedAt,
LocalDateTime createdAt,
LocalDateTime editedAt
) {

public static TemplateDetailResult from(Template template) {
String content = template.getContent().getValue();
return new TemplateDetailResult(
template.getId(),
template.getTitle().getValue(),
template.getShortcut().getValue(),
content,
TemplateVariableExtractor.extract(content),
template.getLastUsedAt(),
template.getCreatedAt(),
template.getEditedAt()
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package com.sniptext.domain.template.controller;

import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.nullValue;
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.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.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.transaction.annotation.Transactional;

import com.sniptext.domain.template.entity.Shortcut;
import com.sniptext.domain.template.entity.Template;
import com.sniptext.domain.template.entity.TemplateContent;
import com.sniptext.domain.template.entity.TemplateTitle;
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;

/** 명세 9번. 본인 소유·휴지통 구분과 응답 형식을 실제 필터·MySQL까지 거쳐 확인한다. */
@Transactional
@Import(MutableClockConfiguration.class)
class TemplateDetailControllerTest 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 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, 명세 순서의 필드와 +09:00이 붙은 시각, 캐시 금지 헤더")
void getTemplate() throws Exception {
Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId, "/Quote"));
template.edit(TemplateTitle.create("프로젝트 견적 안내"), Shortcut.create("/Quote"), TemplateContent.create(CONTENT),
FIXED.plusHours(1));
template.markUsed(FIXED.plusHours(2));
flushAndClear();

MvcResult result = getAs(userId, template.getId())
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE))
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.code").value("TEMPLATE200_2"))
.andExpect(jsonPath("$.message").value("템플릿 조회에 성공했습니다."))
.andExpect(jsonPath("$.result.templateId").value(template.getId()))
.andExpect(jsonPath("$.result.title").value("프로젝트 견적 안내"))
.andExpect(jsonPath("$.result.shortcut").value("/Quote"))
.andExpect(jsonPath("$.result.content").value(CONTENT))
.andExpect(jsonPath("$.result.lastUsedAt").value("2026-10-07T12:30:00+09:00"))
.andExpect(jsonPath("$.result.createdAt").value("2026-10-07T10:30:00+09:00"))
.andExpect(jsonPath("$.result.editedAt").value("2026-10-07T11:30:00+09:00"))
.andReturn();

assertThat(jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
.get("result").propertyNames())
.containsExactly("templateId", "title", "shortcut", "content", "variables", "lastUsedAt", "createdAt",
"editedAt");
}

@Test
@DisplayName("변수 목록은 복호화한 본문에서 뽑아 첫 등장 순으로 중복 없이 내려준다")
void variables() throws Exception {
Template template = templateRepository.saveAndFlush(TemplateFixture.templateWithContent(userId, CONTENT));
flushAndClear();

getAs(userId, template.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.variables", contains("금액", "고객명")));
}

@Test
@DisplayName("사용 이력이 없으면 lastUsedAt은 null이다")
void neverUsed() throws Exception {
Template template = templateRepository.saveAndFlush(TemplateFixture.template(userId));
flushAndClear();

getAs(userId, template.getId())
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.lastUsedAt").value(nullValue()));
}

@Test
@DisplayName("없는 템플릿이면 TEMPLATE404_1")
void notFound() throws Exception {
expectNotFound(getAs(userId, Long.MAX_VALUE));
}

@Test
@DisplayName("다른 회원의 템플릿이면 TEMPLATE404_1")
void otherUsersTemplate() throws Exception {
Template template = templateRepository.saveAndFlush(TemplateFixture.template(otherUserId));

expectNotFound(getAs(userId, template.getId()));
}

@Test
@DisplayName("내 템플릿이 휴지통에 있으면 TEMPLATE404_2")
void trashed() throws Exception {
Template template = saveTrashed(userId);

expectError(getAs(userId, template.getId()), 404, "TEMPLATE404_2", "이미 삭제된 템플릿이에요.");
}

@Test
@DisplayName("다른 회원의 휴지통 템플릿이면 TEMPLATE404_1이다 (존재 여부를 노출하지 않는다)")
void otherUsersTrashed() throws Exception {
Template template = saveTrashed(otherUserId);

expectNotFound(getAs(userId, template.getId()));
}

@Test
@DisplayName("토큰이 없으면 COMMON401_1, 캐시 금지")
void unauthenticated() throws Exception {
expectError(mockMvc.perform(get(PATH, 1L)), 401, "COMMON401_1", "로그인이 필요해요.");
}

@ParameterizedTest
@ValueSource(strings = {"abc", "99999999999999999999"})
@DisplayName("templateId가 Long으로 바뀌지 않으면 COMMON400_1")
void invalidTemplateId(String templateId) throws Exception {
expectError(mockMvc.perform(get("/api/v1/templates/" + templateId)
.header(HttpHeaders.AUTHORIZATION, bearer(userId))), 400, "COMMON400_1", "잘못된 요청입니다.");
}

private ResultActions getAs(Long userId, Long templateId) throws Exception {
return mockMvc.perform(get(PATH, templateId).header(HttpHeaders.AUTHORIZATION, bearer(userId)));
}

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;
}

/** 1차 캐시를 비워 응답이 DB에서 다시 읽은(복호화한) 값으로 만들어지게 한다 */
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();
}

}
Loading
Loading