From 115f02dd2fa05e6976426f85347952a36327e00c Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 27 Jan 2026 22:03:46 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(admin):=20=ED=85=8C=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=ED=83=9C=EA=B7=B8=20CRUD=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이스팅 태그 상세 조회 (조상/자식/연결된 위스키 포함) - 테이스팅 태그 생성/수정/삭제 - 위스키 벌크 연결/해제 기능 - 트리 구조 지원 (단일 부모, 최대 3depth) - 중복 이름 검증, 자식/연결 위스키 존재 시 삭제 방지 - DTO-엔티티 분리 아키텍처 규칙 준수 Co-Authored-By: Claude Opus 4.5 --- .../presentation/AdminTastingTagController.kt | 51 ++- .../app/helper/alcohols/AlcoholsHelper.kt | 1 + .../AdminTastingTagIntegrationTest.kt | 420 ++++++++++++++++++ .../domain/AlcoholsTastingTagsRepository.java | 14 + .../alcohols/domain/TastingTag.java | 24 +- .../alcohols/domain/TastingTagRepository.java | 15 + .../AdminTastingTagAlcoholRequest.java | 7 + .../request/AdminTastingTagUpsertRequest.java | 10 + .../AdminTastingTagDetailResponse.java | 38 ++ .../dto/response/AdminTastingTagItem.java | 1 + .../exception/AlcoholExceptionCode.java | 8 +- .../JpaAlcoholsTastingTagsRepository.java | 34 ++ .../repository/JpaTastingTagRepository.java | 19 +- .../alcohols/service/TastingTagService.java | 224 ++++++++++ .../dto/response/AdminResultResponse.java | 5 + .../fixture/InMemoryTastingTagRepository.java | 62 ++- .../fixture/TastingTagTestFactory.java | 131 ++++++ .../service/TastingTagServiceTest.java | 11 +- git.environment-variables | 2 +- 19 files changed, 1057 insertions(+), 20 deletions(-) create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt index a6077d061..e49d029a4 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt @@ -1,11 +1,20 @@ package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.dto.request.AdminTastingTagAlcoholRequest +import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest import app.bottlenote.alcohols.service.AlcoholReferenceService +import app.bottlenote.alcohols.service.TastingTagService import app.bottlenote.global.data.response.GlobalResponse +import jakarta.validation.Valid import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -13,11 +22,51 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/tasting-tags") class AdminTastingTagController( - private val alcoholReferenceService: AlcoholReferenceService + private val alcoholReferenceService: AlcoholReferenceService, + private val tastingTagService: TastingTagService ) { @GetMapping fun getAllTastingTags(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { return ResponseEntity.ok(alcoholReferenceService.findAllTastingTags(request)) } + + @GetMapping("/{tagId}") + fun getTagDetail(@PathVariable tagId: Long): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.getTagDetail(tagId)) + } + + @PostMapping + fun createTag(@RequestBody @Valid request: AdminTastingTagUpsertRequest): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.createTag(request)) + } + + @PutMapping("/{tagId}") + fun updateTag( + @PathVariable tagId: Long, + @RequestBody @Valid request: AdminTastingTagUpsertRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.updateTag(tagId, request)) + } + + @DeleteMapping("/{tagId}") + fun deleteTag(@PathVariable tagId: Long): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.deleteTag(tagId)) + } + + @PostMapping("/{tagId}/alcohols") + fun addAlcoholsToTag( + @PathVariable tagId: Long, + @RequestBody @Valid request: AdminTastingTagAlcoholRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.addAlcoholsToTag(tagId, request.alcoholIds())) + } + + @DeleteMapping("/{tagId}/alcohols") + fun removeAlcoholsFromTag( + @PathVariable tagId: Long, + @RequestBody @Valid request: AdminTastingTagAlcoholRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(tastingTagService.removeAlcoholsFromTag(tagId, request.alcoholIds())) + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 1ec30f963..b51c932ae 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -112,6 +112,7 @@ object AlcoholsHelper { "Tag$i", "icon$i.png", "테이스팅 태그 설명 $i", + null, LocalDateTime.of(2024, 1, i, 0, 0), LocalDateTime.of(2024, 6, i, 0, 0) ) diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt new file mode 100644 index 000000000..ddb1e39fc --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt @@ -0,0 +1,420 @@ +package app.integration.alcohols + +import app.IntegrationTestSupport +import app.bottlenote.alcohols.fixture.AlcoholTestFactory +import app.bottlenote.alcohols.fixture.TastingTagTestFactory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType + +@Tag("admin_integration") +@DisplayName("[integration] Admin TastingTag API 통합 테스트") +class AdminTastingTagIntegrationTest : IntegrationTestSupport() { + + @Autowired + private lateinit var tastingTagTestFactory: TastingTagTestFactory + + @Autowired + private lateinit var alcoholTestFactory: AlcoholTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("테이스팅 태그 단건 조회 API") + inner class GetTagDetail { + + @Test + @DisplayName("테이스팅 태그 상세 정보를 조회할 수 있다") + fun getTagDetailSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag("허니", "Honey") + + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + assertThat( + mockMvcTester.get().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.korName").isEqualTo("허니") + } + + @Test + @DisplayName("부모 태그가 있는 경우 조상 정보가 포함된다") + fun getTagDetailWithAncestors() { + // given + val tree = tastingTagTestFactory.persistTastingTagTree() + val leafTag = tree[2] + + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/${leafTag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.ancestors.length()").isEqualTo(2) + } + + @Test + @DisplayName("연결된 위스키 목록이 포함된다") + fun getTagDetailWithAlcohols() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol = alcoholTestFactory.persistAlcohol() + tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) + + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.alcohols.length()").isEqualTo(1) + } + + @Test + @DisplayName("존재하지 않는 태그 조회 시 실패한다") + fun getTagDetailNotFound() { + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 생성 API") + inner class CreateTag { + + @Test + @DisplayName("테이스팅 태그를 생성할 수 있다") + fun createTagSuccess() { + // given + val request = mapOf( + "korName" to "새로운 태그", + "engName" to "New Tag", + "description" to "테스트 설명" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_CREATED") + } + + @Test + @DisplayName("부모 태그를 지정하여 생성할 수 있다") + fun createTagWithParent() { + // given + val parent = tastingTagTestFactory.persistTastingTag("부모 태그", "Parent Tag") + val request = mapOf( + "korName" to "자식 태그", + "engName" to "Child Tag", + "parentId" to parent.id + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_CREATED") + } + + @Test + @DisplayName("중복된 한글 이름으로 생성 시 실패한다") + fun createTagDuplicateName() { + // given + tastingTagTestFactory.persistTastingTag("중복 태그", "Duplicate Tag") + val request = mapOf( + "korName" to "중복 태그", + "engName" to "Another Tag" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("최대 깊이를 초과하는 태그 생성 시 실패한다") + fun createTagExceedMaxDepth() { + // given - 3depth 트리 생성 (root -> middle -> leaf) + val tree = tastingTagTestFactory.persistTastingTagTree() + val leafTag = tree[2] + + val request = mapOf( + "korName" to "4depth 태그", + "engName" to "4depth Tag", + "parentId" to leafTag.id + ) + + // when & then - 4depth 생성 시도 시 실패 + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 수정 API") + inner class UpdateTag { + + @Test + @DisplayName("테이스팅 태그를 수정할 수 있다") + fun updateTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val request = mapOf( + "korName" to "수정된 태그", + "engName" to "Updated Tag", + "description" to "수정된 설명" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_UPDATED") + } + + @Test + @DisplayName("다른 태그와 중복되는 이름으로 수정 시 실패한다") + fun updateTagDuplicateName() { + // given + tastingTagTestFactory.persistTastingTag("기존 태그", "Existing Tag") + val targetTag = tastingTagTestFactory.persistTastingTag("수정 대상", "Target Tag") + + val request = mapOf( + "korName" to "기존 태그", + "engName" to "Updated Tag" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/tasting-tags/${targetTag.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("존재하지 않는 태그 수정 시 실패한다") + fun updateTagNotFound() { + // given + val request = mapOf( + "korName" to "수정된 태그", + "engName" to "Updated Tag" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/tasting-tags/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 삭제 API") + inner class DeleteTag { + + @Test + @DisplayName("테이스팅 태그를 삭제할 수 있다") + fun deleteTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_DELETED") + } + + @Test + @DisplayName("자식 태그가 존재하는 경우 삭제할 수 없다") + fun deleteTagWithChildren() { + // given + val parent = tastingTagTestFactory.persistTastingTag("부모", "Parent") + tastingTagTestFactory.persistTastingTagWithParent(parent) + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${parent.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("연결된 위스키가 존재하는 경우 삭제할 수 없다") + fun deleteTagWithAlcohols() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol = alcoholTestFactory.persistAlcohol() + tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${tag.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("존재하지 않는 태그 삭제 시 실패한다") + fun deleteTagNotFound() { + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("테이스팅 태그 위스키 연결 API") + inner class ManageAlcohols { + + @Test + @DisplayName("위스키를 태그에 벌크로 연결할 수 있다") + fun addAlcoholsToTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + + val request = mapOf("alcoholIds" to listOf(alcohol1.id, alcohol2.id)) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags/${tag.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_ALCOHOL_ADDED") + } + + @Test + @DisplayName("위스키 연결을 벌크로 해제할 수 있다") + fun removeAlcoholsFromTagSuccess() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + tastingTagTestFactory.linkAlcoholToTag(alcohol1, tag) + tastingTagTestFactory.linkAlcoholToTag(alcohol2, tag) + + val request = mapOf("alcoholIds" to listOf(alcohol1.id)) + + // when & then + assertThat( + mockMvcTester.delete().uri("/tasting-tags/${tag.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("TASTING_TAG_ALCOHOL_REMOVED") + } + + @Test + @DisplayName("존재하지 않는 위스키 연결 시 실패한다") + fun addAlcoholsNotFound() { + // given + val tag = tastingTagTestFactory.persistTastingTag() + val request = mapOf("alcoholIds" to listOf(999999L)) + + // when & then + assertThat( + mockMvcTester.post().uri("/tasting-tags/${tag.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("인증 테스트") + inner class AuthenticationTest { + + @Test + @DisplayName("인증 없이 요청 시 실패한다") + fun requestWithoutAuth() { + // when & then + assertThat(mockMvcTester.get().uri("/tasting-tags/1")) + .hasStatus4xxClientError() + + assertThat( + mockMvcTester.post().uri("/tasting-tags") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(mapOf("korName" to "테스트", "engName" to "Test"))) + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java new file mode 100644 index 000000000..97383ad96 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholsTastingTagsRepository.java @@ -0,0 +1,14 @@ +package app.bottlenote.alcohols.domain; + +import java.util.List; + +public interface AlcoholsTastingTagsRepository { + + List findByTastingTagId(Long tastingTagId); + + List saveAll(Iterable alcoholsTastingTags); + + void deleteByTastingTagIdAndAlcoholIdIn(Long tastingTagId, List alcoholIds); + + boolean existsByTastingTagId(Long tastingTagId); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java index b836dcd3a..2f01db402 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTag.java @@ -6,6 +6,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Lob; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.ArrayList; @@ -40,19 +41,36 @@ public class TastingTag extends BaseEntity { @Column(name = "kor_name", nullable = false) private String korName; - // base64 이미지로 변환해도 될듯 - @Comment("아이콘") - @Column(name = "icon") + @Lob + @Comment("아이콘 (Base64 이미지)") + @Column(name = "icon", columnDefinition = "MEDIUMTEXT") private String icon; @Comment("태그 설명") @Column(name = "description") private String description; + @Comment("부모 태그 ID (null이면 root)") + @Column(name = "parent_id") + private Long parentId; + @Builder.Default @OneToMany(mappedBy = "tastingTag") private List alcoholsTastingTags = new ArrayList<>(); + public void update( + String korName, String engName, String icon, String description, Long parentId) { + this.korName = korName; + this.engName = engName; + this.icon = icon; + this.description = description; + this.parentId = parentId; + } + + public boolean isRoot() { + return this.parentId == null; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java index 34953cfa4..343d8a28f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java @@ -2,6 +2,7 @@ import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -10,4 +11,18 @@ public interface TastingTagRepository { List findAll(); Page findAllTastingTags(String keyword, Pageable pageable); + + Optional findById(Long id); + + Optional findByKorName(String korName); + + List findByParentId(Long parentId); + + TastingTag save(TastingTag tastingTag); + + void delete(TastingTag tastingTag); + + boolean existsByKorNameAndIdNot(String korName, Long id); + + boolean existsByParentId(Long parentId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java new file mode 100644 index 000000000..307a3da95 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagAlcoholRequest.java @@ -0,0 +1,7 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record AdminTastingTagAlcoholRequest( + @NotEmpty(message = "위스키 ID 목록은 필수입니다.") List alcoholIds) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java new file mode 100644 index 000000000..f173c2b7a --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminTastingTagUpsertRequest.java @@ -0,0 +1,10 @@ +package app.bottlenote.alcohols.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminTastingTagUpsertRequest( + @NotBlank(message = "한글 이름은 필수입니다.") String korName, + @NotBlank(message = "영문 이름은 필수입니다.") String engName, + String icon, + String description, + Long parentId) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java new file mode 100644 index 000000000..53b61f9db --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagDetailResponse.java @@ -0,0 +1,38 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record AdminTastingTagDetailResponse( + Long id, + String korName, + String engName, + String icon, + String description, + AdminTastingTagItem parent, + List ancestors, + List children, + List alcohols, + LocalDateTime createdAt, + LocalDateTime modifiedAt) { + + public static AdminTastingTagDetailResponse of( + AdminTastingTagItem tagItem, + AdminTastingTagItem parent, + List ancestors, + List children, + List alcohols) { + return new AdminTastingTagDetailResponse( + tagItem.id(), + tagItem.korName(), + tagItem.engName(), + tagItem.icon(), + tagItem.description(), + parent, + ancestors, + children, + alcohols, + tagItem.createdAt(), + tagItem.modifiedAt()); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java index c7fa78074..93b4c8bf1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java @@ -8,5 +8,6 @@ public record AdminTastingTagItem( String engName, String icon, String description, + Long parentId, LocalDateTime createdAt, LocalDateTime modifiedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java index 5101c39ab..27d626e79 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/exception/AlcoholExceptionCode.java @@ -9,7 +9,13 @@ public enum AlcoholExceptionCode implements ExceptionCode { DISTILLERY_NOT_FOUND(HttpStatus.NOT_FOUND, "증류소를 찾을 수 없습니다."), ALCOHOL_HAS_REVIEWS(HttpStatus.CONFLICT, "리뷰가 존재하는 위스키는 삭제할 수 없습니다."), ALCOHOL_HAS_RATINGS(HttpStatus.CONFLICT, "평점이 존재하는 위스키는 삭제할 수 없습니다."), - ALCOHOL_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 위스키입니다."); + ALCOHOL_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 위스키입니다."), + TASTING_TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "테이스팅 태그를 찾을 수 없습니다."), + TASTING_TAG_DUPLICATE_NAME(HttpStatus.CONFLICT, "동일한 한글 이름의 태그가 이미 존재합니다."), + TASTING_TAG_HAS_CHILDREN(HttpStatus.CONFLICT, "자식 태그가 존재하는 태그는 삭제할 수 없습니다."), + TASTING_TAG_HAS_ALCOHOLS(HttpStatus.CONFLICT, "연결된 위스키가 존재하는 태그는 삭제할 수 없습니다."), + TASTING_TAG_PARENT_NOT_FOUND(HttpStatus.NOT_FOUND, "부모 태그를 찾을 수 없습니다."), + TASTING_TAG_MAX_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "태그 계층 구조는 최대 3단계까지 가능합니다."); private final HttpStatus httpStatus; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java new file mode 100644 index 000000000..3489a32e4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaAlcoholsTastingTagsRepository.java @@ -0,0 +1,34 @@ +package app.bottlenote.alcohols.repository; + +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +@JpaRepositoryImpl +public interface JpaAlcoholsTastingTagsRepository + extends AlcoholsTastingTagsRepository, JpaRepository { + + @Override + @Query("select att from alcohol_tasting_tags att where att.tastingTag.id = :tastingTagId") + List findByTastingTagId(@Param("tastingTagId") Long tastingTagId); + + @Override + @Modifying + @Query( + """ + delete from alcohol_tasting_tags att + where att.tastingTag.id = :tastingTagId and att.alcohol.id in :alcoholIds + """) + void deleteByTastingTagIdAndAlcoholIdIn( + @Param("tastingTagId") Long tastingTagId, @Param("alcoholIds") List alcoholIds); + + @Override + @Query( + "select case when count(att) > 0 then true else false end from alcohol_tasting_tags att where att.tastingTag.id = :tastingTagId") + boolean existsByTastingTagId(@Param("tastingTagId") Long tastingTagId); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java index f1c343aed..d36a422ae 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java @@ -4,6 +4,8 @@ import app.bottlenote.alcohols.domain.TastingTagRepository; import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; +import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; @@ -18,7 +20,7 @@ public interface JpaTastingTagRepository @Query( """ select new app.bottlenote.alcohols.dto.response.AdminTastingTagItem( - t.id, t.korName, t.engName, t.icon, t.description, t.createAt, t.lastModifyAt + t.id, t.korName, t.engName, t.icon, t.description, t.parentId, t.createAt, t.lastModifyAt ) from tasting_tag t where (:keyword is null or :keyword = '' @@ -26,4 +28,19 @@ or t.korName like concat('%', :keyword, '%') or t.engName like concat('%', :keyword, '%')) """) Page findAllTastingTags(@Param("keyword") String keyword, Pageable pageable); + + @Override + Optional findById(Long id); + + @Override + Optional findByKorName(String korName); + + @Override + List findByParentId(Long parentId); + + @Override + boolean existsByKorNameAndIdNot(String korName, Long id); + + @Override + boolean existsByParentId(Long parentId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java index f5331dac8..eaec13272 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java @@ -1,7 +1,31 @@ package app.bottlenote.alcohols.service; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_DUPLICATE_NAME; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_HAS_ALCOHOLS; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_HAS_CHILDREN; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_MAX_DEPTH_EXCEEDED; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.TASTING_TAG_PARENT_NOT_FOUND; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_ADDED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_REMOVED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_CREATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_DELETED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.TASTING_TAG_UPDATED; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.domain.TastingTagRepository; +import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest; +import app.bottlenote.alcohols.dto.response.AdminAlcoholItem; +import app.bottlenote.alcohols.dto.response.AdminTastingTagDetailResponse; +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.global.dto.response.AdminResultResponse; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +42,11 @@ @RequiredArgsConstructor public class TastingTagService { + private static final int MAX_DEPTH = 3; + private final TastingTagRepository tastingTagRepository; + private final AlcoholsTastingTagsRepository alcoholsTastingTagsRepository; + private final AlcoholQueryRepository alcoholQueryRepository; private volatile Trie trie; @@ -52,4 +80,200 @@ public List extractTagNames(String text) { return trie.parseText(text).stream().map(Emit::getKeyword).distinct().toList(); } + + @Transactional(readOnly = true) + public AdminTastingTagDetailResponse getTagDetail(Long tagId) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + AdminTastingTagItem parent = null; + if (tag.getParentId() != null) { + parent = + tastingTagRepository + .findById(tag.getParentId()) + .map(this::toAdminTastingTagItem) + .orElse(null); + } + + List ancestors = findAncestors(tag.getParentId(), MAX_DEPTH); + List children = + tastingTagRepository.findByParentId(tagId).stream() + .map(this::toAdminTastingTagItem) + .toList(); + + List alcohols = + alcoholsTastingTagsRepository.findByTastingTagId(tagId).stream() + .map(att -> toAdminAlcoholItem(att.getAlcohol())) + .toList(); + + AdminTastingTagItem tagItem = toAdminTastingTagItem(tag); + return AdminTastingTagDetailResponse.of(tagItem, parent, ancestors, children, alcohols); + } + + @Transactional + public AdminResultResponse createTag(AdminTastingTagUpsertRequest request) { + if (tastingTagRepository.findByKorName(request.korName()).isPresent()) { + throw new AlcoholException(TASTING_TAG_DUPLICATE_NAME); + } + + validateParentAndDepth(request.parentId()); + + TastingTag tag = + TastingTag.builder() + .korName(request.korName()) + .engName(request.engName()) + .icon(request.icon()) + .description(request.description()) + .parentId(request.parentId()) + .build(); + + TastingTag saved = tastingTagRepository.save(tag); + return AdminResultResponse.of(TASTING_TAG_CREATED, saved.getId()); + } + + @Transactional + public AdminResultResponse updateTag(Long tagId, AdminTastingTagUpsertRequest request) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + if (tastingTagRepository.existsByKorNameAndIdNot(request.korName(), tagId)) { + throw new AlcoholException(TASTING_TAG_DUPLICATE_NAME); + } + + if (request.parentId() != null && !request.parentId().equals(tag.getParentId())) { + validateParentAndDepth(request.parentId()); + } + + tag.update( + request.korName(), + request.engName(), + request.icon(), + request.description(), + request.parentId()); + + return AdminResultResponse.of(TASTING_TAG_UPDATED, tagId); + } + + @Transactional + public AdminResultResponse deleteTag(Long tagId) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + if (tastingTagRepository.existsByParentId(tagId)) { + throw new AlcoholException(TASTING_TAG_HAS_CHILDREN); + } + + if (alcoholsTastingTagsRepository.existsByTastingTagId(tagId)) { + throw new AlcoholException(TASTING_TAG_HAS_ALCOHOLS); + } + + tastingTagRepository.delete(tag); + return AdminResultResponse.of(TASTING_TAG_DELETED, tagId); + } + + @Transactional + public AdminResultResponse addAlcoholsToTag(Long tagId, List alcoholIds) { + TastingTag tag = + tastingTagRepository + .findById(tagId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_NOT_FOUND)); + + List newMappings = new ArrayList<>(); + for (Long alcoholId : alcoholIds) { + Alcohol alcohol = + alcoholQueryRepository + .findById(alcoholId) + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); + newMappings.add(AlcoholsTastingTags.of(alcohol, tag)); + } + + alcoholsTastingTagsRepository.saveAll(newMappings); + return AdminResultResponse.of(TASTING_TAG_ALCOHOL_ADDED, tagId); + } + + @Transactional + public AdminResultResponse removeAlcoholsFromTag(Long tagId, List alcoholIds) { + if (!tastingTagRepository.findById(tagId).isPresent()) { + throw new AlcoholException(TASTING_TAG_NOT_FOUND); + } + + alcoholsTastingTagsRepository.deleteByTastingTagIdAndAlcoholIdIn(tagId, alcoholIds); + return AdminResultResponse.of(TASTING_TAG_ALCOHOL_REMOVED, tagId); + } + + private List findAncestors(Long parentId, int maxDepth) { + List ancestors = new ArrayList<>(); + Long currentParentId = parentId; + int depth = 0; + + while (currentParentId != null && depth < maxDepth) { + TastingTag parent = tastingTagRepository.findById(currentParentId).orElse(null); + if (parent == null) break; + + ancestors.add(toAdminTastingTagItem(parent)); + currentParentId = parent.getParentId(); + depth++; + } + + return ancestors; + } + + private void validateParentAndDepth(Long parentId) { + if (parentId == null) return; + + TastingTag parent = + tastingTagRepository + .findById(parentId) + .orElseThrow(() -> new AlcoholException(TASTING_TAG_PARENT_NOT_FOUND)); + + int parentDepth = calculateDepth(parent); + if (parentDepth >= MAX_DEPTH) { + throw new AlcoholException(TASTING_TAG_MAX_DEPTH_EXCEEDED); + } + } + + private int calculateDepth(TastingTag tag) { + int depth = 1; + Long currentParentId = tag.getParentId(); + + while (currentParentId != null && depth < MAX_DEPTH) { + TastingTag parent = tastingTagRepository.findById(currentParentId).orElse(null); + if (parent == null) break; + + currentParentId = parent.getParentId(); + depth++; + } + + return depth; + } + + private AdminAlcoholItem toAdminAlcoholItem(Alcohol alcohol) { + return new AdminAlcoholItem( + alcohol.getId(), + alcohol.getKorName(), + alcohol.getEngName(), + alcohol.getKorCategory(), + alcohol.getEngCategory(), + alcohol.getImageUrl(), + alcohol.getCreateAt(), + alcohol.getLastModifyAt()); + } + + private AdminTastingTagItem toAdminTastingTagItem(TastingTag tag) { + return new AdminTastingTagItem( + tag.getId(), + tag.getKorName(), + tag.getEngName(), + tag.getIcon(), + tag.getDescription(), + tag.getParentId(), + tag.getCreateAt(), + tag.getLastModifyAt()); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java index 398bebcbe..912da055f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java @@ -20,6 +20,11 @@ public enum ResultCode { ALCOHOL_CREATED("위스키가 등록되었습니다."), ALCOHOL_UPDATED("위스키가 수정되었습니다."), ALCOHOL_DELETED("위스키가 삭제되었습니다."), + TASTING_TAG_CREATED("테이스팅 태그가 등록되었습니다."), + TASTING_TAG_UPDATED("테이스팅 태그가 수정되었습니다."), + TASTING_TAG_DELETED("테이스팅 태그가 삭제되었습니다."), + TASTING_TAG_ALCOHOL_ADDED("위스키가 연결되었습니다."), + TASTING_TAG_ALCOHOL_REMOVED("위스키 연결이 해제되었습니다."), ; private final String message; diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java index 2f8b44f4c..3a30b7936 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -30,16 +31,7 @@ public Page findAllTastingTags(String keyword, Pageable pag || keyword.isEmpty() || t.getKorName().contains(keyword) || t.getEngName().contains(keyword)) - .map( - t -> - new AdminTastingTagItem( - t.getId(), - t.getKorName(), - t.getEngName(), - t.getIcon(), - t.getDescription(), - t.getCreateAt(), - t.getLastModifyAt())) + .map(this::toAdminTastingTagItem) .toList(); int start = (int) pageable.getOffset(); @@ -50,17 +42,63 @@ public Page findAllTastingTags(String keyword, Pageable pag return new PageImpl<>(pageContent, pageable, filtered.size()); } + @Override + public Optional findById(Long id) { + return tags.stream().filter(t -> Objects.equals(t.getId(), id)).findFirst(); + } + + @Override + public Optional findByKorName(String korName) { + return tags.stream().filter(t -> Objects.equals(t.getKorName(), korName)).findFirst(); + } + + @Override + public List findByParentId(Long parentId) { + return tags.stream().filter(t -> Objects.equals(t.getParentId(), parentId)).toList(); + } + + @Override public TastingTag save(TastingTag tag) { Long id = tag.getId(); if (Objects.isNull(id)) { - id = (long) (tags.size() + 1); - ReflectionTestUtils.setField(tag, "id", id); + Long newId = (long) (tags.size() + 1); + ReflectionTestUtils.setField(tag, "id", newId); } + final Long tagId = tag.getId(); + tags.removeIf(t -> Objects.equals(t.getId(), tagId)); tags.add(tag); return tag; } + @Override + public void delete(TastingTag tag) { + tags.removeIf(t -> Objects.equals(t.getId(), tag.getId())); + } + + @Override + public boolean existsByKorNameAndIdNot(String korName, Long id) { + return tags.stream() + .anyMatch(t -> Objects.equals(t.getKorName(), korName) && !Objects.equals(t.getId(), id)); + } + + @Override + public boolean existsByParentId(Long parentId) { + return tags.stream().anyMatch(t -> Objects.equals(t.getParentId(), parentId)); + } + public void clear() { tags.clear(); } + + private AdminTastingTagItem toAdminTastingTagItem(TastingTag tag) { + return new AdminTastingTagItem( + tag.getId(), + tag.getKorName(), + tag.getEngName(), + tag.getIcon(), + tag.getDescription(), + tag.getParentId(), + tag.getCreateAt(), + tag.getLastModifyAt()); + } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java new file mode 100644 index 000000000..150cd3f90 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/TastingTagTestFactory.java @@ -0,0 +1,131 @@ +package app.bottlenote.alcohols.fixture; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.TastingTag; +import jakarta.persistence.EntityManager; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class TastingTagTestFactory { + + private final Random random = new SecureRandom(); + + @Autowired private EntityManager em; + + @Transactional + @NotNull + public TastingTag persistTastingTag() { + TastingTag tag = + TastingTag.builder() + .korName("테스트 태그-" + generateRandomSuffix()) + .engName("test-tag-" + generateRandomSuffix()) + .description("테스트용 태그입니다") + .build(); + em.persist(tag); + em.flush(); + return tag; + } + + @Transactional + @NotNull + public TastingTag persistTastingTag(@NotNull String korName, @NotNull String engName) { + TastingTag tag = + TastingTag.builder().korName(korName).engName(engName).description("테스트용 태그입니다").build(); + em.persist(tag); + em.flush(); + return tag; + } + + @Transactional + @NotNull + public TastingTag persistTastingTagWithParent(@NotNull TastingTag parent) { + TastingTag tag = + TastingTag.builder() + .korName("자식 태그-" + generateRandomSuffix()) + .engName("child-tag-" + generateRandomSuffix()) + .description("부모가 있는 태그입니다") + .parentId(parent.getId()) + .build(); + em.persist(tag); + em.flush(); + return tag; + } + + @Transactional + @NotNull + public TastingTag persistTastingTagWithParent( + @NotNull String korName, @NotNull String engName, @NotNull TastingTag parent) { + TastingTag tag = + TastingTag.builder() + .korName(korName) + .engName(engName) + .description("부모가 있는 태그입니다") + .parentId(parent.getId()) + .build(); + em.persist(tag); + em.flush(); + return tag; + } + + /** 3depth 트리 구조 생성 (대분류 -> 중분류 -> 소분류) */ + @Transactional + @NotNull + public List persistTastingTagTree() { + List tags = new ArrayList<>(); + + TastingTag root = + TastingTag.builder() + .korName("향-" + generateRandomSuffix()) + .engName("Aroma-" + generateRandomSuffix()) + .description("대분류 태그") + .build(); + em.persist(root); + tags.add(root); + + TastingTag middle = + TastingTag.builder() + .korName("달콤한-" + generateRandomSuffix()) + .engName("Sweet-" + generateRandomSuffix()) + .description("중분류 태그") + .parentId(root.getId()) + .build(); + em.persist(middle); + tags.add(middle); + + TastingTag leaf = + TastingTag.builder() + .korName("허니-" + generateRandomSuffix()) + .engName("Honey-" + generateRandomSuffix()) + .description("소분류 태그") + .parentId(middle.getId()) + .build(); + em.persist(leaf); + tags.add(leaf); + + em.flush(); + return tags; + } + + @Transactional + @NotNull + public AlcoholsTastingTags linkAlcoholToTag(@NotNull Alcohol alcohol, @NotNull TastingTag tag) { + AlcoholsTastingTags mapping = AlcoholsTastingTags.of(alcohol, tag); + em.persist(mapping); + em.flush(); + return mapping; + } + + private String generateRandomSuffix() { + return String.valueOf(random.nextInt(10000)); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java index 34a3fe352..672ad36eb 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java @@ -1,7 +1,10 @@ package app.bottlenote.alcohols.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.AlcoholsTastingTagsRepository; import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.fixture.InMemoryTastingTagRepository; import java.util.List; @@ -21,12 +24,18 @@ class TastingTagServiceTest { InMemoryTastingTagRepository tastingTagRepository; + AlcoholsTastingTagsRepository alcoholsTastingTagsRepository; + AlcoholQueryRepository alcoholQueryRepository; TastingTagService tastingTagService; @BeforeEach void setUp() { tastingTagRepository = new InMemoryTastingTagRepository(); - tastingTagService = new TastingTagService(tastingTagRepository); + alcoholsTastingTagsRepository = mock(AlcoholsTastingTagsRepository.class); + alcoholQueryRepository = mock(AlcoholQueryRepository.class); + tastingTagService = + new TastingTagService( + tastingTagRepository, alcoholsTastingTagsRepository, alcoholQueryRepository); tastingTagRepository.save(createTag("바닐라", "vanilla")); tastingTagRepository.save(createTag("꿀", "honey")); diff --git a/git.environment-variables b/git.environment-variables index cf01d4f68..625ccd3d4 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit cf01d4f685649652a92c43ea8dcbe61daf357b84 +Subproject commit 625ccd3d4b26ce14b19a14cf24b04cbc823bf80a From 35ef2b44de4ecce9cfe6ce478473153eca640729 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 27 Jan 2026 23:49:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(admin):=20=ED=85=8C=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=ED=83=9C=EA=B7=B8=20RestDocs=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminTastingTagControllerDocsTest에 7개 API 테스트 추가 - 목록 조회, 상세 조회, 생성, 수정, 삭제 - 위스키 연결/해제 벌크 API - tasting-tags.adoc 문서 신규 생성 - admin-api.adoc에 Tasting Tag API 섹션 추가 - reference.adoc에서 테이스팅 태그 목록 조회 분리 Co-Authored-By: Claude Opus 4.5 --- .../src/docs/asciidoc/admin-api.adoc | 6 + .../api/admin-reference/reference.adoc | 26 - .../api/admin-tasting-tags/tasting-tags.adoc | 201 ++++++++ .../AdminTastingTagControllerDocsTest.kt | 470 ++++++++++++++++-- 4 files changed, 624 insertions(+), 79 deletions(-) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 39adcc475..5c60d05c2 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -52,6 +52,12 @@ include::api/admin-file/file.adoc[] ''' +== Tasting Tag API + +include::api/admin-tasting-tags/tasting-tags.adoc[] + +''' + == Reference API include::api/admin-reference/reference.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc index 6e3f972e3..2d1072a96 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc @@ -1,29 +1,3 @@ -=== 테이스팅 태그 목록 조회 === - -- 전체 테이스팅 태그 목록을 조회합니다. -- 술의 향미를 표현하는 태그 정보를 제공합니다. - -[source] ----- -GET /admin/api/v1/tasting-tags ----- - -[discrete] -==== 요청 ==== - -[discrete] -include::{snippets}/admin/tasting-tags/list/curl-request.adoc[] -include::{snippets}/admin/tasting-tags/list/http-request.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -[discrete] -include::{snippets}/admin/tasting-tags/list/response-fields.adoc[] -include::{snippets}/admin/tasting-tags/list/http-response.adoc[] - -''' - === 지역 목록 조회 === - 전체 지역(국가) 목록을 조회합니다. diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc new file mode 100644 index 000000000..4fea6d795 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-tasting-tags/tasting-tags.adoc @@ -0,0 +1,201 @@ +=== 테이스팅 태그 목록 조회 === + +- 테이스팅 태그 목록을 페이지네이션으로 조회합니다. +- 키워드로 한글명/영문명 검색이 가능합니다. + +[source] +---- +GET /admin/api/v1/tasting-tags +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/list/query-parameters.adoc[] +include::{snippets}/admin/tasting-tags/list/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/list/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/list/http-response.adoc[] + +''' + +=== 테이스팅 태그 상세 조회 === + +- 특정 테이스팅 태그의 상세 정보를 조회합니다. +- 부모/자식 태그 정보 및 연결된 위스키 목록을 포함합니다. + +[source] +---- +GET /admin/api/v1/tasting-tags/{tagId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/detail/path-parameters.adoc[] +include::{snippets}/admin/tasting-tags/detail/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/detail/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/detail/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/detail/http-response.adoc[] + +''' + +=== 테이스팅 태그 생성 === + +- 새로운 테이스팅 태그를 생성합니다. +- parentId를 지정하여 계층 구조를 만들 수 있습니다. + +[source] +---- +POST /admin/api/v1/tasting-tags +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/create/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/create/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/create/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/create/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/create/http-response.adoc[] + +''' + +=== 테이스팅 태그 수정 === + +- 기존 테이스팅 태그 정보를 수정합니다. + +[source] +---- +PUT /admin/api/v1/tasting-tags/{tagId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/update/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/update/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/update/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/update/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/update/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/update/http-response.adoc[] + +''' + +=== 테이스팅 태그 삭제 === + +- 테이스팅 태그를 삭제합니다. +- 자식 태그가 있는 경우 삭제할 수 없습니다. + +[source] +---- +DELETE /admin/api/v1/tasting-tags/{tagId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/delete/path-parameters.adoc[] +include::{snippets}/admin/tasting-tags/delete/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/delete/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/delete/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/delete/http-response.adoc[] + +''' + +=== 테이스팅 태그 위스키 연결 === + +- 테이스팅 태그에 위스키를 벌크로 연결합니다. + +[source] +---- +POST /admin/api/v1/tasting-tags/{tagId}/alcohols +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/add-alcohols/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/add-alcohols/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/add-alcohols/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/add-alcohols/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/add-alcohols/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/add-alcohols/http-response.adoc[] + +''' + +=== 테이스팅 태그 위스키 연결 해제 === + +- 테이스팅 태그에서 위스키 연결을 벌크로 해제합니다. + +[source] +---- +DELETE /admin/api/v1/tasting-tags/{tagId}/alcohols +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/remove-alcohols/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/remove-alcohols/request-fields.adoc[] +include::{snippets}/admin/tasting-tags/remove-alcohols/curl-request.adoc[] +include::{snippets}/admin/tasting-tags/remove-alcohols/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/tasting-tags/remove-alcohols/response-fields.adoc[] +include::{snippets}/admin/tasting-tags/remove-alcohols/http-response.adoc[] diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index 351dbff82..56d5d18bb 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -1,29 +1,38 @@ package app.docs.alcohols import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.dto.request.AdminTastingTagUpsertRequest +import app.bottlenote.alcohols.dto.response.AdminAlcoholItem +import app.bottlenote.alcohols.dto.response.AdminTastingTagDetailResponse +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem import app.bottlenote.alcohols.presentation.AdminTastingTagController import app.bottlenote.alcohols.service.AlcoholReferenceService +import app.bottlenote.alcohols.service.TastingTagService import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.dto.response.AdminResultResponse import app.helper.alcohols.AlcoholsHelper +import com.fasterxml.jackson.databind.ObjectMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong import org.mockito.BDDMockito.given import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.data.domain.PageImpl +import org.springframework.http.MediaType import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document import org.springframework.restdocs.operation.preprocess.Preprocessors.* import org.springframework.restdocs.payload.JsonFieldType -import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath -import org.springframework.restdocs.payload.PayloadDocumentation.responseFields -import org.springframework.restdocs.request.RequestDocumentation.parameterWithName -import org.springframework.restdocs.request.RequestDocumentation.queryParameters +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.restdocs.request.RequestDocumentation.* import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.time.LocalDateTime @WebMvcTest( controllers = [AdminTastingTagController::class], @@ -36,60 +45,415 @@ class AdminTastingTagControllerDocsTest { @Autowired private lateinit var mvc: MockMvcTester + @Autowired + private lateinit var mapper: ObjectMapper + @MockitoBean private lateinit var alcoholReferenceService: AlcoholReferenceService - @Test - @DisplayName("테이스팅 태그 목록을 조회할 수 있다") - fun getAllTastingTags() { - // given - val items = AlcoholsHelper.createAdminTastingTagItems(3) - val page = PageImpl(items) - val response = GlobalResponse.fromPage(page) - - given(alcoholReferenceService.findAllTastingTags(any(AdminReferenceSearchRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.get().uri("/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC") - ) - .hasStatusOk() - .apply( - document( - "admin/tasting-tags/list", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - queryParameters( - parameterWithName("keyword").description("검색어 (한글명/영문명)").optional(), - parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 20)").optional(), - parameterWithName("sortOrder").description("정렬 방향 (ASC/DESC, 기본값: ASC)").optional() - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.ARRAY).description("테이스팅 태그 목록"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("태그 ID"), - fieldWithPath("data[].korName").type(JsonFieldType.STRING).description("태그 한글명"), - fieldWithPath("data[].engName").type(JsonFieldType.STRING).description("태그 영문명"), - fieldWithPath("data[].icon").type(JsonFieldType.STRING).description("아이콘"), - fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명"), - fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), - fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + @MockitoBean + private lateinit var tastingTagService: TastingTagService + + @Nested + @DisplayName("테이스팅 태그 목록 조회") + inner class GetTastingTagList { + + @Test + @DisplayName("테이스팅 태그 목록을 조회할 수 있다") + fun getAllTastingTags() { + // given + val items = AlcoholsHelper.createAdminTastingTagItems(3) + val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) + + given(alcoholReferenceService.findAllTastingTags(any(AdminReferenceSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC") + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").description("검색어 (한글명/영문명)").optional(), + parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 20)").optional(), + parameterWithName("sortOrder").description("정렬 방향 (ASC/DESC, 기본값: ASC)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("테이스팅 태그 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data[].korName").type(JsonFieldType.STRING).description("태그 한글명"), + fieldWithPath("data[].engName").type(JsonFieldType.STRING).description("태그 영문명"), + fieldWithPath("data[].icon").type(JsonFieldType.STRING).description("아이콘 (Base64)"), + fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("data[].parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional(), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 상세 조회") + inner class GetTastingTagDetail { + + @Test + @DisplayName("테이스팅 태그 상세 정보를 조회할 수 있다") + fun getTagDetail() { + // given + val tagItem = AdminTastingTagItem( + 1L, "바닐라", "Vanilla", "base64icon", "바닐라 향", null, + LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + ) + val childItem = AdminTastingTagItem( + 2L, "바닐라 크림", "Vanilla Cream", null, "바닐라 크림 향", 1L, + LocalDateTime.of(2024, 2, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + ) + val alcoholItem = AdminAlcoholItem( + 1L, "글렌피딕 12년", "Glenfiddich 12", "싱글몰트", "Single Malt", + "https://example.com/image.jpg", + LocalDateTime.of(2024, 1, 1, 0, 0), LocalDateTime.of(2024, 6, 1, 0, 0) + ) + + val response = AdminTastingTagDetailResponse.of( + tagItem, null, emptyList(), listOf(childItem), listOf(alcoholItem) + ) + + given(tastingTagService.getTagDetail(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.get().uri("/tasting-tags/{tagId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("태그 상세 정보"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.korName").type(JsonFieldType.STRING).description("태그 한글명"), + fieldWithPath("data.engName").type(JsonFieldType.STRING).description("태그 영문명"), + fieldWithPath("data.icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("data.parent").type(JsonFieldType.OBJECT).description("부모 태그 정보").optional(), + fieldWithPath("data.ancestors").type(JsonFieldType.ARRAY).description("조상 태그 목록 (가까운 순)"), + fieldWithPath("data.children").type(JsonFieldType.ARRAY).description("자식 태그 목록"), + fieldWithPath("data.children[].id").type(JsonFieldType.NUMBER).description("자식 태그 ID"), + fieldWithPath("data.children[].korName").type(JsonFieldType.STRING).description("자식 태그 한글명"), + fieldWithPath("data.children[].engName").type(JsonFieldType.STRING).description("자식 태그 영문명"), + fieldWithPath("data.children[].icon").type(JsonFieldType.STRING).description("자식 태그 아이콘").optional(), + fieldWithPath("data.children[].description").type(JsonFieldType.STRING).description("자식 태그 설명").optional(), + fieldWithPath("data.children[].parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional(), + fieldWithPath("data.children[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.children[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.alcohols").type(JsonFieldType.ARRAY).description("연결된 위스키 목록"), + fieldWithPath("data.alcohols[].alcoholId").type(JsonFieldType.NUMBER).description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").type(JsonFieldType.STRING).description("위스키 한글명"), + fieldWithPath("data.alcohols[].engName").type(JsonFieldType.STRING).description("위스키 영문명"), + fieldWithPath("data.alcohols[].korCategoryName").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("data.alcohols[].engCategoryName").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("data.alcohols[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL").optional(), + fieldWithPath("data.alcohols[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.alcohols[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 생성") + inner class CreateTastingTag { + + @Test + @DisplayName("테이스팅 태그를 생성할 수 있다") + fun createTag() { + // given + val request = mapOf( + "korName" to "바닐라", + "engName" to "Vanilla", + "icon" to "base64EncodedIcon", + "description" to "바닐라 향", + "parentId" to null + ) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_CREATED, 1L) + + given(tastingTagService.createTag(any(AdminTastingTagUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/tasting-tags") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("태그 한글명 (필수)"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("태그 영문명 (필수)"), + fieldWithPath("icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("parentId").type(JsonFieldType.NULL).description("부모 태그 ID (null이면 루트)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 수정") + inner class UpdateTastingTag { + + @Test + @DisplayName("테이스팅 태그를 수정할 수 있다") + fun updateTag() { + // given + val request = mapOf( + "korName" to "바닐라 수정", + "engName" to "Vanilla Updated", + "description" to "수정된 설명" + ) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_UPDATED, 1L) + + given(tastingTagService.updateTag(anyLong(), any(AdminTastingTagUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/tasting-tags/{tagId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("태그 한글명 (필수)"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("태그 영문명 (필수)"), + fieldWithPath("icon").type(JsonFieldType.STRING).description("아이콘 (Base64)").optional(), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("parentId").type(JsonFieldType.NUMBER).description("부모 태그 ID").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) ) ) + } + } + + @Nested + @DisplayName("테이스팅 태그 삭제") + inner class DeleteTastingTag { + + @Test + @DisplayName("테이스팅 태그를 삭제할 수 있다") + fun deleteTag() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_DELETED, 1L) + + given(tastingTagService.deleteTag(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.delete().uri("/tasting-tags/{tagId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("테이스팅 태그 위스키 연결 관리") + inner class ManageAlcohols { + + @Test + @DisplayName("위스키를 태그에 벌크로 연결할 수 있다") + fun addAlcoholsToTag() { + // given + val request = mapOf("alcoholIds" to listOf(1L, 2L, 3L)) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_ADDED, 1L) + + given(tastingTagService.addAlcoholsToTag(anyLong(), any())) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/tasting-tags/{tagId}/alcohols", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/add-alcohols", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + requestFields( + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("연결할 위스키 ID 목록") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + + @Test + @DisplayName("위스키 연결을 벌크로 해제할 수 있다") + fun removeAlcoholsFromTag() { + // given + val request = mapOf("alcoholIds" to listOf(1L, 2L)) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.TASTING_TAG_ALCOHOL_REMOVED, 1L) + + given(tastingTagService.removeAlcoholsFromTag(anyLong(), any())) + .willReturn(response) + + // when & then + assertThat( + mvc.delete().uri("/tasting-tags/{tagId}/alcohols", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/remove-alcohols", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("tagId").description("태그 ID") + ), + requestFields( + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("해제할 위스키 ID 목록") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } } } From 6ac9530e4cd79598e6a93b6a602dd1482ce8df0b Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 27 Jan 2026 23:52:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(admin):=20=ED=85=8C=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=ED=83=9C=EA=B7=B8=20HTTP=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이스팅태그.http 신규 생성 (7개 API) - 제조사_지역_태그.http → 제조사_지역.http 파일명 변경 - 테이스팅 태그 목록 조회를 별도 파일로 분리 Co-Authored-By: Claude Opus 4.5 --- ...354\202\254_\354\247\200\354\227\255.http" | 4 -- ...\355\214\205\355\203\234\352\267\270.http" | 55 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) rename "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" => "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" (64%) create mode 100644 "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" similarity index 64% rename from "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" rename to "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" index 8852a55cb..c97424722 100644 --- "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255_\355\203\234\352\267\270.http" +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\240\234\354\241\260\354\202\254_\354\247\200\354\227\255.http" @@ -5,7 +5,3 @@ Authorization: Bearer {{accessToken}} ### 지역 목록 조회 GET {{host}}/regions?keyword=&cursor=0&pageSize=10 Authorization: Bearer {{accessToken}} - -### 테이스팅 태그 목록 조회 -GET {{host}}/tasting-tags?keyword=&cursor=0&pageSize=10 -Authorization: Bearer {{accessToken}} diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" new file mode 100644 index 000000000..ec71c9b51 --- /dev/null +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270.http" @@ -0,0 +1,55 @@ +### 테이스팅 태그 목록 조회 +GET {{host}}/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그 상세 조회 +GET {{host}}/tasting-tags/1 +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그 생성 +POST {{host}}/tasting-tags +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "korName": "바닐라", + "engName": "Vanilla", + "icon": "base64EncodedIcon", + "description": "바닐라 향", + "parentId": null +} + +### 테이스팅 태그 수정 +PUT {{host}}/tasting-tags/1 +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "korName": "바닐라 수정", + "engName": "Vanilla Updated", + "icon": "base64EncodedIcon", + "description": "수정된 바닐라 향", + "parentId": null +} + +### 테이스팅 태그 삭제 +DELETE {{host}}/tasting-tags/1 +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그에 위스키 벌크 연결 +POST {{host}}/tasting-tags/1/alcohols +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "alcoholIds": [1, 2, 3] +} + +### 테이스팅 태그에서 위스키 벌크 연결 해제 +DELETE {{host}}/tasting-tags/1/alcohols +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "alcoholIds": [1, 2] +}