From 25f9a166d09ca5a831c719ab1325e8f8df188258 Mon Sep 17 00:00:00 2001 From: chjpooh2002 Date: Wed, 4 Nov 2026 09:28:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B0=A4=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=82=B4=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20API=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gallery/controller/GalleryController.java | 22 +- .../controller/GallerySuccessCode.java | 3 +- .../controller/dto/GallerySaveResponse.java | 24 +++ .../service/GalleryCommandService.java | 39 ++++ .../dto/command/GallerySaveCommand.java | 7 + .../service/dto/result/GallerySaveResult.java | 24 +++ .../controller/GallerySaveControllerTest.java | 190 ++++++++++++++++++ .../service/GalleryCommandServiceTest.java | 74 +++++++ 8 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/sniptext/domain/gallery/controller/dto/GallerySaveResponse.java create mode 100644 src/main/java/com/sniptext/domain/gallery/service/GalleryCommandService.java create mode 100644 src/main/java/com/sniptext/domain/gallery/service/dto/command/GallerySaveCommand.java create mode 100644 src/main/java/com/sniptext/domain/gallery/service/dto/result/GallerySaveResult.java create mode 100644 src/test/java/com/sniptext/domain/gallery/controller/GallerySaveControllerTest.java create mode 100644 src/test/java/com/sniptext/domain/gallery/service/GalleryCommandServiceTest.java diff --git a/src/main/java/com/sniptext/domain/gallery/controller/GalleryController.java b/src/main/java/com/sniptext/domain/gallery/controller/GalleryController.java index 3e874af..1fdfe94 100644 --- a/src/main/java/com/sniptext/domain/gallery/controller/GalleryController.java +++ b/src/main/java/com/sniptext/domain/gallery/controller/GalleryController.java @@ -3,18 +3,26 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.sniptext.domain.gallery.controller.dto.GallerySaveResponse; import com.sniptext.domain.gallery.controller.dto.GalleryTemplateListResponse; import com.sniptext.domain.gallery.controller.dto.GalleryTemplateResponse; +import com.sniptext.domain.gallery.service.GalleryCommandService; import com.sniptext.domain.gallery.service.GalleryQueryService; +import com.sniptext.domain.gallery.service.dto.command.GallerySaveCommand; +import com.sniptext.domain.gallery.service.dto.result.GallerySaveResult; import com.sniptext.global.response.ApiResponse; +import com.sniptext.global.security.LoginUser; import lombok.RequiredArgsConstructor; /** - * 갤러리 조회 (로그인 불필요, 공개 캐시 5분). 토큰이 있든 없든 같은 응답이어야 하므로 로그인 회원을 받지 않는다. + * 갤러리. + * 조회(목록·상세)는 로그인 불필요, 공개 캐시 5분. 토큰이 있든 없든 같은 응답이어야 하므로 로그인 회원을 받지 않는다. + * 내 템플릿으로 저장은 로그인 필요, 캐시 금지. */ @RestController @RequestMapping("/api/v1/gallery/templates") @@ -22,6 +30,7 @@ public class GalleryController { private final GalleryQueryService galleryQueryService; + private final GalleryCommandService galleryCommandService; @GetMapping public ResponseEntity> getGalleryTemplates() { @@ -36,4 +45,15 @@ public ResponseEntity> getGalleryTemplate( GalleryTemplateResponse.from(galleryQueryService.getGalleryTemplate(galleryTemplateId))); } + @PostMapping("/{galleryTemplateId}/save") + public ResponseEntity> saveToMyTemplates( + @LoginUser Long userId, + @PathVariable Long galleryTemplateId) { + + GallerySaveResult result = galleryCommandService.saveToMyTemplates( + new GallerySaveCommand(userId, galleryTemplateId)); + + return ApiResponse.success(GallerySuccessCode.GALLERY_TEMPLATE_SAVED, GallerySaveResponse.from(result)); + } + } diff --git a/src/main/java/com/sniptext/domain/gallery/controller/GallerySuccessCode.java b/src/main/java/com/sniptext/domain/gallery/controller/GallerySuccessCode.java index 3f746ca..5aa2cab 100644 --- a/src/main/java/com/sniptext/domain/gallery/controller/GallerySuccessCode.java +++ b/src/main/java/com/sniptext/domain/gallery/controller/GallerySuccessCode.java @@ -12,7 +12,8 @@ public enum GallerySuccessCode implements ResultCode { GALLERY_TEMPLATES_FOUND(HttpStatus.OK, "GALLERY200_2", "갤러리 조회에 성공했습니다."), - GALLERY_TEMPLATE_FOUND(HttpStatus.OK, "GALLERY200_3", "갤러리 템플릿 조회에 성공했습니다."); + GALLERY_TEMPLATE_FOUND(HttpStatus.OK, "GALLERY200_3", "갤러리 템플릿 조회에 성공했습니다."), + GALLERY_TEMPLATE_SAVED(HttpStatus.CREATED, "GALLERY201_1", "내 템플릿에 저장되었습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/sniptext/domain/gallery/controller/dto/GallerySaveResponse.java b/src/main/java/com/sniptext/domain/gallery/controller/dto/GallerySaveResponse.java new file mode 100644 index 0000000..b313044 --- /dev/null +++ b/src/main/java/com/sniptext/domain/gallery/controller/dto/GallerySaveResponse.java @@ -0,0 +1,24 @@ +package com.sniptext.domain.gallery.controller.dto; + +import com.sniptext.domain.gallery.service.dto.result.GallerySaveResult; + +/** 명세 21번 저장 결과. 개인 데이터라 캐시하지 않는다. */ +public record GallerySaveResponse( + Long templateId, + String title, + String shortcut, + String originalShortcut, + boolean shortcutChanged +) { + + public static GallerySaveResponse from(GallerySaveResult result) { + return new GallerySaveResponse( + result.templateId(), + result.title(), + result.shortcut(), + result.originalShortcut(), + result.shortcutChanged() + ); + } + +} diff --git a/src/main/java/com/sniptext/domain/gallery/service/GalleryCommandService.java b/src/main/java/com/sniptext/domain/gallery/service/GalleryCommandService.java new file mode 100644 index 0000000..940a2f0 --- /dev/null +++ b/src/main/java/com/sniptext/domain/gallery/service/GalleryCommandService.java @@ -0,0 +1,39 @@ +package com.sniptext.domain.gallery.service; + +import org.springframework.stereotype.Service; + +import com.sniptext.domain.gallery.service.dto.command.GallerySaveCommand; +import com.sniptext.domain.gallery.service.dto.result.GallerySaveResult; +import com.sniptext.domain.gallery.service.dto.result.GalleryTemplateResult; +import com.sniptext.domain.template.service.TemplateCommandService; +import com.sniptext.domain.template.service.dto.command.TemplateCreateFromGalleryCommand; + +import lombok.RequiredArgsConstructor; + +/** + * 갤러리 템플릿을 내 템플릿으로 저장. + * 서비스 클래스에 readOnly 트랜잭션을 거는 규칙의 예외로, 클래스·메서드 어디에도 @Transactional을 붙이지 않는다. + * createFromGallery는 회원 잠금이 트랜잭션의 첫 조회여야 해서 바깥 트랜잭션이 있으면 예외를 던진다. + * 원본 조회는 자체 조회 트랜잭션에서 끝내고, 그 뒤에 저장을 부른다. + */ +@Service +@RequiredArgsConstructor +public class GalleryCommandService { + + private final GalleryQueryService galleryQueryService; + + private final TemplateCommandService templateCommandService; + + /** 없거나 비공개면 GALLERY404_1. 한도·자동 번호 에러는 createFromGallery가 던진다 */ + public GallerySaveResult saveToMyTemplates(GallerySaveCommand command) { + GalleryTemplateResult original = galleryQueryService.getGalleryTemplate(command.galleryTemplateId()); + + return GallerySaveResult.from(templateCommandService.createFromGallery(new TemplateCreateFromGalleryCommand( + command.userId(), + command.galleryTemplateId(), + original.title(), + original.shortcut(), + original.content()))); + } + +} diff --git a/src/main/java/com/sniptext/domain/gallery/service/dto/command/GallerySaveCommand.java b/src/main/java/com/sniptext/domain/gallery/service/dto/command/GallerySaveCommand.java new file mode 100644 index 0000000..505e01b --- /dev/null +++ b/src/main/java/com/sniptext/domain/gallery/service/dto/command/GallerySaveCommand.java @@ -0,0 +1,7 @@ +package com.sniptext.domain.gallery.service.dto.command; + +public record GallerySaveCommand( + Long userId, + Long galleryTemplateId +) { +} diff --git a/src/main/java/com/sniptext/domain/gallery/service/dto/result/GallerySaveResult.java b/src/main/java/com/sniptext/domain/gallery/service/dto/result/GallerySaveResult.java new file mode 100644 index 0000000..cb1bd6e --- /dev/null +++ b/src/main/java/com/sniptext/domain/gallery/service/dto/result/GallerySaveResult.java @@ -0,0 +1,24 @@ +package com.sniptext.domain.gallery.service.dto.result; + +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; + +/** 내 템플릿으로 저장한 결과. 컨트롤러가 template의 결과 객체에 묶이지 않도록 gallery 쪽으로 옮겨 담는다. */ +public record GallerySaveResult( + Long templateId, + String title, + String shortcut, + String originalShortcut, + boolean shortcutChanged +) { + + public static GallerySaveResult from(TemplateShortcutAssignResult result) { + return new GallerySaveResult( + result.templateId(), + result.title(), + result.shortcut(), + result.originalShortcut(), + result.shortcutChanged() + ); + } + +} diff --git a/src/test/java/com/sniptext/domain/gallery/controller/GallerySaveControllerTest.java b/src/test/java/com/sniptext/domain/gallery/controller/GallerySaveControllerTest.java new file mode 100644 index 0000000..78601b8 --- /dev/null +++ b/src/test/java/com/sniptext/domain/gallery/controller/GallerySaveControllerTest.java @@ -0,0 +1,190 @@ +package com.sniptext.domain.gallery.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willAnswer; +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.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.ResultActions; + +import com.sniptext.domain.gallery.repository.GalleryTemplateRepository; +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.GalleryTemplateFixture; +import com.sniptext.fixture.TemplateFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.support.IntegrationTestSupport; + +/** + * 명세 21번. 상태 코드·응답 필드·에러 변환·인증·캐시 헤더를 실제 필터까지 거쳐 확인한다. + * 한도·자동 번호 규칙의 세부 경우는 TemplateCreateFromGalleryTest가 맡는다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 createFromGallery가 바깥 트랜잭션으로 보고 예외를 던진다. + * 그래서 저장이 성공하는 것으로 갤러리 저장 서비스가 트랜잭션 없이 부른다는 것도 확인된다. 대신 만든 행을 직접 지운다. + */ +class GallerySaveControllerTest extends IntegrationTestSupport { + + private static final String SAVE_PATH = "/api/v1/gallery/templates/{id}/save"; + private static final String NO_STORE = "no-store"; + + @MockitoSpyBean + private TemplateRepository templateRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private GalleryTemplateRepository galleryTemplateRepository; + + private final List galleryTemplateIds = new ArrayList<>(); + private Long userId; + private Long galleryTemplateId; + + @BeforeEach + void setUp() { + userId = userRepository.save(UserFixture.user("gallery-save-api@sniptext.com")).getId(); + galleryTemplateId = saveGalleryTemplate(true); + } + + // 회원을 지우면 외래키(ON DELETE CASCADE)로 템플릿도 지워진다. 갤러리는 이 테스트가 만든 행만 지운다 + @AfterEach + void tearDown() { + userRepository.deleteById(userId); + galleryTemplateIds.forEach(galleryTemplateRepository::deleteById); + } + + @Test + @DisplayName("단축어가 겹치지 않으면 201, 원본 단축어 그대로 저장하고 응답 필드 5개를 담는다") + void saved() throws Exception { + save(galleryTemplateId) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("GALLERY201_1")) + .andExpect(jsonPath("$.message").value("내 템플릿에 저장되었습니다.")) + .andExpect(jsonPath("$.result.templateId").isNumber()) + .andExpect(jsonPath("$.result.title").value(GalleryTemplateFixture.TITLE)) + .andExpect(jsonPath("$.result.shortcut").value(GalleryTemplateFixture.SHORTCUT)) + .andExpect(jsonPath("$.result.originalShortcut").value(GalleryTemplateFixture.SHORTCUT)) + .andExpect(jsonPath("$.result.shortcutChanged").value(false)) + .andExpect(jsonPath("$.result.length()").value(5)); + } + + @Test + @DisplayName("단축어가 겹치면 /견적2로 저장하고 originalShortcut은 원본, shortcutChanged는 true다") + void shortcutNumbered() throws Exception { + templateRepository.save(TemplateFixture.template(userId, GalleryTemplateFixture.SHORTCUT)); + + save(galleryTemplateId) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.result.shortcut").value("/견적2")) + .andExpect(jsonPath("$.result.originalShortcut").value(GalleryTemplateFixture.SHORTCUT)) + .andExpect(jsonPath("$.result.shortcutChanged").value(true)); + } + + @Test + @DisplayName("같은 갤러리 템플릿을 두 번 저장하면 템플릿이 2개 생기고 둘 다 출처가 기록된다") + void savedTwice() throws Exception { + save(galleryTemplateId).andExpect(status().isCreated()); + save(galleryTemplateId).andExpect(status().isCreated()); + + assertThat(myTemplates()) + .hasSize(2) + .extracting(Template::getSourceGalleryTemplateId) + .containsOnly(galleryTemplateId); + } + + @Test + @DisplayName("활성 템플릿 50개에서 저장하면 TEMPLATE403_1이고 개수가 그대로다") + void limitReached() throws Exception { + IntStream.rangeClosed(1, 50) + .forEach(number -> templateRepository.save(TemplateFixture.template(userId, "/t" + number))); + + expectError(save(galleryTemplateId), 403, "TEMPLATE403_1", "템플릿 저장 한도에 도달했어요."); + + assertThat(templateRepository.countByUserIdAndDeletedAtIsNull(userId)).isEqualTo(50); + } + + @Test + @DisplayName("없는 갤러리 템플릿 ID면 GALLERY404_1이고 내 템플릿이 생기지 않는다") + void missingGalleryTemplate() throws Exception { + expectError(save(galleryTemplateId + 1_000), 404, "GALLERY404_1", "갤러리 템플릿을 찾을 수 없어요."); + + assertThat(myTemplates()).isEmpty(); + } + + @Test + @DisplayName("비공개 갤러리 템플릿이면 GALLERY404_1이고 내 템플릿이 생기지 않는다") + void unpublishedGalleryTemplate() throws Exception { + Long hiddenId = saveGalleryTemplate(false); + + expectError(save(hiddenId), 404, "GALLERY404_1", "갤러리 템플릿을 찾을 수 없어요."); + + assertThat(myTemplates()).isEmpty(); + } + + @Test + @DisplayName("토큰 없이 요청하면 COMMON401_1, 캐시 금지") + void unauthenticated() throws Exception { + expectError(mockMvc.perform(post(SAVE_PATH, galleryTemplateId)), 401, "COMMON401_1", "로그인이 필요해요."); + + assertThat(myTemplates()).isEmpty(); + } + + /** 번호 2~99 소진은 한도 50에서 실제로 닿지 않는다. 후보 키를 모두 사용 중으로 돌려줘 서비스의 예외가 응답으로 바뀌는지만 본다 */ + @Test + @DisplayName("자동 번호를 모두 쓰면 TEMPLATE409_2 에러 응답이다") + void shortcutNumberExhausted() throws Exception { + willAnswer(invocation -> Set.copyOf(invocation.>getArgument(1))) + .given(templateRepository).findActiveShortcutKeys(anyLong(), anyCollection()); + + expectError(save(galleryTemplateId), 409, "TEMPLATE409_2", "사용할 수 있는 단축어 번호가 없어요."); + + assertThat(myTemplates()).isEmpty(); + } + + private ResultActions save(Long id) throws Exception { + return mockMvc.perform(post(SAVE_PATH, id).header(HttpHeaders.AUTHORIZATION, bearer(userId))); + } + + private static void expectError(ResultActions actions, int status, String code, String message) throws Exception { + actions + .andExpect(status().is(status)) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, NO_STORE)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(code)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.result").isEmpty()); + } + + private Long saveGalleryTemplate(boolean published) { + Long id = galleryTemplateRepository.save( + published ? GalleryTemplateFixture.published(1) : GalleryTemplateFixture.unpublished(1)).getId(); + galleryTemplateIds.add(id); + return id; + } + + private List