From 99f727b9bdbd84179155bb3a1a589dc6768c8f1d Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 31 Dec 2025 13:43:19 +0900 Subject: [PATCH 01/95] =?UTF-8?q?`test:=20AdminHelpControllerDocsTest=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20URI=20=EB=B0=8F=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EC=88=98=EC=A0=95`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/app/docs/help/AdminHelpControllerDocsTest.kt | 6 +++--- git.environment-variables | 2 +- gradle/libs.versions.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt index 3fe323c41..f61ce7526 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/help/AdminHelpControllerDocsTest.kt @@ -152,7 +152,7 @@ class AdminHelpControllerDocsTest { // when & then assertThat( - mvc.get().uri("/helps/1") + mvc.get().uri("/helps/{helpId}", 1L) .header("Authorization", "Bearer test_access_token") ) .hasStatusOk() @@ -177,7 +177,7 @@ class AdminHelpControllerDocsTest { fieldWithPath("data.content").type(JsonFieldType.STRING).description("문의 내용"), fieldWithPath("data.type").type(JsonFieldType.STRING).description("문의 유형"), fieldWithPath("data.imageUrlList[].order").type(JsonFieldType.NUMBER).description("이미지 순서"), - fieldWithPath("data.imageUrlList[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("data.imageUrlList[].viewUrl").type(JsonFieldType.STRING).description("이미지 URL"), fieldWithPath("data.status").type(JsonFieldType.STRING).description("처리 상태"), fieldWithPath("data.adminId").type(JsonFieldType.NULL).description("담당 관리자 ID"), fieldWithPath("data.responseContent").type(JsonFieldType.NULL).description("답변 내용"), @@ -212,7 +212,7 @@ class AdminHelpControllerDocsTest { // when & then assertThat( - mvc.post().uri("/helps/1/answer") + mvc.post().uri("/helps/{helpId}/answer", 1L) .header("Authorization", "Bearer test_access_token") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) diff --git a/git.environment-variables b/git.environment-variables index 375ea7433..3b1e7f92c 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 375ea74335f68cff7b94062fd5918faec11673c0 +Subproject commit 3b1e7f92c7fd056209049369253cdbe853abac2c diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8eb366f78..ee1778b73 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ mockito-inline = "5.2.0" archunit = "1.4.0" # Documentation -spring-restdocs = "3.0.1" +spring-restdocs = "3.0.3" restdocs-api-spec = "0.19.4" # Scheduling From 71c91b5d88b2de8e34dc15dc7e31180d09f2520f Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 31 Dec 2025 13:51:04 +0900 Subject: [PATCH 02/95] =?UTF-8?q?docs:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20Help=20API=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입 API 문서 추가 - Help API 목록 조회, 상세 조회, 답변 등록 문서 추가 - 잘못된 URI 및 필드명 수정 --- .github/workflows/github-pages.yml | 2 + docs/admin-api.html | 798 ++++++++++++++++++++++++++++- 2 files changed, 782 insertions(+), 18 deletions(-) diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 41d7b9715..a497e8110 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -5,7 +5,9 @@ on: branches: [ "main" ] paths: - 'bottlenote-*/src/docs/**' + - 'bottlenote-*/src/test/**/docs/**' - 'bottlenote-*/build.gradle*' + - 'gradle/libs.versions.toml' - 'docs/**' - '.github/workflows/github-pages.yml' workflow_dispatch: diff --git a/docs/admin-api.html b/docs/admin-api.html index 1e20fdda3..6a88f3eca 100644 --- a/docs/admin-api.html +++ b/docs/admin-api.html @@ -458,7 +458,8 @@

Bottle Note Admin API 문서

  • 3. Alcohol API @@ -466,6 +467,13 @@

    Bottle Note Admin API 문서

  • 3.1. 술(Alcohol) 목록 조회
  • +
  • 4. Help API + +
  • @@ -503,11 +511,11 @@

    1.1. API

    개발 (dev)

    -

    https://api.admin.development.bottle-note.com/

    +

    https://admin-api.development.bottle-note.com/

    운영(prod)

    -

    https://api.admin.product.bottle-note.com/

    +

    https://admin-api.bottle-note.com/

    @@ -818,14 +826,14 @@

    응답 파라미터

    "success" : true, "code" : 200, "data" : { - "accessToken" : "MdqzrkDjFbBVuhujMuqswwJsnXwREp9E", - "refreshToken" : "HlZlAhxicKGitIkwaZzDI6e4EaQZ4i5y" + "accessToken" : "cKKYogKS4WKo4PXl3CFOPPmYZ7fuROzj", + "refreshToken" : "BaLRekHCi8HacQkNwZmGECM90pd4Cb7D" }, "errors" : [ ], "meta" : { "serverVersion" : "1.0.0", "serverEncoding" : "UTF-8", - "serverResponseTime" : "2025-12-24T00:30:43.091782", + "serverResponseTime" : "2025-12-31T13:44:59.874373", "serverPathVersion" : "v1" } } @@ -927,20 +935,191 @@

    응답 파라미터

    HTTP/1.1 200 OK
     Content-Type: application/json
    -Content-Length: 354
    +Content-Length: 355
    +
    +{
    +  "success" : true,
    +  "code" : 200,
    +  "data" : {
    +    "accessToken" : "gZkFjp3IDuqGOWskT1nzMrs6vSea5DMA",
    +    "refreshToken" : "thq98RQinKwJiS2BBM2DUG07rUSEUp1a"
    +  },
    +  "errors" : [ ],
    +  "meta" : {
    +    "serverVersion" : "1.0.0",
    +    "serverEncoding" : "UTF-8",
    +    "serverResponseTime" : "2025-12-31T13:44:59.888659",
    +    "serverPathVersion" : "v1"
    +  }
    +}
    +
    + +
    + +
    +

    2.3. 회원가입

    +
    +
      +
    • +

      인증된 관리자가 새로운 관리자 계정을 생성합니다.

      +
    • +
    • +

      모든 인증된 어드민이 호출 가능합니다.

      +
    • +
    • +

      ROOT_ADMIN 역할은 ROOT_ADMIN만 부여할 수 있습니다.

      +
    • +
    +
    +
    +
    +
    POST /admin/api/v1/auth/signup
    +
    +
    +

    요청 헤더

    + ++++ + + + + + + + + + + + + +
    NameDescription

    Authorization

    Bearer 액세스 토큰

    +

    요청 파라미터

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PathTypeDescription

    email

    String

    어드민 이메일

    password

    String

    비밀번호 (8~35자)

    name

    String

    어드민 이름 (2~50자)

    roles

    Array

    역할 목록 (ROOT_ADMIN, PARTNER, CLIENT, BAR_OWNER, COMMUNITY_MANAGER)

    +
    +
    +
    POST /auth/signup HTTP/1.1
    +Content-Type: application/json;charset=UTF-8
    +Authorization: Bearer test_access_token
    +Content-Length: 142
    +Host: localhost:8080
    +
    +{
    +  "email" : "new@bottlenote.com",
    +  "password" : "password123",
    +  "name" : "새 어드민",
    +  "roles" : [ "PARTNER", "COMMUNITY_MANAGER" ]
    +}
    +
    +
    +

    응답 파라미터

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PathTypeDescription

    success

    Boolean

    응답 성공 여부

    code

    Number

    응답 코드

    data.adminId

    Number

    생성된 어드민 ID

    data.email

    String

    어드민 이메일

    data.name

    String

    어드민 이름

    data.roles

    Array

    부여된 역할 목록

    errors

    Array

    에러 목록

    +
    +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +Content-Length: 377
     
     {
       "success" : true,
       "code" : 200,
       "data" : {
    -    "accessToken" : "ZT2WjqFet4blwGshMHREVTHcK1MwbReI",
    -    "refreshToken" : "bD4ttl3VrUacRIFKr7UplhxFypCXmRUr"
    +    "adminId" : 1,
    +    "email" : "new@bottlenote.com",
    +    "name" : "새 어드민",
    +    "roles" : [ "PARTNER", "COMMUNITY_MANAGER" ]
       },
       "errors" : [ ],
       "meta" : {
         "serverVersion" : "1.0.0",
         "serverEncoding" : "UTF-8",
    -    "serverResponseTime" : "2025-12-24T00:30:43.10809",
    +    "serverResponseTime" : "2025-12-31T13:44:59.853345",
         "serverPathVersion" : "v1"
       }
     }
    @@ -949,7 +1128,7 @@

    응답 파라미터


    -

    2.3. 탈퇴

    +

    2.4. 탈퇴

    • @@ -962,7 +1141,7 @@

      2.3. 탈퇴

      DELETE /admin/api/v1/auth/withdraw
    -

    요청 헤더

    +

    요청 헤더

    @@ -988,7 +1167,7 @@

    요청 헤더

    Host: localhost:8080 -

    응답 파라미터

    +

    응답 파라미터

    @@ -1041,7 +1220,7 @@

    응답 파라미터

    "meta" : { "serverVersion" : "1.0.0", "serverEncoding" : "UTF-8", - "serverResponseTime" : "2025-12-24T00:30:43.061124", + "serverResponseTime" : "2025-12-31T13:44:59.774392", "serverPathVersion" : "v1" } } @@ -1071,7 +1250,7 @@

    GET /admin/api/v1/alcohols -

    요청 파라미터

    +

    요청 파라미터

    @@ -1125,7 +1304,7 @@

    요청 파라미터

    Host: localhost:8080 -

    응답 파라미터

    +

    응답 파라미터

    @@ -1269,7 +1448,590 @@

    응답 파라미터

    "hasNext" : false, "serverVersion" : "1.0.0", "serverEncoding" : "UTF-8", - "serverResponseTime" : "2025-12-24T00:30:42.412696", + "serverResponseTime" : "2025-12-31T13:44:59.120043", + "serverPathVersion" : "v1" + } +} + + +
    + + + +
    +

    4. Help API

    +
    +
    +

    4.1. 문의 목록 조회

    +
    +
      +
    • +

      전체 문의 목록을 조회합니다.

      +
    • +
    • +

      상태(status)와 유형(type)으로 필터링할 수 있습니다.

      +
    • +
    • +

      커서 기반 페이징을 지원합니다.

      +
    • +
    +
    +
    +
    +
    GET /admin/api/v1/helps
    +
    +
    +

    요청 헤더

    +
    ++++ + + + + + + + + + + + + +
    NameDescription

    Authorization

    Bearer 액세스 토큰

    +

    요청 파라미터

    + ++++ + + + + + + + + + + + + + + + + + + + + + + + + +
    ParameterDescription

    status

    상태 필터 (WAITING, SUCCESS, REJECT, DELETED)

    type

    문의 유형 필터 (WHISKEY, REVIEW, USER, ETC)

    cursor

    페이징 커서 (기본값: 0)

    pageSize

    페이지 크기 (기본값: 20)

    +
    +
    +
    GET /helps?status=WAITING&type=WHISKEY&cursor=0&pageSize=20 HTTP/1.1
    +Authorization: Bearer test_access_token
    +Host: localhost:8080
    +
    +
    +

    응답 파라미터

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PathTypeDescription

    success

    Boolean

    응답 성공 여부

    code

    Number

    응답 코드

    data.content.totalCount

    Number

    전체 문의 수

    data.content.helpList[].helpId

    Number

    문의 ID

    data.content.helpList[].userId

    Number

    문의자 ID

    data.content.helpList[].userNickname

    String

    문의자 닉네임

    data.content.helpList[].title

    String

    문의 제목

    data.content.helpList[].type

    String

    문의 유형

    data.content.helpList[].status

    String

    처리 상태

    data.content.helpList[].createAt

    String

    생성일시

    data.cursorPageable.cursor

    Number

    다음 커서

    data.cursorPageable.pageSize

    Number

    페이지 크기

    data.cursorPageable.hasNext

    Boolean

    다음 페이지 여부

    data.cursorPageable.currentCursor

    Number

    현재 커서

    errors

    Array

    에러 목록

    +
    +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +Content-Length: 694
    +
    +{
    +  "success" : true,
    +  "code" : 200,
    +  "data" : {
    +    "content" : {
    +      "totalCount" : 1,
    +      "helpList" : [ {
    +        "helpId" : 1,
    +        "userId" : 100,
    +        "userNickname" : "테스트유저",
    +        "title" : "위스키 관련 문의",
    +        "type" : "WHISKEY",
    +        "status" : "WAITING",
    +        "createAt" : "2025-12-31T13:45:00.114159"
    +      } ]
    +    },
    +    "cursorPageable" : {
    +      "currentCursor" : 0,
    +      "cursor" : 20,
    +      "pageSize" : 20,
    +      "hasNext" : false
    +    }
    +  },
    +  "errors" : [ ],
    +  "meta" : {
    +    "serverVersion" : "1.0.0",
    +    "serverEncoding" : "UTF-8",
    +    "serverResponseTime" : "2025-12-31T13:45:00.115917",
    +    "serverPathVersion" : "v1"
    +  }
    +}
    +
    +
    +
    +
    +
    +

    4.2. 문의 상세 조회

    +
    +
      +
    • +

      특정 문의의 상세 정보를 조회합니다.

      +
    • +
    • +

      문의 내용, 이미지, 답변 내용 등을 확인할 수 있습니다.

      +
    • +
    +
    +
    +
    +
    GET /admin/api/v1/helps/{helpId}
    +
    +
    +

    요청 헤더

    + ++++ + + + + + + + + + + + + +
    NameDescription

    Authorization

    Bearer 액세스 토큰

    +

    경로 파라미터

    + + ++++ + + + + + + + + + + + + +
    Table 1. /helps/{helpId}
    ParameterDescription

    helpId

    문의 ID

    +
    +
    +
    GET /helps/1 HTTP/1.1
    +Authorization: Bearer test_access_token
    +Host: localhost:8080
    +
    +
    +

    응답 파라미터

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PathTypeDescription

    success

    Boolean

    응답 성공 여부

    code

    Number

    응답 코드

    data.helpId

    Number

    문의 ID

    data.userId

    Number

    문의자 ID

    data.userNickname

    String

    문의자 닉네임

    data.title

    String

    문의 제목

    data.content

    String

    문의 내용

    data.type

    String

    문의 유형

    data.imageUrlList[].order

    Number

    이미지 순서

    data.imageUrlList[].viewUrl

    String

    이미지 URL

    data.status

    String

    처리 상태

    data.adminId

    Null

    담당 관리자 ID

    data.responseContent

    Null

    답변 내용

    data.createAt

    String

    생성일시

    data.lastModifyAt

    String

    수정일시

    errors

    Array

    에러 목록

    +
    +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +Content-Length: 723
    +
    +{
    +  "success" : true,
    +  "code" : 200,
    +  "data" : {
    +    "helpId" : 1,
    +    "userId" : 100,
    +    "userNickname" : "테스트유저",
    +    "title" : "위스키 관련 문의",
    +    "content" : "위스키에 대해 문의드립니다.",
    +    "type" : "WHISKEY",
    +    "imageUrlList" : [ {
    +      "order" : 1,
    +      "viewUrl" : "https://example.com/image.jpg"
    +    } ],
    +    "status" : "WAITING",
    +    "adminId" : null,
    +    "responseContent" : null,
    +    "createAt" : "2025-12-31T13:45:00.073754",
    +    "lastModifyAt" : "2025-12-31T13:45:00.073762"
    +  },
    +  "errors" : [ ],
    +  "meta" : {
    +    "serverVersion" : "1.0.0",
    +    "serverEncoding" : "UTF-8",
    +    "serverResponseTime" : "2025-12-31T13:45:00.076799",
    +    "serverPathVersion" : "v1"
    +  }
    +}
    +
    +
    +
    +
    +
    +

    4.3. 문의 답변 등록

    +
    +
      +
    • +

      문의에 대한 답변을 등록합니다.

      +
    • +
    • +

      처리 상태를 SUCCESS(처리 완료) 또는 REJECT(반려)로 설정할 수 있습니다.

      +
    • +
    +
    +
    +
    +
    POST /admin/api/v1/helps/{helpId}/answer
    +
    +
    +

    요청 헤더

    + ++++ + + + + + + + + + + + + +
    NameDescription

    Authorization

    Bearer 액세스 토큰

    +

    경로 파라미터

    + + ++++ + + + + + + + + + + + + +
    Table 2. /helps/{helpId}/answer
    ParameterDescription

    helpId

    문의 ID

    +

    요청 파라미터

    + +++++ + + + + + + + + + + + + + + + + + + + +
    PathTypeDescription

    responseContent

    String

    답변 내용

    status

    String

    처리 상태 (SUCCESS, REJECT)

    +
    +
    +
    POST /helps/1/answer HTTP/1.1
    +Content-Type: application/json;charset=UTF-8
    +Authorization: Bearer test_access_token
    +Content-Length: 75
    +Host: localhost:8080
    +
    +{
    +  "responseContent" : "답변 내용입니다.",
    +  "status" : "SUCCESS"
    +}
    +
    +
    +

    응답 파라미터

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PathTypeDescription

    success

    Boolean

    응답 성공 여부

    code

    Number

    응답 코드

    data.helpId

    Number

    문의 ID

    data.status

    String

    처리 상태

    data.message

    String

    결과 메시지

    errors

    Array

    에러 목록

    +
    +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +Content-Length: 338
    +
    +{
    +  "success" : true,
    +  "code" : 200,
    +  "data" : {
    +    "helpId" : 1,
    +    "status" : "SUCCESS",
    +    "message" : "답변이 등록되었습니다."
    +  },
    +  "errors" : [ ],
    +  "meta" : {
    +    "serverVersion" : "1.0.0",
    +    "serverEncoding" : "UTF-8",
    +    "serverResponseTime" : "2025-12-31T13:45:00.103422",
         "serverPathVersion" : "v1"
       }
     }
    @@ -1282,7 +2044,7 @@

    응답 파라미터

    From 3243e5392a6401adf14f28b6dbc2fcc063e84ac8 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 31 Dec 2025 16:38:23 +0900 Subject: [PATCH 03/95] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EA=B7=B8=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- git.environment-variables | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index 3b1e7f92c..57863a015 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 3b1e7f92c7fd056209049369253cdbe853abac2c +Subproject commit 57863a0153841f0a9c85a026132477f54d045cdc From e00eecc57ea464401b3de0e2df2a73122b3232d3 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 5 Jan 2026 16:43:27 +0900 Subject: [PATCH 04/95] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EA=B7=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadLog 엔티티, 레포지토리, 서비스 구현 - PreSigned URL 생성 시 업로드 로그 자동 저장 (로그인 사용자) - MinIO TestContainer 추가 및 테스트 구성 - 미사용 S3 이벤트 리스너 코드 제거 - DTO에서 엔티티 의존성 제거 (아키텍처 규칙 준수) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../common/file/domain/ImageUploadLog.java | 131 ++++++++ .../file/domain/ImageUploadLogRepository.java | 25 ++ .../common/file/domain/ImageUploadStatus.java | 8 + .../common/file/dto/ImageUploadLogItem.java | 19 ++ .../dto/request/ImageUploadLogRequest.java | 31 ++ .../dto/response/ImageUploadLogResponse.java | 23 ++ .../file/event/listener/S3EventListener.java | 28 -- .../file/event/payload/S3RequestEvent.java | 16 - .../JpaImageUploadLogRepository.java | 27 ++ .../file/service/ImageUploadLogService.java | 133 ++++++++ .../file/service/ImageUploadService.java | 36 ++- .../common/file/ImageUploadUnitTest.java | 287 ++++++++++++++++++ .../operation/utils/TestContainersConfig.java | 47 +++ .../upload/CoreImageUploadServiceTest.java | 7 +- .../upload/ImageUploadLogServiceTest.java | 239 +++++++++++++++ .../upload/MinioContainerLoadingTest.java | 54 ++++ .../fixture/FakeImageEventPublisher.java | 17 -- .../InMemoryImageUploadLogRepository.java | 88 ++++++ gradle/libs.versions.toml | 3 + 19 files changed, 1150 insertions(+), 69 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadStatus.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/ImageUploadLogItem.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/S3EventListener.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/S3RequestEvent.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeImageEventPublisher.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java new file mode 100644 index 000000000..8708a83ae --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java @@ -0,0 +1,131 @@ +package app.bottlenote.common.file.domain; + +import app.bottlenote.common.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Entity(name = "image_upload_log") +@Table(name = "image_upload_logs") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ImageUploadLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("업로드 요청 사용자 ID") + @Column(name = "user_id", nullable = false) + private Long userId; + + @Comment("S3 객체 키") + @Column(name = "image_key", nullable = false, length = 1024) + private String imageKey; + + @Comment("CloudFront 조회 URL") + @Column(name = "view_url", nullable = false, length = 2048) + private String viewUrl; + + @Comment("상태: PENDING/UPLOADED/CONFIRMED/DELETED") + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private ImageUploadStatus status; + + @Comment("연결된 엔티티 ID") + @Column(name = "reference_id") + private Long referenceId; + + @Comment("저장 경로") + @Column(name = "root_path", length = 255) + private String rootPath; + + @Comment("MIME 타입") + @Column(name = "content_type", length = 128) + private String contentType; + + @Comment("파일 크기 bytes") + @Column(name = "content_length") + private Long contentLength; + + @Comment("원본 파일명") + @Column(name = "original_file_name", length = 512) + private String originalFileName; + + @Comment("S3 버킷명") + @Column(name = "bucket_name", length = 128) + private String bucketName; + + @Comment("MD5 해시") + @Column(name = "etag", length = 128) + private String etag; + + @Comment("사용 확정 시간") + @Column(name = "confirmed_at") + private LocalDateTime confirmedAt; + + @Comment("연결 타입: REVIEW/PROFILE/HELP") + @Column(name = "reference_type", length = 64) + private String referenceType; + + @Builder + public ImageUploadLog( + Long id, + Long userId, + String imageKey, + String viewUrl, + ImageUploadStatus status, + Long referenceId, + String rootPath, + String contentType, + Long contentLength, + String originalFileName, + String bucketName, + String etag, + LocalDateTime confirmedAt, + String referenceType) { + this.id = id; + this.userId = userId; + this.imageKey = imageKey; + this.viewUrl = viewUrl; + this.status = status; + this.referenceId = referenceId; + this.rootPath = rootPath; + this.contentType = contentType; + this.contentLength = contentLength; + this.originalFileName = originalFileName; + this.bucketName = bucketName; + this.etag = etag; + this.confirmedAt = confirmedAt; + this.referenceType = referenceType; + } + + public void confirm(Long referenceId, String referenceType) { + this.referenceId = referenceId; + this.referenceType = referenceType; + this.status = ImageUploadStatus.CONFIRMED; + this.confirmedAt = LocalDateTime.now(); + } + + public void markAsDeleted() { + this.status = ImageUploadStatus.DELETED; + } + + public void updateUploadInfo(String etag, Long contentLength, String contentType) { + this.etag = etag; + this.contentLength = contentLength; + this.contentType = contentType; + this.status = ImageUploadStatus.UPLOADED; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java new file mode 100644 index 000000000..5d3aecd62 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java @@ -0,0 +1,25 @@ +package app.bottlenote.common.file.domain; + +import app.bottlenote.common.annotation.DomainRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@DomainRepository +public interface ImageUploadLogRepository { + + ImageUploadLog save(ImageUploadLog imageUploadLog); + + Optional findById(Long id); + + Optional findByImageKey(String imageKey); + + List findByUserId(Long userId); + + List findByStatusAndCreateAtBefore( + ImageUploadStatus status, LocalDateTime dateTime); + + List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); + + void delete(ImageUploadLog imageUploadLog); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadStatus.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadStatus.java new file mode 100644 index 000000000..98fac26e4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadStatus.java @@ -0,0 +1,8 @@ +package app.bottlenote.common.file.domain; + +public enum ImageUploadStatus { + PENDING, // URL 발급됨, 업로드 대기 + UPLOADED, // 업로드 완료, 사용 대기 + CONFIRMED, // 사용 확정 + DELETED // 삭제됨 +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/ImageUploadLogItem.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/ImageUploadLogItem.java new file mode 100644 index 000000000..2d6efbc83 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/ImageUploadLogItem.java @@ -0,0 +1,19 @@ +package app.bottlenote.common.file.dto; + +import app.bottlenote.common.file.domain.ImageUploadStatus; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record ImageUploadLogItem( + Long id, + Long userId, + String imageKey, + String viewUrl, + ImageUploadStatus status, + Long referenceId, + String referenceType, + String rootPath, + String bucketName, + LocalDateTime createdAt, + LocalDateTime confirmedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java new file mode 100644 index 000000000..4464674f5 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java @@ -0,0 +1,31 @@ +package app.bottlenote.common.file.dto.request; + +import app.bottlenote.common.file.domain.ImageUploadLog; +import app.bottlenote.common.file.domain.ImageUploadStatus; +import lombok.Builder; + +@Builder +public record ImageUploadLogRequest( + Long userId, + String imageKey, + String viewUrl, + String rootPath, + String bucketName, + String originalFileName, + String contentType, + Long contentLength) { + + public ImageUploadLog toEntity() { + return ImageUploadLog.builder() + .userId(userId) + .imageKey(imageKey) + .viewUrl(viewUrl) + .rootPath(rootPath) + .bucketName(bucketName) + .originalFileName(originalFileName) + .contentType(contentType) + .contentLength(contentLength) + .status(ImageUploadStatus.PENDING) + .build(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java new file mode 100644 index 000000000..8a477bf92 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java @@ -0,0 +1,23 @@ +package app.bottlenote.common.file.dto.response; + +import app.bottlenote.common.file.domain.ImageUploadStatus; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record ImageUploadLogResponse( + Long id, + Long userId, + String imageKey, + String viewUrl, + ImageUploadStatus status, + Long referenceId, + String referenceType, + String rootPath, + String contentType, + Long contentLength, + String originalFileName, + String bucketName, + String etag, + LocalDateTime createdAt, + LocalDateTime confirmedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/S3EventListener.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/S3EventListener.java deleted file mode 100644 index 801c299a4..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/S3EventListener.java +++ /dev/null @@ -1,28 +0,0 @@ -package app.bottlenote.common.file.event.listener; - -import static app.bottlenote.common.annotation.DomainEventListener.ProcessingType.ASYNCHRONOUS; - -import app.bottlenote.common.annotation.DomainEventListener; -import app.bottlenote.common.file.event.payload.S3RequestEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; - -@Slf4j -@RequiredArgsConstructor -@DomainEventListener(type = ASYNCHRONOUS) -public class S3EventListener { - - /** - * S3 요청 이벤트 기록 처리 - * - * @param event the event - */ - // @TransactionalEventListener - @EventListener - public void handleS3RequestEvent(final S3RequestEvent event) { - log.info("S3 Request Event: {}", event); - // 현재 쓰레드 출력 - log.info("Current Thread: {}", Thread.currentThread().getName()); - } -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/S3RequestEvent.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/S3RequestEvent.java deleted file mode 100644 index d05d6eef4..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/S3RequestEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package app.bottlenote.common.file.event.payload; - -import static java.time.LocalDateTime.now; - -import java.time.LocalDateTime; - -public record S3RequestEvent( - String eventName, String bucketName, Long requestCount, LocalDateTime eventTime) { - public S3RequestEvent(String eventName, String bucketName, Long requestCount) { - this(eventName, bucketName, requestCount, now()); - } - - public static S3RequestEvent of(String eventName, String bucketName, Long requestCount) { - return new S3RequestEvent(eventName, bucketName, requestCount); - } -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java new file mode 100644 index 000000000..e69100599 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java @@ -0,0 +1,27 @@ +package app.bottlenote.common.file.repository; + +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import app.bottlenote.common.file.domain.ImageUploadLog; +import app.bottlenote.common.file.domain.ImageUploadLogRepository; +import app.bottlenote.common.file.domain.ImageUploadStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +@JpaRepositoryImpl +public interface JpaImageUploadLogRepository + extends ImageUploadLogRepository, JpaRepository { + + Optional findByImageKey(String imageKey); + + List findByUserId(Long userId); + + @Query("SELECT i FROM image_upload_log i WHERE i.status = :status AND i.createAt < :dateTime") + List findByStatusAndCreateAtBefore( + @Param("status") ImageUploadStatus status, @Param("dateTime") LocalDateTime dateTime); + + List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java new file mode 100644 index 000000000..13e17ae5b --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java @@ -0,0 +1,133 @@ +package app.bottlenote.common.file.service; + +import app.bottlenote.common.file.domain.ImageUploadLog; +import app.bottlenote.common.file.domain.ImageUploadLogRepository; +import app.bottlenote.common.file.domain.ImageUploadStatus; +import app.bottlenote.common.file.dto.ImageUploadLogItem; +import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; +import app.bottlenote.common.file.dto.response.ImageUploadLogResponse; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageUploadLogService { + + private final ImageUploadLogRepository imageUploadLogRepository; + + /** + * 비동기로 이미지 로그를 저장한다. + * + * @param request 이미지 업로드 로그 요청 + * @return 저장된 이미지 업로드 로그 응답 + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CompletableFuture saveAsync(ImageUploadLogRequest request) { + ImageUploadLog saved = imageUploadLogRepository.save(request.toEntity()); + log.info("이미지 업로드 로그 저장 완료 - imageKey: {}, userId: {}", saved.getImageKey(), saved.getUserId()); + return CompletableFuture.completedFuture(toResponse(saved)); + } + + /** + * 이미지 로그 정보를 조회한다. + * + * @param imageKey S3 객체 키 + * @return 이미지 업로드 로그 응답 + */ + @Transactional(readOnly = true) + public Optional findByImageKey(String imageKey) { + return imageUploadLogRepository.findByImageKey(imageKey).map(this::toResponse); + } + + /** + * 날짜와 상태를 기준으로 미확정된 로그 목록을 조회한다. + * + * @param status 조회할 상태 + * @param dateTime 기준 날짜 (이 날짜 이전에 생성된 로그) + * @return 이미지 업로드 로그 아이템 목록 + */ + @Transactional(readOnly = true) + public List findUnconfirmedLogs( + ImageUploadStatus status, LocalDateTime dateTime) { + return imageUploadLogRepository.findByStatusAndCreateAtBefore(status, dateTime).stream() + .map(this::toItem) + .toList(); + } + + private ImageUploadLogItem toItem(ImageUploadLog log) { + return ImageUploadLogItem.builder() + .id(log.getId()) + .userId(log.getUserId()) + .imageKey(log.getImageKey()) + .viewUrl(log.getViewUrl()) + .status(log.getStatus()) + .referenceId(log.getReferenceId()) + .referenceType(log.getReferenceType()) + .rootPath(log.getRootPath()) + .bucketName(log.getBucketName()) + .createdAt(log.getCreateAt()) + .confirmedAt(log.getConfirmedAt()) + .build(); + } + + private ImageUploadLogResponse toResponse(ImageUploadLog log) { + return ImageUploadLogResponse.builder() + .id(log.getId()) + .userId(log.getUserId()) + .imageKey(log.getImageKey()) + .viewUrl(log.getViewUrl()) + .status(log.getStatus()) + .referenceId(log.getReferenceId()) + .referenceType(log.getReferenceType()) + .rootPath(log.getRootPath()) + .contentType(log.getContentType()) + .contentLength(log.getContentLength()) + .originalFileName(log.getOriginalFileName()) + .bucketName(log.getBucketName()) + .etag(log.getEtag()) + .createdAt(log.getCreateAt()) + .confirmedAt(log.getConfirmedAt()) + .build(); + } + + /** + * 비동기로 이미지 상태를 업데이트한다. + * + * @param imageKey S3 객체 키 + * @param referenceId 연결된 엔티티 ID + * @param referenceType 연결 타입 + * @return 업데이트된 이미지 업로드 로그 응답 + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CompletableFuture> confirmAsync( + String imageKey, Long referenceId, String referenceType) { + return imageUploadLogRepository + .findByImageKey(imageKey) + .map( + uploadLog -> { + uploadLog.confirm(referenceId, referenceType); + log.info( + "이미지 상태 확정 완료 - imageKey: {}, referenceId: {}, referenceType: {}", + imageKey, + referenceId, + referenceType); + return CompletableFuture.completedFuture(Optional.of(toResponse(uploadLog))); + }) + .orElseGet( + () -> { + log.warn("이미지 로그를 찾을 수 없음 - imageKey: {}", imageKey); + return CompletableFuture.completedFuture(Optional.empty()); + }); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java index 62105b702..4a01ab4d9 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java @@ -2,10 +2,11 @@ import app.bottlenote.common.annotation.ThirdPartyService; import app.bottlenote.common.file.PreSignUrlProvider; +import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; import app.bottlenote.common.file.dto.request.ImageUploadRequest; import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; -import app.bottlenote.common.file.event.payload.S3RequestEvent; +import app.bottlenote.global.security.SecurityContextUtil; import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; import java.util.ArrayList; @@ -13,24 +14,23 @@ import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationEventPublisher; @Slf4j @ThirdPartyService public class ImageUploadService implements PreSignUrlProvider { private static final Integer EXPIRY_TIME = 5; - private final ApplicationEventPublisher eventPublisher; + private final ImageUploadLogService imageUploadLogService; private final AmazonS3 amazonS3; private final String imageBucketName; private final String cloudFrontUrl; public ImageUploadService( - ApplicationEventPublisher eventPublisher, + ImageUploadLogService imageUploadLogService, AmazonS3 amazonS3, @Value("${amazon.aws.bucket}") String imageBucketName, @Value("${amazon.aws.cloudFrontUrl}") String cloudFrontUrl) { - this.eventPublisher = eventPublisher; + this.imageUploadLogService = imageUploadLogService; this.amazonS3 = amazonS3; this.imageBucketName = imageBucketName; this.cloudFrontUrl = cloudFrontUrl; @@ -54,7 +54,7 @@ public ImageUploadResponse getPreSignUrl(ImageUploadRequest request) { keys.add( ImageUploadItem.builder().order(index).viewUrl(viewUrl).uploadUrl(preSignUrl).build()); } - eventPublisher.publishEvent(S3RequestEvent.of("s3 Image upload", imageBucketName, uploadSize)); + saveImageUploadLogs(rootPath, keys); log.info( "S3 PreSignedURL 생성 완료 - rootPath: {}, uploadSize: {}, bucket: {}, expiryTime: {}분", @@ -83,4 +83,28 @@ public String generatePreSignUrl(String imageKey) { .generatePresignedUrl(imageBucketName, imageKey, uploadExpiryTime.getTime(), HttpMethod.PUT) .toString(); } + + private void saveImageUploadLogs(String rootPath, List items) { + SecurityContextUtil.getUserIdByContext() + .ifPresent( + userId -> + items.forEach( + item -> { + String imageKey = extractImageKey(item.viewUrl()); + ImageUploadLogRequest logRequest = + ImageUploadLogRequest.builder() + .userId(userId) + .imageKey(imageKey) + .viewUrl(item.viewUrl()) + .rootPath(rootPath) + .bucketName(imageBucketName) + .build(); + imageUploadLogService.saveAsync(logRequest); + })); + } + + private String extractImageKey(String viewUrl) { + int lastSlashOfCloudFront = cloudFrontUrl.length() + 1; + return viewUrl.substring(lastSlashOfCloudFront); + } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java new file mode 100644 index 000000000..907844676 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -0,0 +1,287 @@ +package app.bottlenote.common.file; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import app.bottlenote.common.file.domain.ImageUploadLog; +import app.bottlenote.common.file.domain.ImageUploadLogRepository; +import app.bottlenote.common.file.domain.ImageUploadStatus; +import app.bottlenote.common.file.dto.request.ImageUploadRequest; +import app.bottlenote.common.file.dto.response.ImageUploadResponse; +import app.bottlenote.common.file.service.ImageUploadLogService; +import app.bottlenote.common.file.service.ImageUploadService; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Tag("unit") +@Testcontainers +@DisplayName("[unit] ImageUpload MinIO 기반 테스트") +class ImageUploadUnitTest { + + private static final Logger log = LoggerFactory.getLogger(ImageUploadUnitTest.class); + private static final String MINIO_ACCESS_KEY = "minioadmin"; + private static final String MINIO_SECRET_KEY = "minioadmin"; + private static final String TEST_BUCKET = "test-bucket"; + private static final String CLOUD_FRONT_URL = "https://cdn.example.com"; + + @Container + static MinIOContainer minioContainer = + new MinIOContainer("minio/minio:latest") + .withUserName(MINIO_ACCESS_KEY) + .withPassword(MINIO_SECRET_KEY); + + private static AmazonS3 amazonS3; + private ImageUploadService imageUploadService; + private ImageUploadLogService imageUploadLogService; + private InMemoryImageUploadLogRepository imageUploadLogRepository; + + @BeforeAll + static void setUpContainer() { + amazonS3 = + AmazonS3ClientBuilder.standard() + .withEndpointConfiguration( + new AwsClientBuilder.EndpointConfiguration(minioContainer.getS3URL(), "us-east-1")) + .withCredentials( + new AWSStaticCredentialsProvider( + new BasicAWSCredentials(MINIO_ACCESS_KEY, MINIO_SECRET_KEY))) + .withPathStyleAccessEnabled(true) + .build(); + + if (!amazonS3.doesBucketExistV2(TEST_BUCKET)) { + amazonS3.createBucket(TEST_BUCKET); + } + } + + @BeforeEach + void setUp() { + imageUploadLogRepository = new InMemoryImageUploadLogRepository(); + imageUploadLogService = new ImageUploadLogService(imageUploadLogRepository); + imageUploadService = + new ImageUploadService(imageUploadLogService, amazonS3, TEST_BUCKET, CLOUD_FRONT_URL); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + imageUploadLogRepository.clear(); + } + + @Nested + @DisplayName("PreSigned URL 생성 테스트") + class PreSignedUrlTest { + + @Test + @DisplayName("PreSigned URL 생성 시 MinIO에서 유효한 URL을 반환한다") + void test_1() { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 1L); + + // when + ImageUploadResponse response = imageUploadService.getPreSignUrl(request); + + // then + assertNotNull(response); + assertEquals(1, response.uploadSize()); + assertEquals(TEST_BUCKET, response.bucketName()); + assertNotNull(response.imageUploadInfo().get(0).uploadUrl()); + assertTrue(response.imageUploadInfo().get(0).uploadUrl().contains(minioContainer.getS3URL())); + + log.info("PreSigned URL = {}", response.imageUploadInfo().get(0).uploadUrl()); + } + + @Test + @DisplayName("PreSigned URL로 실제 파일 업로드가 가능하다") + void test_2() throws Exception { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadResponse response = imageUploadService.getPreSignUrl(request); + String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); + byte[] testData = "test image content".getBytes(); + + // when + URL url = new URL(uploadUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("PUT"); + connection.setRequestProperty("Content-Type", "image/jpeg"); + connection.setRequestProperty("Content-Length", String.valueOf(testData.length)); + + try (OutputStream os = connection.getOutputStream()) { + os.write(testData); + } + int responseCode = connection.getResponseCode(); + connection.disconnect(); + + // then + assertEquals(200, responseCode); + log.info("업로드 응답 코드 = {}", responseCode); + } + + @Test + @DisplayName("업로드된 파일이 MinIO에 존재한다") + void test_3() throws Exception { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadResponse response = imageUploadService.getPreSignUrl(request); + String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); + String viewUrl = response.imageUploadInfo().get(0).viewUrl(); + String imageKey = viewUrl.substring(CLOUD_FRONT_URL.length() + 1); + byte[] testData = "test image content".getBytes(); + + URL url = new URL(uploadUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("PUT"); + connection.setRequestProperty("Content-Type", "image/jpeg"); + try (OutputStream os = connection.getOutputStream()) { + os.write(testData); + } + connection.getResponseCode(); + connection.disconnect(); + + // when + boolean exists = amazonS3.doesObjectExist(TEST_BUCKET, imageKey); + + // then + assertTrue(exists); + log.info("업로드된 객체 키 = {}, 존재 여부 = {}", imageKey, exists); + } + } + + @Nested + @DisplayName("이미지 업로드 로그 저장 테스트") + class ImageUploadLogTest { + + @Test + @DisplayName("로그인 사용자가 PreSigned URL 생성 시 로그가 저장된다") + void test_1() { + // given + Long userId = 1L; + SecurityContextHolder.getContext() + .setAuthentication(new TestingAuthenticationToken(userId.toString(), null)); + ImageUploadRequest request = new ImageUploadRequest("review", 2L); + + // when + imageUploadService.getPreSignUrl(request); + + // then + List logs = imageUploadLogRepository.findByUserId(userId); + assertEquals(2, logs.size()); + assertEquals(ImageUploadStatus.PENDING, logs.get(0).getStatus()); + + log.info("저장된 로그 수 = {}", logs.size()); + } + + @Test + @DisplayName("비로그인 사용자가 PreSigned URL 생성 시 로그가 저장되지 않는다") + void test_2() { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 2L); + + // when + imageUploadService.getPreSignUrl(request); + + // then + List logs = imageUploadLogRepository.findAll(); + assertEquals(0, logs.size()); + + log.info("저장된 로그 수 = {}", logs.size()); + } + } + + static class InMemoryImageUploadLogRepository implements ImageUploadLogRepository { + + private final Map database = new HashMap<>(); + + @Override + public ImageUploadLog save(ImageUploadLog imageUploadLog) { + Long id = (Long) ReflectionTestUtils.getField(imageUploadLog, "id"); + if (id == null) { + id = database.size() + 1L; + ReflectionTestUtils.setField(imageUploadLog, "id", id); + } + database.put(id, imageUploadLog); + return imageUploadLog; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(database.get(id)); + } + + @Override + public Optional findByImageKey(String imageKey) { + return database.values().stream() + .filter(log -> log.getImageKey().equals(imageKey)) + .findFirst(); + } + + @Override + public List findByUserId(Long userId) { + return database.values().stream().filter(log -> log.getUserId().equals(userId)).toList(); + } + + @Override + public List findByStatusAndCreateAtBefore( + ImageUploadStatus status, LocalDateTime dateTime) { + return database.values().stream() + .filter(log -> log.getStatus() == status) + .filter( + log -> { + LocalDateTime createAt = + (LocalDateTime) ReflectionTestUtils.getField(log, "createAt"); + return createAt == null || createAt.isBefore(dateTime); + }) + .toList(); + } + + @Override + public List findByReferenceIdAndReferenceType( + Long referenceId, String referenceType) { + return database.values().stream() + .filter(log -> referenceId.equals(log.getReferenceId())) + .filter(log -> referenceType.equals(log.getReferenceType())) + .toList(); + } + + @Override + public void delete(ImageUploadLog imageUploadLog) { + database.remove(imageUploadLog.getId()); + } + + public void clear() { + database.clear(); + } + + public List findAll() { + return List.copyOf(database.values()); + } + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index 7320fcdee..1bbdf0941 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -1,10 +1,16 @@ package app.bottlenote.operation.utils; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.redis.testcontainers.RedisContainer; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import org.testcontainers.containers.MinIOContainer; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; @@ -44,4 +50,45 @@ RedisContainer redisContainer() { FakeWebhookRestTemplate webhookRestTemplate() { return new FakeWebhookRestTemplate(); } + + private static final String MINIO_IMAGE = "minio/minio:latest"; + private static final String MINIO_ACCESS_KEY = "minioadmin"; + private static final String MINIO_SECRET_KEY = "minioadmin"; + private static final String TEST_BUCKET = "test-bucket"; + + /** MinIO 컨테이너를 Spring Bean으로 등록합니다. */ + @Bean + MinIOContainer minioContainer() { + MinIOContainer container = + new MinIOContainer(MINIO_IMAGE) + .withUserName(MINIO_ACCESS_KEY) + .withPassword(MINIO_SECRET_KEY); + container.start(); + return container; + } + + /** MinIO에 연결하는 AmazonS3 클라이언트를 등록합니다. */ + @Bean + @Primary + AmazonS3 amazonS3(MinIOContainer minioContainer) { + AmazonS3 s3Client = + AmazonS3ClientBuilder.standard() + .withEndpointConfiguration( + new AwsClientBuilder.EndpointConfiguration(minioContainer.getS3URL(), "us-east-1")) + .withCredentials( + new AWSStaticCredentialsProvider( + new BasicAWSCredentials(MINIO_ACCESS_KEY, MINIO_SECRET_KEY))) + .withPathStyleAccessEnabled(true) + .build(); + + if (!s3Client.doesBucketExistV2(TEST_BUCKET)) { + s3Client.createBucket(TEST_BUCKET); + } + + return s3Client; + } + + public static String getTestBucket() { + return TEST_BUCKET; + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java index 94d2b396b..c96197cf8 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java @@ -10,9 +10,10 @@ import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.common.file.exception.FileException; +import app.bottlenote.common.file.service.ImageUploadLogService; import app.bottlenote.common.file.service.ImageUploadService; import app.bottlenote.common.file.upload.fixture.FakeAmazonS3; -import app.bottlenote.common.file.upload.fixture.FakeImageEventPublisher; +import app.bottlenote.common.file.upload.fixture.InMemoryImageUploadLogRepository; import java.time.LocalDate; import java.util.Calendar; import java.util.concurrent.TimeUnit; @@ -37,9 +38,11 @@ class CoreImageUploadServiceTest { @BeforeEach void setUp() { + ImageUploadLogService imageUploadLogService = + new ImageUploadLogService(new InMemoryImageUploadLogRepository()); imageUploadService = new ImageUploadService( - new FakeImageEventPublisher(), new FakeAmazonS3(), ImageBucketName, cloudFrontUrl) { + imageUploadLogService, new FakeAmazonS3(), ImageBucketName, cloudFrontUrl) { @Override public String getImageKey(String rootPath, Long index) { if (rootPath.startsWith(PATH_DELIMITER)) { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java new file mode 100644 index 000000000..8ffe63d05 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java @@ -0,0 +1,239 @@ +package app.bottlenote.common.file.upload; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import app.bottlenote.common.file.domain.ImageUploadStatus; +import app.bottlenote.common.file.dto.ImageUploadLogItem; +import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; +import app.bottlenote.common.file.dto.response.ImageUploadLogResponse; +import app.bottlenote.common.file.service.ImageUploadLogService; +import app.bottlenote.common.file.upload.fixture.InMemoryImageUploadLogRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.test.util.ReflectionTestUtils; + +@Tag("unit") +@DisplayName("[unit] [service] ImageUploadLog") +class ImageUploadLogServiceTest { + + private static final Logger log = LoggerFactory.getLogger(ImageUploadLogServiceTest.class); + private ImageUploadLogService imageUploadLogService; + private InMemoryImageUploadLogRepository imageUploadLogRepository; + + @BeforeEach + void setUp() { + imageUploadLogRepository = new InMemoryImageUploadLogRepository(); + imageUploadLogService = new ImageUploadLogService(imageUploadLogRepository); + } + + private ImageUploadLogRequest createRequest(Long userId, String imageKey) { + return ImageUploadLogRequest.builder() + .userId(userId) + .imageKey(imageKey) + .viewUrl("https://cdn.example.com/" + imageKey) + .rootPath("review") + .bucketName("test-bucket") + .originalFileName("test.jpg") + .contentType("image/jpeg") + .contentLength(1024L) + .build(); + } + + @Nested + @DisplayName("이미지 로그 저장 테스트") + class SaveAsyncTest { + + @Test + @DisplayName("이미지 로그 요청을 저장할 때 PENDING 상태로 저장된다") + void test_1() { + // given + ImageUploadLogRequest request = createRequest(1L, "review/20251231/1-uuid.jpg"); + + // when + CompletableFuture future = imageUploadLogService.saveAsync(request); + ImageUploadLogResponse response = future.join(); + + // then + assertNotNull(response); + assertEquals(1L, response.id()); + assertEquals(1L, response.userId()); + assertEquals("review/20251231/1-uuid.jpg", response.imageKey()); + assertEquals(ImageUploadStatus.PENDING, response.status()); + + log.info("저장된 응답 = {}", response); + } + + @Test + @DisplayName("여러 이미지 로그를 저장할 때 각각 다른 ID로 저장된다") + void test_2() { + // given + ImageUploadLogRequest request1 = createRequest(1L, "review/20251231/1-uuid1.jpg"); + ImageUploadLogRequest request2 = createRequest(1L, "review/20251231/2-uuid2.jpg"); + + // when + ImageUploadLogResponse response1 = imageUploadLogService.saveAsync(request1).join(); + ImageUploadLogResponse response2 = imageUploadLogService.saveAsync(request2).join(); + + // then + assertEquals(1L, response1.id()); + assertEquals(2L, response2.id()); + assertEquals(2, imageUploadLogRepository.findAll().size()); + + log.info("첫 번째 저장 = {}", response1); + log.info("두 번째 저장 = {}", response2); + } + } + + @Nested + @DisplayName("이미지 로그 조회 테스트") + class FindByImageKeyTest { + + @Test + @DisplayName("imageKey로 조회할 때 저장된 로그를 반환한다") + void test_1() { + // given + String imageKey = "review/20251231/1-uuid.jpg"; + imageUploadLogService.saveAsync(createRequest(1L, imageKey)).join(); + + // when + Optional result = imageUploadLogService.findByImageKey(imageKey); + + // then + assertTrue(result.isPresent()); + assertEquals(imageKey, result.get().imageKey()); + + log.info("조회 결과 = {}", result.get()); + } + + @Test + @DisplayName("존재하지 않는 imageKey로 조회할 때 빈 결과를 반환한다") + void test_2() { + // given + String imageKey = "non-existent-key.jpg"; + + // when + Optional result = imageUploadLogService.findByImageKey(imageKey); + + // then + assertTrue(result.isEmpty()); + + log.info("조회 결과 = {}", result); + } + } + + @Nested + @DisplayName("미확정 로그 목록 조회 테스트") + class FindUnconfirmedLogsTest { + + @Test + @DisplayName("상태와 날짜 기준으로 미확정 로그 목록을 조회할 때 조건에 맞는 목록을 반환한다") + void test_1() { + // given + imageUploadLogService.saveAsync(createRequest(1L, "review/20251231/1-uuid1.jpg")).join(); + imageUploadLogService.saveAsync(createRequest(2L, "review/20251231/2-uuid2.jpg")).join(); + + // createAt 설정 (과거 날짜로) + imageUploadLogRepository + .findById(1L) + .ifPresent( + uploadLog -> + ReflectionTestUtils.setField( + uploadLog, "createAt", LocalDateTime.now().minusDays(7))); + imageUploadLogRepository + .findById(2L) + .ifPresent( + uploadLog -> + ReflectionTestUtils.setField( + uploadLog, "createAt", LocalDateTime.now().minusDays(3))); + + // when + List result = + imageUploadLogService.findUnconfirmedLogs( + ImageUploadStatus.PENDING, LocalDateTime.now().minusDays(1)); + + // then + assertEquals(2, result.size()); + + log.info("조회된 미확정 로그 수 = {}", result.size()); + } + + @Test + @DisplayName("CONFIRMED 상태의 로그는 PENDING 조회 시 제외된다") + void test_2() { + // given + imageUploadLogService.saveAsync(createRequest(1L, "review/20251231/1-uuid1.jpg")).join(); + imageUploadLogRepository + .findById(1L) + .ifPresent( + uploadLog -> { + ReflectionTestUtils.setField( + uploadLog, "createAt", LocalDateTime.now().minusDays(7)); + uploadLog.confirm(100L, "REVIEW"); + }); + + // when + List result = + imageUploadLogService.findUnconfirmedLogs(ImageUploadStatus.PENDING, LocalDateTime.now()); + + // then + assertEquals(0, result.size()); + + log.info("조회된 미확정 로그 수 = {}", result.size()); + } + } + + @Nested + @DisplayName("이미지 상태 확정 테스트") + class ConfirmAsyncTest { + + @Test + @DisplayName("이미지 상태를 확정할 때 CONFIRMED 상태로 변경된다") + void test_1() { + // given + String imageKey = "review/20251231/1-uuid.jpg"; + imageUploadLogService.saveAsync(createRequest(1L, imageKey)).join(); + + // when + CompletableFuture> future = + imageUploadLogService.confirmAsync(imageKey, 100L, "REVIEW"); + Optional result = future.join(); + + // then + assertTrue(result.isPresent()); + assertEquals(ImageUploadStatus.CONFIRMED, result.get().status()); + assertEquals(100L, result.get().referenceId()); + assertEquals("REVIEW", result.get().referenceType()); + assertNotNull(result.get().confirmedAt()); + + log.info("확정 결과 = {}", result.get()); + } + + @Test + @DisplayName("존재하지 않는 imageKey로 확정 요청할 때 빈 결과를 반환한다") + void test_2() { + // given + String imageKey = "non-existent-key.jpg"; + + // when + CompletableFuture> future = + imageUploadLogService.confirmAsync(imageKey, 100L, "REVIEW"); + Optional result = future.join(); + + // then + assertTrue(result.isEmpty()); + + log.info("확정 결과 = {}", result); + } + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java new file mode 100644 index 000000000..58d29f7ee --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java @@ -0,0 +1,54 @@ +package app.bottlenote.common.file.upload; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.operation.utils.TestContainersConfig; +import com.amazonaws.services.s3.AmazonS3; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.testcontainers.containers.MinIOContainer; + +@DisplayName("[integration] MinIO 컨테이너 로딩 테스트") +class MinioContainerLoadingTest extends IntegrationTestSupport { + + @Autowired private MinIOContainer minioContainer; + + @Autowired private AmazonS3 amazonS3; + + @Test + @DisplayName("MinIO 컨테이너가 정상적으로 시작될 때 running 상태가 된다") + void test_1() { + // given & when + boolean isRunning = minioContainer.isRunning(); + + // then + assertTrue(isRunning); + log.info("MinIO 컨테이너 상태 = running: {}", isRunning); + log.info("MinIO S3 URL = {}", minioContainer.getS3URL()); + } + + @Test + @DisplayName("AmazonS3 클라이언트가 MinIO에 연결될 때 테스트 버킷이 존재한다") + void test_2() { + // given + String testBucket = TestContainersConfig.getTestBucket(); + + // when + boolean bucketExists = amazonS3.doesBucketExistV2(testBucket); + + // then + assertTrue(bucketExists); + log.info("테스트 버킷 존재 여부 = {}: {}", testBucket, bucketExists); + } + + @Test + @DisplayName("AmazonS3 클라이언트가 정상적으로 주입될 때 null이 아니다") + void test_3() { + // given & when & then + assertNotNull(amazonS3); + log.info("AmazonS3 클라이언트 = {}", amazonS3); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeImageEventPublisher.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeImageEventPublisher.java deleted file mode 100644 index 9f24f7ab5..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeImageEventPublisher.java +++ /dev/null @@ -1,17 +0,0 @@ -package app.bottlenote.common.file.upload.fixture; - -import org.springframework.context.ApplicationEvent; -import org.springframework.context.ApplicationEventPublisher; - -public class FakeImageEventPublisher implements ApplicationEventPublisher { - - @Override - public void publishEvent(ApplicationEvent event) { - ApplicationEventPublisher.super.publishEvent(event); - } - - @Override - public void publishEvent(Object event) { - System.out.println("Fake 이벤트 발생"); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java new file mode 100644 index 000000000..b1cbc46b2 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java @@ -0,0 +1,88 @@ +package app.bottlenote.common.file.upload.fixture; + +import app.bottlenote.common.file.domain.ImageUploadLog; +import app.bottlenote.common.file.domain.ImageUploadLogRepository; +import app.bottlenote.common.file.domain.ImageUploadStatus; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.test.util.ReflectionTestUtils; + +public class InMemoryImageUploadLogRepository implements ImageUploadLogRepository { + + private static final Logger log = LogManager.getLogger(InMemoryImageUploadLogRepository.class); + Map database = new HashMap<>(); + + @Override + public ImageUploadLog save(ImageUploadLog imageUploadLog) { + Long id = (Long) ReflectionTestUtils.getField(imageUploadLog, "id"); + if (id != null && database.containsKey(id)) { + database.put(id, imageUploadLog); + } else { + id = database.size() + 1L; + database.put(id, imageUploadLog); + ReflectionTestUtils.setField(imageUploadLog, "id", id); + } + log.info("[InMemory] imageUploadLog repository save = {}", imageUploadLog); + return imageUploadLog; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(database.get(id)); + } + + @Override + public Optional findByImageKey(String imageKey) { + return database.values().stream() + .filter(uploadLog -> uploadLog.getImageKey().equals(imageKey)) + .findFirst(); + } + + @Override + public List findByUserId(Long userId) { + return database.values().stream() + .filter(uploadLog -> uploadLog.getUserId().equals(userId)) + .toList(); + } + + @Override + public List findByStatusAndCreateAtBefore( + ImageUploadStatus status, LocalDateTime dateTime) { + return database.values().stream() + .filter(uploadLog -> uploadLog.getStatus() == status) + .filter( + uploadLog -> { + LocalDateTime createAt = + (LocalDateTime) ReflectionTestUtils.getField(uploadLog, "createAt"); + return createAt == null || createAt.isBefore(dateTime); + }) + .toList(); + } + + @Override + public List findByReferenceIdAndReferenceType( + Long referenceId, String referenceType) { + return database.values().stream() + .filter(uploadLog -> referenceId.equals(uploadLog.getReferenceId())) + .filter(uploadLog -> referenceType.equals(uploadLog.getReferenceType())) + .toList(); + } + + @Override + public void delete(ImageUploadLog imageUploadLog) { + database.remove(imageUploadLog.getId()); + } + + public void clear() { + database.clear(); + } + + public List findAll() { + return List.copyOf(database.values()); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee1778b73..dfe9377d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ testng = "7.7.0" testcontainers = "1.19.8" testcontainers-junit = "1.19.8" testcontainers-mysql = "1.19.8" +testcontainers-minio = "1.19.8" testcontainers-redis = "2.2.4" mockito-inline = "5.2.0" archunit = "1.4.0" @@ -143,6 +144,7 @@ testng = { module = "org.testng:testng", version.ref = "testng" } testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } testcontainers-junit = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers-junit" } testcontainers-mysql = { module = "org.testcontainers:mysql", version.ref = "testcontainers-mysql" } +testcontainers-minio = { module = "org.testcontainers:minio", version.ref = "testcontainers-minio" } testcontainers-redis = { module = "com.redis:testcontainers-redis", version.ref = "testcontainers-redis" } spring-boot-testcontainers = { module = "org.springframework.boot:spring-boot-testcontainers", version.ref = "spring-boot" } mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } @@ -215,6 +217,7 @@ testcontainers-complete = [ "testcontainers", "testcontainers-junit", "testcontainers-mysql", + "testcontainers-minio", "testcontainers-redis", "spring-boot-testcontainers" ] From ef9a2b2a017896f80e2ff7ff6f122deb874cc7f5 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 5 Jan 2026 17:19:21 +0900 Subject: [PATCH 05/95] =?UTF-8?q?refactor:=20Request=20DTO=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Entity=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadLogRequest.toEntity() 메서드 제거 - 엔티티 생성 로직을 ImageUploadLogService.saveAsync()로 이동 - 아키텍처 규칙 준수: DTO는 Entity에 의존하지 않음 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../dto/request/ImageUploadLogRequest.java | 19 +------------------ .../file/service/ImageUploadLogService.java | 14 +++++++++++++- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java index 4464674f5..19893c445 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java @@ -1,7 +1,5 @@ package app.bottlenote.common.file.dto.request; -import app.bottlenote.common.file.domain.ImageUploadLog; -import app.bottlenote.common.file.domain.ImageUploadStatus; import lombok.Builder; @Builder @@ -13,19 +11,4 @@ public record ImageUploadLogRequest( String bucketName, String originalFileName, String contentType, - Long contentLength) { - - public ImageUploadLog toEntity() { - return ImageUploadLog.builder() - .userId(userId) - .imageKey(imageKey) - .viewUrl(viewUrl) - .rootPath(rootPath) - .bucketName(bucketName) - .originalFileName(originalFileName) - .contentType(contentType) - .contentLength(contentLength) - .status(ImageUploadStatus.PENDING) - .build(); - } -} + Long contentLength) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java index 13e17ae5b..709a741b8 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java @@ -33,7 +33,19 @@ public class ImageUploadLogService { @Async @Transactional(propagation = Propagation.REQUIRES_NEW) public CompletableFuture saveAsync(ImageUploadLogRequest request) { - ImageUploadLog saved = imageUploadLogRepository.save(request.toEntity()); + ImageUploadLog entity = + ImageUploadLog.builder() + .userId(request.userId()) + .imageKey(request.imageKey()) + .viewUrl(request.viewUrl()) + .rootPath(request.rootPath()) + .bucketName(request.bucketName()) + .originalFileName(request.originalFileName()) + .contentType(request.contentType()) + .contentLength(request.contentLength()) + .status(ImageUploadStatus.PENDING) + .build(); + ImageUploadLog saved = imageUploadLogRepository.save(entity); log.info("이미지 업로드 로그 저장 완료 - imageKey: {}, userId: {}", saved.getImageKey(), saved.getUserId()); return CompletableFuture.completedFuture(toResponse(saved)); } From d0f82dc7272732bce2b62bd0617ae8ac8a43d641 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 5 Jan 2026 17:28:40 +0900 Subject: [PATCH 06/95] =?UTF-8?q?refactor:=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EA=B7=9C=EC=B9=99=20=EC=9C=84=EB=B0=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadStatus를 domain -> constant 패키지로 이동 - ImageUploadLogItem을 dto -> dto.response 패키지로 이동 - 관련 import 문 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../common/file/{domain => constant}/ImageUploadStatus.java | 2 +- .../app/bottlenote/common/file/domain/ImageUploadLog.java | 1 + .../common/file/domain/ImageUploadLogRepository.java | 1 + .../common/file/dto/{ => response}/ImageUploadLogItem.java | 4 ++-- .../common/file/dto/response/ImageUploadLogResponse.java | 2 +- .../common/file/repository/JpaImageUploadLogRepository.java | 2 +- .../bottlenote/common/file/service/ImageUploadLogService.java | 4 ++-- .../java/app/bottlenote/common/file/ImageUploadUnitTest.java | 2 +- .../common/file/upload/ImageUploadLogServiceTest.java | 4 ++-- .../file/upload/fixture/InMemoryImageUploadLogRepository.java | 2 +- 10 files changed, 13 insertions(+), 11 deletions(-) rename bottlenote-mono/src/main/java/app/bottlenote/common/file/{domain => constant}/ImageUploadStatus.java (80%) rename bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/{ => response}/ImageUploadLogItem.java (76%) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadStatus.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ImageUploadStatus.java similarity index 80% rename from bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadStatus.java rename to bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ImageUploadStatus.java index 98fac26e4..9f5e8288a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadStatus.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ImageUploadStatus.java @@ -1,4 +1,4 @@ -package app.bottlenote.common.file.domain; +package app.bottlenote.common.file.constant; public enum ImageUploadStatus { PENDING, // URL 발급됨, 업로드 대기 diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java index 8708a83ae..8b7bb9e55 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java @@ -1,6 +1,7 @@ package app.bottlenote.common.file.domain; import app.bottlenote.common.domain.BaseEntity; +import app.bottlenote.common.file.constant.ImageUploadStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java index 5d3aecd62..4cc4fd4bb 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java @@ -1,6 +1,7 @@ package app.bottlenote.common.file.domain; import app.bottlenote.common.annotation.DomainRepository; +import app.bottlenote.common.file.constant.ImageUploadStatus; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/ImageUploadLogItem.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogItem.java similarity index 76% rename from bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/ImageUploadLogItem.java rename to bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogItem.java index 2d6efbc83..24de980f0 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/ImageUploadLogItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogItem.java @@ -1,6 +1,6 @@ -package app.bottlenote.common.file.dto; +package app.bottlenote.common.file.dto.response; -import app.bottlenote.common.file.domain.ImageUploadStatus; +import app.bottlenote.common.file.constant.ImageUploadStatus; import java.time.LocalDateTime; import lombok.Builder; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java index 8a477bf92..302b30e29 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java @@ -1,6 +1,6 @@ package app.bottlenote.common.file.dto.response; -import app.bottlenote.common.file.domain.ImageUploadStatus; +import app.bottlenote.common.file.constant.ImageUploadStatus; import java.time.LocalDateTime; import lombok.Builder; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java index e69100599..8704dacd1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java @@ -1,9 +1,9 @@ package app.bottlenote.common.file.repository; import app.bottlenote.common.annotation.JpaRepositoryImpl; +import app.bottlenote.common.file.constant.ImageUploadStatus; import app.bottlenote.common.file.domain.ImageUploadLog; import app.bottlenote.common.file.domain.ImageUploadLogRepository; -import app.bottlenote.common.file.domain.ImageUploadStatus; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java index 709a741b8..49cca6f16 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java @@ -1,10 +1,10 @@ package app.bottlenote.common.file.service; +import app.bottlenote.common.file.constant.ImageUploadStatus; import app.bottlenote.common.file.domain.ImageUploadLog; import app.bottlenote.common.file.domain.ImageUploadLogRepository; -import app.bottlenote.common.file.domain.ImageUploadStatus; -import app.bottlenote.common.file.dto.ImageUploadLogItem; import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; +import app.bottlenote.common.file.dto.response.ImageUploadLogItem; import app.bottlenote.common.file.dto.response.ImageUploadLogResponse; import java.time.LocalDateTime; import java.util.List; diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index 907844676..3be0b1a4a 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -4,9 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import app.bottlenote.common.file.constant.ImageUploadStatus; import app.bottlenote.common.file.domain.ImageUploadLog; import app.bottlenote.common.file.domain.ImageUploadLogRepository; -import app.bottlenote.common.file.domain.ImageUploadStatus; import app.bottlenote.common.file.dto.request.ImageUploadRequest; import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.common.file.service.ImageUploadLogService; diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java index 8ffe63d05..5776c103a 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java @@ -4,9 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import app.bottlenote.common.file.domain.ImageUploadStatus; -import app.bottlenote.common.file.dto.ImageUploadLogItem; +import app.bottlenote.common.file.constant.ImageUploadStatus; import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; +import app.bottlenote.common.file.dto.response.ImageUploadLogItem; import app.bottlenote.common.file.dto.response.ImageUploadLogResponse; import app.bottlenote.common.file.service.ImageUploadLogService; import app.bottlenote.common.file.upload.fixture.InMemoryImageUploadLogRepository; diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java index b1cbc46b2..6d809cd3e 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java @@ -1,8 +1,8 @@ package app.bottlenote.common.file.upload.fixture; +import app.bottlenote.common.file.constant.ImageUploadStatus; import app.bottlenote.common.file.domain.ImageUploadLog; import app.bottlenote.common.file.domain.ImageUploadLogRepository; -import app.bottlenote.common.file.domain.ImageUploadStatus; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; From 5dbd511c2550795f0827dd5cfa2c938f42619999 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 6 Jan 2026 10:45:46 +0900 Subject: [PATCH 07/95] =?UTF-8?q?refactor:=20ImageUploadLog=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20ResourceLog?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadLog (상태 기반) -> ResourceLog (이벤트 기반) 리팩토링 - ResourceEventType enum 추가 (CREATED, ACTIVATED, INVALIDATED, DELETED) - ResourceCommandService로 추상화하여 향후 다른 리소스 타입 확장 가능 - 이벤트 로그 방식으로 변경하여 리소스 이력 추적 용이 - 기존 ImageUploadLog 관련 파일 삭제 - 테스트 코드 갱신 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../file/constant/ImageUploadStatus.java | 8 - .../file/constant/ResourceEventType.java | 15 + .../common/file/domain/ImageUploadLog.java | 132 --------- .../file/domain/ImageUploadLogRepository.java | 26 -- .../common/file/domain/ResourceLog.java | 105 +++++++ .../file/domain/ResourceLogRepository.java | 28 ++ .../dto/request/ImageUploadLogRequest.java | 14 - .../file/dto/request/ResourceLogRequest.java | 7 + .../dto/response/ImageUploadLogResponse.java | 23 -- ...ploadLogItem.java => ResourceLogItem.java} | 14 +- .../dto/response/ResourceLogResponse.java | 19 ++ .../JpaImageUploadLogRepository.java | 27 -- .../repository/JpaResourceLogRepository.java | 31 ++ .../file/service/ImageUploadLogService.java | 145 ---------- .../file/service/ImageUploadService.java | 16 +- .../file/service/ResourceCommandService.java | 152 ++++++++++ .../common/file/ImageUploadUnitTest.java | 86 +++--- .../upload/CoreImageUploadServiceTest.java | 10 +- .../upload/ImageUploadLogServiceTest.java | 239 ---------------- .../upload/ResourceCommandServiceTest.java | 269 ++++++++++++++++++ .../InMemoryImageUploadLogRepository.java | 88 ------ .../InMemoryResourceLogRepository.java | 87 ++++++ git.environment-variables | 2 +- 23 files changed, 779 insertions(+), 764 deletions(-) delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ImageUploadStatus.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ResourceEventType.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ResourceLogRequest.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java rename bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/{ImageUploadLogItem.java => ResourceLogItem.java} (55%) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ImageUploadStatus.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ImageUploadStatus.java deleted file mode 100644 index 9f5e8288a..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ImageUploadStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package app.bottlenote.common.file.constant; - -public enum ImageUploadStatus { - PENDING, // URL 발급됨, 업로드 대기 - UPLOADED, // 업로드 완료, 사용 대기 - CONFIRMED, // 사용 확정 - DELETED // 삭제됨 -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ResourceEventType.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ResourceEventType.java new file mode 100644 index 000000000..45b8d11c3 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/constant/ResourceEventType.java @@ -0,0 +1,15 @@ +package app.bottlenote.common.file.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ResourceEventType { + CREATED("리소스 생성"), + ACTIVATED("사용 가능"), + INVALIDATED("무효화"), + DELETED("삭제됨"); + + private final String description; +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java deleted file mode 100644 index 8b7bb9e55..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLog.java +++ /dev/null @@ -1,132 +0,0 @@ -package app.bottlenote.common.file.domain; - -import app.bottlenote.common.domain.BaseEntity; -import app.bottlenote.common.file.constant.ImageUploadStatus; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.Comment; - -@Getter -@Entity(name = "image_upload_log") -@Table(name = "image_upload_logs") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ImageUploadLog extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Comment("업로드 요청 사용자 ID") - @Column(name = "user_id", nullable = false) - private Long userId; - - @Comment("S3 객체 키") - @Column(name = "image_key", nullable = false, length = 1024) - private String imageKey; - - @Comment("CloudFront 조회 URL") - @Column(name = "view_url", nullable = false, length = 2048) - private String viewUrl; - - @Comment("상태: PENDING/UPLOADED/CONFIRMED/DELETED") - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 32) - private ImageUploadStatus status; - - @Comment("연결된 엔티티 ID") - @Column(name = "reference_id") - private Long referenceId; - - @Comment("저장 경로") - @Column(name = "root_path", length = 255) - private String rootPath; - - @Comment("MIME 타입") - @Column(name = "content_type", length = 128) - private String contentType; - - @Comment("파일 크기 bytes") - @Column(name = "content_length") - private Long contentLength; - - @Comment("원본 파일명") - @Column(name = "original_file_name", length = 512) - private String originalFileName; - - @Comment("S3 버킷명") - @Column(name = "bucket_name", length = 128) - private String bucketName; - - @Comment("MD5 해시") - @Column(name = "etag", length = 128) - private String etag; - - @Comment("사용 확정 시간") - @Column(name = "confirmed_at") - private LocalDateTime confirmedAt; - - @Comment("연결 타입: REVIEW/PROFILE/HELP") - @Column(name = "reference_type", length = 64) - private String referenceType; - - @Builder - public ImageUploadLog( - Long id, - Long userId, - String imageKey, - String viewUrl, - ImageUploadStatus status, - Long referenceId, - String rootPath, - String contentType, - Long contentLength, - String originalFileName, - String bucketName, - String etag, - LocalDateTime confirmedAt, - String referenceType) { - this.id = id; - this.userId = userId; - this.imageKey = imageKey; - this.viewUrl = viewUrl; - this.status = status; - this.referenceId = referenceId; - this.rootPath = rootPath; - this.contentType = contentType; - this.contentLength = contentLength; - this.originalFileName = originalFileName; - this.bucketName = bucketName; - this.etag = etag; - this.confirmedAt = confirmedAt; - this.referenceType = referenceType; - } - - public void confirm(Long referenceId, String referenceType) { - this.referenceId = referenceId; - this.referenceType = referenceType; - this.status = ImageUploadStatus.CONFIRMED; - this.confirmedAt = LocalDateTime.now(); - } - - public void markAsDeleted() { - this.status = ImageUploadStatus.DELETED; - } - - public void updateUploadInfo(String etag, Long contentLength, String contentType) { - this.etag = etag; - this.contentLength = contentLength; - this.contentType = contentType; - this.status = ImageUploadStatus.UPLOADED; - } -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java deleted file mode 100644 index 4cc4fd4bb..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ImageUploadLogRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package app.bottlenote.common.file.domain; - -import app.bottlenote.common.annotation.DomainRepository; -import app.bottlenote.common.file.constant.ImageUploadStatus; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -@DomainRepository -public interface ImageUploadLogRepository { - - ImageUploadLog save(ImageUploadLog imageUploadLog); - - Optional findById(Long id); - - Optional findByImageKey(String imageKey); - - List findByUserId(Long userId); - - List findByStatusAndCreateAtBefore( - ImageUploadStatus status, LocalDateTime dateTime); - - List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); - - void delete(ImageUploadLog imageUploadLog); -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java new file mode 100644 index 000000000..6863e3a6f --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java @@ -0,0 +1,105 @@ +package app.bottlenote.common.file.domain; + +import app.bottlenote.common.file.constant.ResourceEventType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; + +@Getter +@Entity(name = "resource_log") +@Table(name = "resource_logs") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ResourceLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("요청 사용자 ID") + @Column(name = "user_id", nullable = false) + private Long userId; + + @Comment("리소스 키 (S3 객체 키 등)") + @Column(name = "resource_key", nullable = false, length = 1024) + private String resourceKey; + + @Comment("리소스 타입: IMAGE") + @Column(name = "resource_type", nullable = false, length = 64) + private String resourceType; + + @Comment("이벤트 타입: CREATED/ACTIVATED/INVALIDATED/DELETED") + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 32) + private ResourceEventType eventType; + + @Comment("연결된 엔티티 ID") + @Column(name = "reference_id") + private Long referenceId; + + @Comment("연결 타입: REVIEW/PROFILE/HELP") + @Column(name = "reference_type", length = 64) + private String referenceType; + + @Comment("조회 URL") + @Column(name = "view_url", length = 2048) + private String viewUrl; + + @Comment("저장 경로") + @Column(name = "root_path", length = 255) + private String rootPath; + + @Comment("버킷명") + @Column(name = "bucket_name", length = 128) + private String bucketName; + + @CreatedDate + @Comment("이벤트 발생일") + @Column(name = "create_at", nullable = false, updatable = false) + private LocalDateTime createAt; + + @CreatedBy + @Comment("이벤트 발생자") + @Column(name = "create_by", length = 255) + private String createBy; + + @Builder + public ResourceLog( + Long id, + Long userId, + String resourceKey, + String resourceType, + ResourceEventType eventType, + Long referenceId, + String referenceType, + String viewUrl, + String rootPath, + String bucketName, + LocalDateTime createAt, + String createBy) { + this.id = id; + this.userId = userId; + this.resourceKey = resourceKey; + this.resourceType = resourceType; + this.eventType = eventType; + this.referenceId = referenceId; + this.referenceType = referenceType; + this.viewUrl = viewUrl; + this.rootPath = rootPath; + this.bucketName = bucketName; + this.createAt = createAt != null ? createAt : LocalDateTime.now(); + this.createBy = createBy; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java new file mode 100644 index 000000000..824cadbe4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java @@ -0,0 +1,28 @@ +package app.bottlenote.common.file.domain; + +import app.bottlenote.common.annotation.DomainRepository; +import app.bottlenote.common.file.constant.ResourceEventType; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@DomainRepository +public interface ResourceLogRepository { + + ResourceLog save(ResourceLog resourceLog); + + Optional findById(Long id); + + List findByResourceKey(String resourceKey); + + List findByUserId(Long userId); + + List findByEventTypeAndCreateAtBefore( + ResourceEventType eventType, LocalDateTime dateTime); + + List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); + + Optional findLatestByResourceKey(String resourceKey); + + void delete(ResourceLog resourceLog); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java deleted file mode 100644 index 19893c445..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadLogRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package app.bottlenote.common.file.dto.request; - -import lombok.Builder; - -@Builder -public record ImageUploadLogRequest( - Long userId, - String imageKey, - String viewUrl, - String rootPath, - String bucketName, - String originalFileName, - String contentType, - Long contentLength) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ResourceLogRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ResourceLogRequest.java new file mode 100644 index 000000000..886812216 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ResourceLogRequest.java @@ -0,0 +1,7 @@ +package app.bottlenote.common.file.dto.request; + +import lombok.Builder; + +@Builder +public record ResourceLogRequest( + Long userId, String resourceKey, String viewUrl, String rootPath, String bucketName) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java deleted file mode 100644 index 302b30e29..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package app.bottlenote.common.file.dto.response; - -import app.bottlenote.common.file.constant.ImageUploadStatus; -import java.time.LocalDateTime; -import lombok.Builder; - -@Builder -public record ImageUploadLogResponse( - Long id, - Long userId, - String imageKey, - String viewUrl, - ImageUploadStatus status, - Long referenceId, - String referenceType, - String rootPath, - String contentType, - Long contentLength, - String originalFileName, - String bucketName, - String etag, - LocalDateTime createdAt, - LocalDateTime confirmedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogItem.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogItem.java similarity index 55% rename from bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogItem.java rename to bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogItem.java index 24de980f0..716e08c57 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ImageUploadLogItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogItem.java @@ -1,19 +1,19 @@ package app.bottlenote.common.file.dto.response; -import app.bottlenote.common.file.constant.ImageUploadStatus; +import app.bottlenote.common.file.constant.ResourceEventType; import java.time.LocalDateTime; import lombok.Builder; @Builder -public record ImageUploadLogItem( +public record ResourceLogItem( Long id, Long userId, - String imageKey, - String viewUrl, - ImageUploadStatus status, + String resourceKey, + String resourceType, + ResourceEventType eventType, Long referenceId, String referenceType, + String viewUrl, String rootPath, String bucketName, - LocalDateTime createdAt, - LocalDateTime confirmedAt) {} + LocalDateTime createAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java new file mode 100644 index 000000000..e4bf219df --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java @@ -0,0 +1,19 @@ +package app.bottlenote.common.file.dto.response; + +import app.bottlenote.common.file.constant.ResourceEventType; +import java.time.LocalDateTime; +import lombok.Builder; + +@Builder +public record ResourceLogResponse( + Long id, + Long userId, + String resourceKey, + String resourceType, + ResourceEventType eventType, + Long referenceId, + String referenceType, + String viewUrl, + String rootPath, + String bucketName, + LocalDateTime createAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java deleted file mode 100644 index 8704dacd1..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaImageUploadLogRepository.java +++ /dev/null @@ -1,27 +0,0 @@ -package app.bottlenote.common.file.repository; - -import app.bottlenote.common.annotation.JpaRepositoryImpl; -import app.bottlenote.common.file.constant.ImageUploadStatus; -import app.bottlenote.common.file.domain.ImageUploadLog; -import app.bottlenote.common.file.domain.ImageUploadLogRepository; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -@JpaRepositoryImpl -public interface JpaImageUploadLogRepository - extends ImageUploadLogRepository, JpaRepository { - - Optional findByImageKey(String imageKey); - - List findByUserId(Long userId); - - @Query("SELECT i FROM image_upload_log i WHERE i.status = :status AND i.createAt < :dateTime") - List findByStatusAndCreateAtBefore( - @Param("status") ImageUploadStatus status, @Param("dateTime") LocalDateTime dateTime); - - List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java new file mode 100644 index 000000000..a16b9eec4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java @@ -0,0 +1,31 @@ +package app.bottlenote.common.file.repository; + +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.domain.ResourceLogRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +@JpaRepositoryImpl +public interface JpaResourceLogRepository + extends ResourceLogRepository, JpaRepository { + + List findByResourceKey(String resourceKey); + + List findByUserId(Long userId); + + @Query("SELECT r FROM resource_log r WHERE r.eventType = :eventType AND r.createAt < :dateTime") + List findByEventTypeAndCreateAtBefore( + @Param("eventType") ResourceEventType eventType, @Param("dateTime") LocalDateTime dateTime); + + List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); + + @Query( + "SELECT r FROM resource_log r WHERE r.resourceKey = :resourceKey ORDER BY r.createAt DESC LIMIT 1") + Optional findLatestByResourceKey(@Param("resourceKey") String resourceKey); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java deleted file mode 100644 index 49cca6f16..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadLogService.java +++ /dev/null @@ -1,145 +0,0 @@ -package app.bottlenote.common.file.service; - -import app.bottlenote.common.file.constant.ImageUploadStatus; -import app.bottlenote.common.file.domain.ImageUploadLog; -import app.bottlenote.common.file.domain.ImageUploadLogRepository; -import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; -import app.bottlenote.common.file.dto.response.ImageUploadLogItem; -import app.bottlenote.common.file.dto.response.ImageUploadLogResponse; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class ImageUploadLogService { - - private final ImageUploadLogRepository imageUploadLogRepository; - - /** - * 비동기로 이미지 로그를 저장한다. - * - * @param request 이미지 업로드 로그 요청 - * @return 저장된 이미지 업로드 로그 응답 - */ - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public CompletableFuture saveAsync(ImageUploadLogRequest request) { - ImageUploadLog entity = - ImageUploadLog.builder() - .userId(request.userId()) - .imageKey(request.imageKey()) - .viewUrl(request.viewUrl()) - .rootPath(request.rootPath()) - .bucketName(request.bucketName()) - .originalFileName(request.originalFileName()) - .contentType(request.contentType()) - .contentLength(request.contentLength()) - .status(ImageUploadStatus.PENDING) - .build(); - ImageUploadLog saved = imageUploadLogRepository.save(entity); - log.info("이미지 업로드 로그 저장 완료 - imageKey: {}, userId: {}", saved.getImageKey(), saved.getUserId()); - return CompletableFuture.completedFuture(toResponse(saved)); - } - - /** - * 이미지 로그 정보를 조회한다. - * - * @param imageKey S3 객체 키 - * @return 이미지 업로드 로그 응답 - */ - @Transactional(readOnly = true) - public Optional findByImageKey(String imageKey) { - return imageUploadLogRepository.findByImageKey(imageKey).map(this::toResponse); - } - - /** - * 날짜와 상태를 기준으로 미확정된 로그 목록을 조회한다. - * - * @param status 조회할 상태 - * @param dateTime 기준 날짜 (이 날짜 이전에 생성된 로그) - * @return 이미지 업로드 로그 아이템 목록 - */ - @Transactional(readOnly = true) - public List findUnconfirmedLogs( - ImageUploadStatus status, LocalDateTime dateTime) { - return imageUploadLogRepository.findByStatusAndCreateAtBefore(status, dateTime).stream() - .map(this::toItem) - .toList(); - } - - private ImageUploadLogItem toItem(ImageUploadLog log) { - return ImageUploadLogItem.builder() - .id(log.getId()) - .userId(log.getUserId()) - .imageKey(log.getImageKey()) - .viewUrl(log.getViewUrl()) - .status(log.getStatus()) - .referenceId(log.getReferenceId()) - .referenceType(log.getReferenceType()) - .rootPath(log.getRootPath()) - .bucketName(log.getBucketName()) - .createdAt(log.getCreateAt()) - .confirmedAt(log.getConfirmedAt()) - .build(); - } - - private ImageUploadLogResponse toResponse(ImageUploadLog log) { - return ImageUploadLogResponse.builder() - .id(log.getId()) - .userId(log.getUserId()) - .imageKey(log.getImageKey()) - .viewUrl(log.getViewUrl()) - .status(log.getStatus()) - .referenceId(log.getReferenceId()) - .referenceType(log.getReferenceType()) - .rootPath(log.getRootPath()) - .contentType(log.getContentType()) - .contentLength(log.getContentLength()) - .originalFileName(log.getOriginalFileName()) - .bucketName(log.getBucketName()) - .etag(log.getEtag()) - .createdAt(log.getCreateAt()) - .confirmedAt(log.getConfirmedAt()) - .build(); - } - - /** - * 비동기로 이미지 상태를 업데이트한다. - * - * @param imageKey S3 객체 키 - * @param referenceId 연결된 엔티티 ID - * @param referenceType 연결 타입 - * @return 업데이트된 이미지 업로드 로그 응답 - */ - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public CompletableFuture> confirmAsync( - String imageKey, Long referenceId, String referenceType) { - return imageUploadLogRepository - .findByImageKey(imageKey) - .map( - uploadLog -> { - uploadLog.confirm(referenceId, referenceType); - log.info( - "이미지 상태 확정 완료 - imageKey: {}, referenceId: {}, referenceType: {}", - imageKey, - referenceId, - referenceType); - return CompletableFuture.completedFuture(Optional.of(toResponse(uploadLog))); - }) - .orElseGet( - () -> { - log.warn("이미지 로그를 찾을 수 없음 - imageKey: {}", imageKey); - return CompletableFuture.completedFuture(Optional.empty()); - }); - } -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java index 4a01ab4d9..1373688e6 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java @@ -2,8 +2,8 @@ import app.bottlenote.common.annotation.ThirdPartyService; import app.bottlenote.common.file.PreSignUrlProvider; -import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; import app.bottlenote.common.file.dto.request.ImageUploadRequest; +import app.bottlenote.common.file.dto.request.ResourceLogRequest; import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.global.security.SecurityContextUtil; @@ -20,17 +20,17 @@ public class ImageUploadService implements PreSignUrlProvider { private static final Integer EXPIRY_TIME = 5; - private final ImageUploadLogService imageUploadLogService; + private final ResourceCommandService resourceCommandService; private final AmazonS3 amazonS3; private final String imageBucketName; private final String cloudFrontUrl; public ImageUploadService( - ImageUploadLogService imageUploadLogService, + ResourceCommandService resourceCommandService, AmazonS3 amazonS3, @Value("${amazon.aws.bucket}") String imageBucketName, @Value("${amazon.aws.cloudFrontUrl}") String cloudFrontUrl) { - this.imageUploadLogService = imageUploadLogService; + this.resourceCommandService = resourceCommandService; this.amazonS3 = amazonS3; this.imageBucketName = imageBucketName; this.cloudFrontUrl = cloudFrontUrl; @@ -91,15 +91,15 @@ private void saveImageUploadLogs(String rootPath, List items) { items.forEach( item -> { String imageKey = extractImageKey(item.viewUrl()); - ImageUploadLogRequest logRequest = - ImageUploadLogRequest.builder() + ResourceLogRequest logRequest = + ResourceLogRequest.builder() .userId(userId) - .imageKey(imageKey) + .resourceKey(imageKey) .viewUrl(item.viewUrl()) .rootPath(rootPath) .bucketName(imageBucketName) .build(); - imageUploadLogService.saveAsync(logRequest); + resourceCommandService.saveImageResourceCreated(logRequest); })); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java new file mode 100644 index 000000000..d2aa36d1e --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java @@ -0,0 +1,152 @@ +package app.bottlenote.common.file.service; + +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.domain.ResourceLogRepository; +import app.bottlenote.common.file.dto.request.ResourceLogRequest; +import app.bottlenote.common.file.dto.response.ResourceLogItem; +import app.bottlenote.common.file.dto.response.ResourceLogResponse; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ResourceCommandService { + + private static final String RESOURCE_TYPE_IMAGE = "IMAGE"; + + private final ResourceLogRepository resourceLogRepository; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CompletableFuture saveImageResourceCreated( + ResourceLogRequest request) { + ResourceLog entity = + ResourceLog.builder() + .userId(request.userId()) + .resourceKey(request.resourceKey()) + .resourceType(RESOURCE_TYPE_IMAGE) + .eventType(ResourceEventType.CREATED) + .viewUrl(request.viewUrl()) + .rootPath(request.rootPath()) + .bucketName(request.bucketName()) + .build(); + ResourceLog saved = resourceLogRepository.save(entity); + log.info( + "이미지 리소스 생성 로그 저장 - resourceKey: {}, userId: {}", + saved.getResourceKey(), + saved.getUserId()); + return CompletableFuture.completedFuture(toResponse(saved)); + } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CompletableFuture> activateImageResource( + String resourceKey, Long referenceId, String referenceType) { + ResourceLog entity = + ResourceLog.builder() + .userId(getUserIdFromLatestLog(resourceKey)) + .resourceKey(resourceKey) + .resourceType(RESOURCE_TYPE_IMAGE) + .eventType(ResourceEventType.ACTIVATED) + .referenceId(referenceId) + .referenceType(referenceType) + .viewUrl(getViewUrlFromLatestLog(resourceKey)) + .build(); + ResourceLog saved = resourceLogRepository.save(entity); + log.info("이미지 리소스 활성화 로그 저장 - resourceKey: {}, referenceId: {}", resourceKey, referenceId); + return CompletableFuture.completedFuture(Optional.of(toResponse(saved))); + } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CompletableFuture> invalidateImageResource( + String resourceKey) { + ResourceLog entity = + ResourceLog.builder() + .userId(getUserIdFromLatestLog(resourceKey)) + .resourceKey(resourceKey) + .resourceType(RESOURCE_TYPE_IMAGE) + .eventType(ResourceEventType.INVALIDATED) + .viewUrl(getViewUrlFromLatestLog(resourceKey)) + .build(); + ResourceLog saved = resourceLogRepository.save(entity); + log.info("이미지 리소스 무효화 로그 저장 - resourceKey: {}", resourceKey); + return CompletableFuture.completedFuture(Optional.of(toResponse(saved))); + } + + @Transactional(readOnly = true) + public Optional findLatestByResourceKey(String resourceKey) { + return resourceLogRepository.findLatestByResourceKey(resourceKey).map(this::toResponse); + } + + @Transactional(readOnly = true) + public List findByEventTypeAndCreateAtBefore( + ResourceEventType eventType, LocalDateTime dateTime) { + return resourceLogRepository.findByEventTypeAndCreateAtBefore(eventType, dateTime).stream() + .map(this::toItem) + .toList(); + } + + @Transactional(readOnly = true) + public List findByResourceKey(String resourceKey) { + return resourceLogRepository.findByResourceKey(resourceKey).stream() + .map(this::toResponse) + .toList(); + } + + private Long getUserIdFromLatestLog(String resourceKey) { + return resourceLogRepository + .findLatestByResourceKey(resourceKey) + .map(ResourceLog::getUserId) + .orElse(null); + } + + private String getViewUrlFromLatestLog(String resourceKey) { + return resourceLogRepository + .findLatestByResourceKey(resourceKey) + .map(ResourceLog::getViewUrl) + .orElse(null); + } + + private ResourceLogItem toItem(ResourceLog log) { + return ResourceLogItem.builder() + .id(log.getId()) + .userId(log.getUserId()) + .resourceKey(log.getResourceKey()) + .resourceType(log.getResourceType()) + .eventType(log.getEventType()) + .referenceId(log.getReferenceId()) + .referenceType(log.getReferenceType()) + .viewUrl(log.getViewUrl()) + .rootPath(log.getRootPath()) + .bucketName(log.getBucketName()) + .createAt(log.getCreateAt()) + .build(); + } + + private ResourceLogResponse toResponse(ResourceLog log) { + return ResourceLogResponse.builder() + .id(log.getId()) + .userId(log.getUserId()) + .resourceKey(log.getResourceKey()) + .resourceType(log.getResourceType()) + .eventType(log.getEventType()) + .referenceId(log.getReferenceId()) + .referenceType(log.getReferenceType()) + .viewUrl(log.getViewUrl()) + .rootPath(log.getRootPath()) + .bucketName(log.getBucketName()) + .createAt(log.getCreateAt()) + .build(); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index 3be0b1a4a..b32b5a130 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -4,13 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import app.bottlenote.common.file.constant.ImageUploadStatus; -import app.bottlenote.common.file.domain.ImageUploadLog; -import app.bottlenote.common.file.domain.ImageUploadLogRepository; +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.domain.ResourceLogRepository; import app.bottlenote.common.file.dto.request.ImageUploadRequest; import app.bottlenote.common.file.dto.response.ImageUploadResponse; -import app.bottlenote.common.file.service.ImageUploadLogService; import app.bottlenote.common.file.service.ImageUploadService; +import app.bottlenote.common.file.service.ResourceCommandService; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; import com.amazonaws.client.builder.AwsClientBuilder; @@ -20,6 +20,7 @@ import java.net.HttpURLConnection; import java.net.URL; import java.time.LocalDateTime; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -59,8 +60,8 @@ class ImageUploadUnitTest { private static AmazonS3 amazonS3; private ImageUploadService imageUploadService; - private ImageUploadLogService imageUploadLogService; - private InMemoryImageUploadLogRepository imageUploadLogRepository; + private ResourceCommandService resourceCommandService; + private InMemoryResourceLogRepository resourceLogRepository; @BeforeAll static void setUpContainer() { @@ -81,16 +82,16 @@ static void setUpContainer() { @BeforeEach void setUp() { - imageUploadLogRepository = new InMemoryImageUploadLogRepository(); - imageUploadLogService = new ImageUploadLogService(imageUploadLogRepository); + resourceLogRepository = new InMemoryResourceLogRepository(); + resourceCommandService = new ResourceCommandService(resourceLogRepository); imageUploadService = - new ImageUploadService(imageUploadLogService, amazonS3, TEST_BUCKET, CLOUD_FRONT_URL); + new ImageUploadService(resourceCommandService, amazonS3, TEST_BUCKET, CLOUD_FRONT_URL); } @AfterEach void tearDown() { SecurityContextHolder.clearContext(); - imageUploadLogRepository.clear(); + resourceLogRepository.clear(); } @Nested @@ -176,11 +177,11 @@ void test_3() throws Exception { } @Nested - @DisplayName("이미지 업로드 로그 저장 테스트") - class ImageUploadLogTest { + @DisplayName("리소스 로그 저장 테스트") + class ResourceLogTest { @Test - @DisplayName("로그인 사용자가 PreSigned URL 생성 시 로그가 저장된다") + @DisplayName("로그인 사용자가 PreSigned URL 생성 시 CREATED 이벤트 로그가 저장된다") void test_1() { // given Long userId = 1L; @@ -192,9 +193,10 @@ void test_1() { imageUploadService.getPreSignUrl(request); // then - List logs = imageUploadLogRepository.findByUserId(userId); + List logs = resourceLogRepository.findByUserId(userId); assertEquals(2, logs.size()); - assertEquals(ImageUploadStatus.PENDING, logs.get(0).getStatus()); + assertEquals(ResourceEventType.CREATED, logs.get(0).getEventType()); + assertEquals("IMAGE", logs.get(0).getResourceType()); log.info("저장된 로그 수 = {}", logs.size()); } @@ -209,61 +211,56 @@ void test_2() { imageUploadService.getPreSignUrl(request); // then - List logs = imageUploadLogRepository.findAll(); + List logs = resourceLogRepository.findAll(); assertEquals(0, logs.size()); log.info("저장된 로그 수 = {}", logs.size()); } } - static class InMemoryImageUploadLogRepository implements ImageUploadLogRepository { + static class InMemoryResourceLogRepository implements ResourceLogRepository { - private final Map database = new HashMap<>(); + private final Map database = new HashMap<>(); @Override - public ImageUploadLog save(ImageUploadLog imageUploadLog) { - Long id = (Long) ReflectionTestUtils.getField(imageUploadLog, "id"); + public ResourceLog save(ResourceLog resourceLog) { + Long id = (Long) ReflectionTestUtils.getField(resourceLog, "id"); if (id == null) { id = database.size() + 1L; - ReflectionTestUtils.setField(imageUploadLog, "id", id); + ReflectionTestUtils.setField(resourceLog, "id", id); } - database.put(id, imageUploadLog); - return imageUploadLog; + database.put(id, resourceLog); + return resourceLog; } @Override - public Optional findById(Long id) { + public Optional findById(Long id) { return Optional.ofNullable(database.get(id)); } @Override - public Optional findByImageKey(String imageKey) { + public List findByResourceKey(String resourceKey) { return database.values().stream() - .filter(log -> log.getImageKey().equals(imageKey)) - .findFirst(); + .filter(log -> log.getResourceKey().equals(resourceKey)) + .toList(); } @Override - public List findByUserId(Long userId) { + public List findByUserId(Long userId) { return database.values().stream().filter(log -> log.getUserId().equals(userId)).toList(); } @Override - public List findByStatusAndCreateAtBefore( - ImageUploadStatus status, LocalDateTime dateTime) { + public List findByEventTypeAndCreateAtBefore( + ResourceEventType eventType, LocalDateTime dateTime) { return database.values().stream() - .filter(log -> log.getStatus() == status) - .filter( - log -> { - LocalDateTime createAt = - (LocalDateTime) ReflectionTestUtils.getField(log, "createAt"); - return createAt == null || createAt.isBefore(dateTime); - }) + .filter(log -> log.getEventType() == eventType) + .filter(log -> log.getCreateAt() == null || log.getCreateAt().isBefore(dateTime)) .toList(); } @Override - public List findByReferenceIdAndReferenceType( + public List findByReferenceIdAndReferenceType( Long referenceId, String referenceType) { return database.values().stream() .filter(log -> referenceId.equals(log.getReferenceId())) @@ -272,15 +269,22 @@ public List findByReferenceIdAndReferenceType( } @Override - public void delete(ImageUploadLog imageUploadLog) { - database.remove(imageUploadLog.getId()); + public Optional findLatestByResourceKey(String resourceKey) { + return database.values().stream() + .filter(log -> log.getResourceKey().equals(resourceKey)) + .max(Comparator.comparing(ResourceLog::getCreateAt)); + } + + @Override + public void delete(ResourceLog resourceLog) { + database.remove(resourceLog.getId()); } public void clear() { database.clear(); } - public List findAll() { + public List findAll() { return List.copyOf(database.values()); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java index c96197cf8..81e210bf3 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java @@ -10,10 +10,10 @@ import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.common.file.exception.FileException; -import app.bottlenote.common.file.service.ImageUploadLogService; import app.bottlenote.common.file.service.ImageUploadService; +import app.bottlenote.common.file.service.ResourceCommandService; import app.bottlenote.common.file.upload.fixture.FakeAmazonS3; -import app.bottlenote.common.file.upload.fixture.InMemoryImageUploadLogRepository; +import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; import java.time.LocalDate; import java.util.Calendar; import java.util.concurrent.TimeUnit; @@ -38,11 +38,11 @@ class CoreImageUploadServiceTest { @BeforeEach void setUp() { - ImageUploadLogService imageUploadLogService = - new ImageUploadLogService(new InMemoryImageUploadLogRepository()); + ResourceCommandService resourceCommandService = + new ResourceCommandService(new InMemoryResourceLogRepository()); imageUploadService = new ImageUploadService( - imageUploadLogService, new FakeAmazonS3(), ImageBucketName, cloudFrontUrl) { + resourceCommandService, new FakeAmazonS3(), ImageBucketName, cloudFrontUrl) { @Override public String getImageKey(String rootPath, Long index) { if (rootPath.startsWith(PATH_DELIMITER)) { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java deleted file mode 100644 index 5776c103a..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadLogServiceTest.java +++ /dev/null @@ -1,239 +0,0 @@ -package app.bottlenote.common.file.upload; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import app.bottlenote.common.file.constant.ImageUploadStatus; -import app.bottlenote.common.file.dto.request.ImageUploadLogRequest; -import app.bottlenote.common.file.dto.response.ImageUploadLogItem; -import app.bottlenote.common.file.dto.response.ImageUploadLogResponse; -import app.bottlenote.common.file.service.ImageUploadLogService; -import app.bottlenote.common.file.upload.fixture.InMemoryImageUploadLogRepository; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -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.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.test.util.ReflectionTestUtils; - -@Tag("unit") -@DisplayName("[unit] [service] ImageUploadLog") -class ImageUploadLogServiceTest { - - private static final Logger log = LoggerFactory.getLogger(ImageUploadLogServiceTest.class); - private ImageUploadLogService imageUploadLogService; - private InMemoryImageUploadLogRepository imageUploadLogRepository; - - @BeforeEach - void setUp() { - imageUploadLogRepository = new InMemoryImageUploadLogRepository(); - imageUploadLogService = new ImageUploadLogService(imageUploadLogRepository); - } - - private ImageUploadLogRequest createRequest(Long userId, String imageKey) { - return ImageUploadLogRequest.builder() - .userId(userId) - .imageKey(imageKey) - .viewUrl("https://cdn.example.com/" + imageKey) - .rootPath("review") - .bucketName("test-bucket") - .originalFileName("test.jpg") - .contentType("image/jpeg") - .contentLength(1024L) - .build(); - } - - @Nested - @DisplayName("이미지 로그 저장 테스트") - class SaveAsyncTest { - - @Test - @DisplayName("이미지 로그 요청을 저장할 때 PENDING 상태로 저장된다") - void test_1() { - // given - ImageUploadLogRequest request = createRequest(1L, "review/20251231/1-uuid.jpg"); - - // when - CompletableFuture future = imageUploadLogService.saveAsync(request); - ImageUploadLogResponse response = future.join(); - - // then - assertNotNull(response); - assertEquals(1L, response.id()); - assertEquals(1L, response.userId()); - assertEquals("review/20251231/1-uuid.jpg", response.imageKey()); - assertEquals(ImageUploadStatus.PENDING, response.status()); - - log.info("저장된 응답 = {}", response); - } - - @Test - @DisplayName("여러 이미지 로그를 저장할 때 각각 다른 ID로 저장된다") - void test_2() { - // given - ImageUploadLogRequest request1 = createRequest(1L, "review/20251231/1-uuid1.jpg"); - ImageUploadLogRequest request2 = createRequest(1L, "review/20251231/2-uuid2.jpg"); - - // when - ImageUploadLogResponse response1 = imageUploadLogService.saveAsync(request1).join(); - ImageUploadLogResponse response2 = imageUploadLogService.saveAsync(request2).join(); - - // then - assertEquals(1L, response1.id()); - assertEquals(2L, response2.id()); - assertEquals(2, imageUploadLogRepository.findAll().size()); - - log.info("첫 번째 저장 = {}", response1); - log.info("두 번째 저장 = {}", response2); - } - } - - @Nested - @DisplayName("이미지 로그 조회 테스트") - class FindByImageKeyTest { - - @Test - @DisplayName("imageKey로 조회할 때 저장된 로그를 반환한다") - void test_1() { - // given - String imageKey = "review/20251231/1-uuid.jpg"; - imageUploadLogService.saveAsync(createRequest(1L, imageKey)).join(); - - // when - Optional result = imageUploadLogService.findByImageKey(imageKey); - - // then - assertTrue(result.isPresent()); - assertEquals(imageKey, result.get().imageKey()); - - log.info("조회 결과 = {}", result.get()); - } - - @Test - @DisplayName("존재하지 않는 imageKey로 조회할 때 빈 결과를 반환한다") - void test_2() { - // given - String imageKey = "non-existent-key.jpg"; - - // when - Optional result = imageUploadLogService.findByImageKey(imageKey); - - // then - assertTrue(result.isEmpty()); - - log.info("조회 결과 = {}", result); - } - } - - @Nested - @DisplayName("미확정 로그 목록 조회 테스트") - class FindUnconfirmedLogsTest { - - @Test - @DisplayName("상태와 날짜 기준으로 미확정 로그 목록을 조회할 때 조건에 맞는 목록을 반환한다") - void test_1() { - // given - imageUploadLogService.saveAsync(createRequest(1L, "review/20251231/1-uuid1.jpg")).join(); - imageUploadLogService.saveAsync(createRequest(2L, "review/20251231/2-uuid2.jpg")).join(); - - // createAt 설정 (과거 날짜로) - imageUploadLogRepository - .findById(1L) - .ifPresent( - uploadLog -> - ReflectionTestUtils.setField( - uploadLog, "createAt", LocalDateTime.now().minusDays(7))); - imageUploadLogRepository - .findById(2L) - .ifPresent( - uploadLog -> - ReflectionTestUtils.setField( - uploadLog, "createAt", LocalDateTime.now().minusDays(3))); - - // when - List result = - imageUploadLogService.findUnconfirmedLogs( - ImageUploadStatus.PENDING, LocalDateTime.now().minusDays(1)); - - // then - assertEquals(2, result.size()); - - log.info("조회된 미확정 로그 수 = {}", result.size()); - } - - @Test - @DisplayName("CONFIRMED 상태의 로그는 PENDING 조회 시 제외된다") - void test_2() { - // given - imageUploadLogService.saveAsync(createRequest(1L, "review/20251231/1-uuid1.jpg")).join(); - imageUploadLogRepository - .findById(1L) - .ifPresent( - uploadLog -> { - ReflectionTestUtils.setField( - uploadLog, "createAt", LocalDateTime.now().minusDays(7)); - uploadLog.confirm(100L, "REVIEW"); - }); - - // when - List result = - imageUploadLogService.findUnconfirmedLogs(ImageUploadStatus.PENDING, LocalDateTime.now()); - - // then - assertEquals(0, result.size()); - - log.info("조회된 미확정 로그 수 = {}", result.size()); - } - } - - @Nested - @DisplayName("이미지 상태 확정 테스트") - class ConfirmAsyncTest { - - @Test - @DisplayName("이미지 상태를 확정할 때 CONFIRMED 상태로 변경된다") - void test_1() { - // given - String imageKey = "review/20251231/1-uuid.jpg"; - imageUploadLogService.saveAsync(createRequest(1L, imageKey)).join(); - - // when - CompletableFuture> future = - imageUploadLogService.confirmAsync(imageKey, 100L, "REVIEW"); - Optional result = future.join(); - - // then - assertTrue(result.isPresent()); - assertEquals(ImageUploadStatus.CONFIRMED, result.get().status()); - assertEquals(100L, result.get().referenceId()); - assertEquals("REVIEW", result.get().referenceType()); - assertNotNull(result.get().confirmedAt()); - - log.info("확정 결과 = {}", result.get()); - } - - @Test - @DisplayName("존재하지 않는 imageKey로 확정 요청할 때 빈 결과를 반환한다") - void test_2() { - // given - String imageKey = "non-existent-key.jpg"; - - // when - CompletableFuture> future = - imageUploadLogService.confirmAsync(imageKey, 100L, "REVIEW"); - Optional result = future.join(); - - // then - assertTrue(result.isEmpty()); - - log.info("확정 결과 = {}", result); - } - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java new file mode 100644 index 000000000..7aa55aab7 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java @@ -0,0 +1,269 @@ +package app.bottlenote.common.file.upload; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.dto.request.ResourceLogRequest; +import app.bottlenote.common.file.dto.response.ResourceLogItem; +import app.bottlenote.common.file.dto.response.ResourceLogResponse; +import app.bottlenote.common.file.service.ResourceCommandService; +import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.test.util.ReflectionTestUtils; + +@Tag("unit") +@DisplayName("[unit] [service] ResourceCommandService") +class ResourceCommandServiceTest { + + private static final Logger log = LoggerFactory.getLogger(ResourceCommandServiceTest.class); + private ResourceCommandService resourceCommandService; + private InMemoryResourceLogRepository resourceLogRepository; + + @BeforeEach + void setUp() { + resourceLogRepository = new InMemoryResourceLogRepository(); + resourceCommandService = new ResourceCommandService(resourceLogRepository); + } + + private ResourceLogRequest createRequest(Long userId, String resourceKey) { + return ResourceLogRequest.builder() + .userId(userId) + .resourceKey(resourceKey) + .viewUrl("https://cdn.example.com/" + resourceKey) + .rootPath("review") + .bucketName("test-bucket") + .build(); + } + + @Nested + @DisplayName("이미지 리소스 생성 로그 저장 테스트") + class SaveImageResourceCreatedTest { + + @Test + @DisplayName("이미지 리소스 생성 요청을 저장할 때 CREATED 이벤트로 저장된다") + void test_1() { + // given + ResourceLogRequest request = createRequest(1L, "review/20251231/1-uuid.jpg"); + + // when + CompletableFuture future = + resourceCommandService.saveImageResourceCreated(request); + ResourceLogResponse response = future.join(); + + // then + assertNotNull(response); + assertEquals(1L, response.id()); + assertEquals(1L, response.userId()); + assertEquals("review/20251231/1-uuid.jpg", response.resourceKey()); + assertEquals("IMAGE", response.resourceType()); + assertEquals(ResourceEventType.CREATED, response.eventType()); + + log.info("저장된 응답 = {}", response); + } + + @Test + @DisplayName("여러 이미지 리소스 생성 시 각각 다른 ID로 저장된다") + void test_2() { + // given + ResourceLogRequest request1 = createRequest(1L, "review/20251231/1-uuid1.jpg"); + ResourceLogRequest request2 = createRequest(1L, "review/20251231/2-uuid2.jpg"); + + // when + ResourceLogResponse response1 = + resourceCommandService.saveImageResourceCreated(request1).join(); + ResourceLogResponse response2 = + resourceCommandService.saveImageResourceCreated(request2).join(); + + // then + assertEquals(1L, response1.id()); + assertEquals(2L, response2.id()); + assertEquals(2, resourceLogRepository.findAll().size()); + + log.info("첫 번째 저장 = {}", response1); + log.info("두 번째 저장 = {}", response2); + } + } + + @Nested + @DisplayName("이미지 리소스 활성화 로그 저장 테스트") + class ActivateImageResourceTest { + + @Test + @DisplayName("이미지 리소스를 활성화할 때 ACTIVATED 이벤트로 저장된다") + void test_1() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + + // when + CompletableFuture> future = + resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW"); + Optional result = future.join(); + + // then + assertTrue(result.isPresent()); + assertEquals(ResourceEventType.ACTIVATED, result.get().eventType()); + assertEquals(100L, result.get().referenceId()); + assertEquals("REVIEW", result.get().referenceType()); + + log.info("활성화 결과 = {}", result.get()); + } + + @Test + @DisplayName("같은 리소스 키에 대해 CREATED, ACTIVATED 두 개의 로그가 저장된다") + void test_2() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + + // when + resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); + + // then + List logs = resourceCommandService.findByResourceKey(resourceKey); + assertEquals(2, logs.size()); + + log.info("저장된 로그 수 = {}", logs.size()); + } + } + + @Nested + @DisplayName("이미지 리소스 무효화 로그 저장 테스트") + class InvalidateImageResourceTest { + + @Test + @DisplayName("이미지 리소스를 무효화할 때 INVALIDATED 이벤트로 저장된다") + void test_1() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + + // when + CompletableFuture> future = + resourceCommandService.invalidateImageResource(resourceKey); + Optional result = future.join(); + + // then + assertTrue(result.isPresent()); + assertEquals(ResourceEventType.INVALIDATED, result.get().eventType()); + + log.info("무효화 결과 = {}", result.get()); + } + } + + @Nested + @DisplayName("리소스 로그 조회 테스트") + class FindResourceLogTest { + + @Test + @DisplayName("resourceKey로 최신 로그를 조회할 때 가장 최근 로그를 반환한다") + void test_1() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); + + // when + Optional result = + resourceCommandService.findLatestByResourceKey(resourceKey); + + // then + assertTrue(result.isPresent()); + assertEquals(ResourceEventType.ACTIVATED, result.get().eventType()); + + log.info("최신 로그 = {}", result.get()); + } + + @Test + @DisplayName("존재하지 않는 resourceKey로 조회할 때 빈 결과를 반환한다") + void test_2() { + // given + String resourceKey = "non-existent-key.jpg"; + + // when + Optional result = + resourceCommandService.findLatestByResourceKey(resourceKey); + + // then + assertTrue(result.isEmpty()); + + log.info("조회 결과 = {}", result); + } + } + + @Nested + @DisplayName("이벤트 타입별 로그 조회 테스트") + class FindByEventTypeTest { + + @Test + @DisplayName("CREATED 이벤트와 날짜 기준으로 로그 목록을 조회할 때 조건에 맞는 목록을 반환한다") + void test_1() { + // given + resourceCommandService + .saveImageResourceCreated(createRequest(1L, "review/20251231/1-uuid1.jpg")) + .join(); + resourceCommandService + .saveImageResourceCreated(createRequest(2L, "review/20251231/2-uuid2.jpg")) + .join(); + + // createAt 설정 (과거 날짜로) + resourceLogRepository + .findById(1L) + .ifPresent( + log -> + ReflectionTestUtils.setField(log, "createAt", LocalDateTime.now().minusDays(7))); + resourceLogRepository + .findById(2L) + .ifPresent( + log -> + ReflectionTestUtils.setField(log, "createAt", LocalDateTime.now().minusDays(3))); + + // when + List result = + resourceCommandService.findByEventTypeAndCreateAtBefore( + ResourceEventType.CREATED, LocalDateTime.now().minusDays(1)); + + // then + assertEquals(2, result.size()); + + log.info("조회된 로그 수 = {}", result.size()); + } + + @Test + @DisplayName("ACTIVATED 상태의 로그는 CREATED 조회 시 제외된다") + void test_2() { + // given + String resourceKey = "review/20251231/1-uuid1.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceLogRepository + .findById(1L) + .ifPresent( + log -> + ReflectionTestUtils.setField(log, "createAt", LocalDateTime.now().minusDays(7))); + resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); + + // when + List result = + resourceCommandService.findByEventTypeAndCreateAtBefore( + ResourceEventType.CREATED, LocalDateTime.now()); + + // then + assertEquals(1, result.size()); + assertEquals(ResourceEventType.CREATED, result.get(0).eventType()); + + log.info("조회된 로그 수 = {}", result.size()); + } + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java deleted file mode 100644 index 6d809cd3e..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryImageUploadLogRepository.java +++ /dev/null @@ -1,88 +0,0 @@ -package app.bottlenote.common.file.upload.fixture; - -import app.bottlenote.common.file.constant.ImageUploadStatus; -import app.bottlenote.common.file.domain.ImageUploadLog; -import app.bottlenote.common.file.domain.ImageUploadLogRepository; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.springframework.test.util.ReflectionTestUtils; - -public class InMemoryImageUploadLogRepository implements ImageUploadLogRepository { - - private static final Logger log = LogManager.getLogger(InMemoryImageUploadLogRepository.class); - Map database = new HashMap<>(); - - @Override - public ImageUploadLog save(ImageUploadLog imageUploadLog) { - Long id = (Long) ReflectionTestUtils.getField(imageUploadLog, "id"); - if (id != null && database.containsKey(id)) { - database.put(id, imageUploadLog); - } else { - id = database.size() + 1L; - database.put(id, imageUploadLog); - ReflectionTestUtils.setField(imageUploadLog, "id", id); - } - log.info("[InMemory] imageUploadLog repository save = {}", imageUploadLog); - return imageUploadLog; - } - - @Override - public Optional findById(Long id) { - return Optional.ofNullable(database.get(id)); - } - - @Override - public Optional findByImageKey(String imageKey) { - return database.values().stream() - .filter(uploadLog -> uploadLog.getImageKey().equals(imageKey)) - .findFirst(); - } - - @Override - public List findByUserId(Long userId) { - return database.values().stream() - .filter(uploadLog -> uploadLog.getUserId().equals(userId)) - .toList(); - } - - @Override - public List findByStatusAndCreateAtBefore( - ImageUploadStatus status, LocalDateTime dateTime) { - return database.values().stream() - .filter(uploadLog -> uploadLog.getStatus() == status) - .filter( - uploadLog -> { - LocalDateTime createAt = - (LocalDateTime) ReflectionTestUtils.getField(uploadLog, "createAt"); - return createAt == null || createAt.isBefore(dateTime); - }) - .toList(); - } - - @Override - public List findByReferenceIdAndReferenceType( - Long referenceId, String referenceType) { - return database.values().stream() - .filter(uploadLog -> referenceId.equals(uploadLog.getReferenceId())) - .filter(uploadLog -> referenceType.equals(uploadLog.getReferenceType())) - .toList(); - } - - @Override - public void delete(ImageUploadLog imageUploadLog) { - database.remove(imageUploadLog.getId()); - } - - public void clear() { - database.clear(); - } - - public List findAll() { - return List.copyOf(database.values()); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java new file mode 100644 index 000000000..64b19d180 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java @@ -0,0 +1,87 @@ +package app.bottlenote.common.file.upload.fixture; + +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.domain.ResourceLogRepository; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.test.util.ReflectionTestUtils; + +public class InMemoryResourceLogRepository implements ResourceLogRepository { + + private static final Logger log = LogManager.getLogger(InMemoryResourceLogRepository.class); + private final Map database = new HashMap<>(); + + @Override + public ResourceLog save(ResourceLog resourceLog) { + Long id = (Long) ReflectionTestUtils.getField(resourceLog, "id"); + if (id == null) { + id = database.size() + 1L; + ReflectionTestUtils.setField(resourceLog, "id", id); + } + database.put(id, resourceLog); + log.info("[InMemory] resourceLog repository save = {}", resourceLog); + return resourceLog; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(database.get(id)); + } + + @Override + public List findByResourceKey(String resourceKey) { + return database.values().stream() + .filter(log -> log.getResourceKey().equals(resourceKey)) + .toList(); + } + + @Override + public List findByUserId(Long userId) { + return database.values().stream().filter(log -> log.getUserId().equals(userId)).toList(); + } + + @Override + public List findByEventTypeAndCreateAtBefore( + ResourceEventType eventType, LocalDateTime dateTime) { + return database.values().stream() + .filter(log -> log.getEventType() == eventType) + .filter(log -> log.getCreateAt() == null || log.getCreateAt().isBefore(dateTime)) + .toList(); + } + + @Override + public List findByReferenceIdAndReferenceType( + Long referenceId, String referenceType) { + return database.values().stream() + .filter(log -> referenceId.equals(log.getReferenceId())) + .filter(log -> referenceType.equals(log.getReferenceType())) + .toList(); + } + + @Override + public Optional findLatestByResourceKey(String resourceKey) { + return database.values().stream() + .filter(log -> log.getResourceKey().equals(resourceKey)) + .max(Comparator.comparing(ResourceLog::getCreateAt)); + } + + @Override + public void delete(ResourceLog resourceLog) { + database.remove(resourceLog.getId()); + } + + public void clear() { + database.clear(); + } + + public List findAll() { + return List.copyOf(database.values()); + } +} diff --git a/git.environment-variables b/git.environment-variables index 57863a015..6e446486d 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 57863a0153841f0a9c85a026132477f54d045cdc +Subproject commit 6e446486d4292f92b13cf3790ab9cc8da10ba2fe From d48647abdedcd90c103ad2b80f4dbb9a3ae19c8e Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 6 Jan 2026 11:00:48 +0900 Subject: [PATCH 08/95] =?UTF-8?q?refactor:=20ImageUpload=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20Mock=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadControllerTest 삭제 (Mock 기반 컨트롤러 테스트 제거) - CoreImageUploadServiceTest + ImageUploadServiceTest 통합 - Mock 대신 Fake/Stub 기반 테스트로 통일 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../upload/CoreImageUploadServiceTest.java | 154 ---------- .../upload/ImageUploadControllerTest.java | 117 -------- .../file/upload/ImageUploadServiceTest.java | 274 ++++++++++++------ 3 files changed, 183 insertions(+), 362 deletions(-) delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadControllerTest.java diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java deleted file mode 100644 index 81e210bf3..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/CoreImageUploadServiceTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package app.bottlenote.common.file.upload; - -import static java.time.format.DateTimeFormatter.ofPattern; -import static org.hibernate.validator.internal.util.Contracts.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import app.bottlenote.common.file.dto.request.ImageUploadRequest; -import app.bottlenote.common.file.dto.response.ImageUploadItem; -import app.bottlenote.common.file.dto.response.ImageUploadResponse; -import app.bottlenote.common.file.exception.FileException; -import app.bottlenote.common.file.service.ImageUploadService; -import app.bottlenote.common.file.service.ResourceCommandService; -import app.bottlenote.common.file.upload.fixture.FakeAmazonS3; -import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; -import java.time.LocalDate; -import java.util.Calendar; -import java.util.concurrent.TimeUnit; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -@Tag("unit") -@DisplayName("[unit] [service] [fake] CoreImageUpload") -class CoreImageUploadServiceTest { - private static final String ImageBucketName = "테스트-버킷-이름"; - private static final String cloudFrontUrl = "https://testUrl.cloudfront.net"; - private static final String awsUrl = "https://" + ImageBucketName + ".s3.amazonaws.com/"; - private static final String uploadAt = LocalDate.of(2024, 5, 1).format(ofPattern("yyyyMMdd")); - private static final String fakeUUID = "ddd8d2d8-7b0c-47e9-91d0-d21251f891e8"; - private static final Logger log = LogManager.getLogger(CoreImageUploadServiceTest.class); - private ImageUploadService imageUploadService; - - @BeforeEach - void setUp() { - ResourceCommandService resourceCommandService = - new ResourceCommandService(new InMemoryResourceLogRepository()); - imageUploadService = - new ImageUploadService( - resourceCommandService, new FakeAmazonS3(), ImageBucketName, cloudFrontUrl) { - @Override - public String getImageKey(String rootPath, Long index) { - if (rootPath.startsWith(PATH_DELIMITER)) { - rootPath = rootPath.substring(1); - } - if (rootPath.endsWith(PATH_DELIMITER)) { - rootPath = rootPath.substring(0, rootPath.length() - 1); - } - String imageId = index + KEY_DELIMITER + fakeUUID + "." + EXTENSION; - return rootPath + PATH_DELIMITER + uploadAt + PATH_DELIMITER + imageId; - } - }; - } - - @Test - @DisplayName("PreSignUrl을 생성할 수 있다.") - void test_1() { - String key = imageUploadService.getImageKey("review", 1L); - String preSignUrl = imageUploadService.generatePreSignUrl(key); - - log.info("PreSignUrl: {}", preSignUrl); - assertNotNull(preSignUrl); - assertEquals(awsUrl + key, preSignUrl); - } - - @Test - @DisplayName("업로드용 인증 URL을 생성할 수 있다.") - void test_2() { - ImageUploadRequest request = new ImageUploadRequest("review", 2L); - - ImageUploadResponse preSignUrl = imageUploadService.getPreSignUrl(request); - - assertNotNull(preSignUrl); - assertEquals(request.uploadSize(), preSignUrl.uploadSize()); - assertEquals(5, preSignUrl.expiryTime()); - - for (Long index = 1L; index <= preSignUrl.imageUploadInfo().size(); index++) { - String imageKey = imageUploadService.getImageKey(request.rootPath(), index); - - String uploadUrlFixture = imageUploadService.generatePreSignUrl(imageKey); - String viewUrlFixture = imageUploadService.generateViewUrl(cloudFrontUrl, imageKey); - - ImageUploadItem info = preSignUrl.imageUploadInfo().get((int) (index - 1)); - - log.info("[{}] ImageUploadItem: {}", index, info); - Assertions.assertEquals(index, info.order()); - Assertions.assertEquals(uploadUrlFixture, info.uploadUrl()); - Assertions.assertEquals(viewUrlFixture, info.viewUrl()); - } - } - - @Test - @DisplayName("조회용 URL을 생성할 수 있다.") - void test_3() { - String imageKey = imageUploadService.getImageKey("review", 1L); - String viewUrl = imageUploadService.generateViewUrl(cloudFrontUrl, imageKey); - - log.info("ViewUrl: {}", viewUrl); - assertNotNull(viewUrl); - assertEquals(cloudFrontUrl + "/" + imageKey, viewUrl); - } - - @Test - @DisplayName("이미지 루트 경로와 인덱스를 제공해 이미지 키를 생성할 수 있다.") - void test_4() { - String imageKey = imageUploadService.getImageKey("review", 1L); - String expected = "review/20240501/1-ddd8d2d8-7b0c-47e9-91d0-d21251f891e8.jpg"; - - log.info("ImageKey: {}", imageKey); - assertNotNull(imageKey); - assertEquals(expected, imageKey); - } - - @Test - @DisplayName("기본 만료 시간은 5분이다.") - void test_5() { - // given - Calendar expectedExpiryTime = Calendar.getInstance(); - expectedExpiryTime.add(Calendar.MINUTE, 5); - - // when - Calendar actualExpiryTime = imageUploadService.getUploadExpiryTime(null); - - // then - log.info("ExpiryTime: {}", actualExpiryTime); - long diffInMillis = - Math.abs(expectedExpiryTime.getTimeInMillis() - actualExpiryTime.getTimeInMillis()); - assertTrue( - diffInMillis < TimeUnit.SECONDS.toMillis(1), "The difference should be less than 1 second"); - } - - @Test - @DisplayName("최대 만료 시간은 10분이다.") - void test_6() { - // given - Calendar expectedExpiryTime = Calendar.getInstance(); - expectedExpiryTime.add(Calendar.MINUTE, 10); - - // when - Calendar actualExpiryTime = imageUploadService.getUploadExpiryTime(10); - - // then - long diffInMillis = - Math.abs(expectedExpiryTime.getTimeInMillis() - actualExpiryTime.getTimeInMillis()); - assertTrue( - diffInMillis < TimeUnit.SECONDS.toMillis(1), "The difference should be less than 1 second"); - assertThrows(FileException.class, () -> imageUploadService.getUploadExpiryTime(11)); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadControllerTest.java deleted file mode 100644 index 072464472..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadControllerTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package app.bottlenote.common.file.upload; - -import static org.mockito.BDDMockito.given; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.common.file.controller.ImageUploadController; -import app.bottlenote.common.file.dto.request.ImageUploadRequest; -import app.bottlenote.common.file.dto.response.ImageUploadItem; -import app.bottlenote.common.file.dto.response.ImageUploadResponse; -import app.bottlenote.common.file.service.ImageUploadService; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.security.test.context.support.WithAnonymousUser; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -@Tag("unit") -@ActiveProfiles("test") -@DisplayName("[unit] [controller] [mock] CoreImageUpload") -@WebMvcTest(controllers = {ImageUploadController.class}) -class ImageUploadControllerTest { - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - @MockitoBean private ImageUploadService imageUploadService; - - static Stream provider_1() { - return Stream.of( - Arguments.of("모든값이 있을때", new ImageUploadRequest("images", 2L)), - Arguments.of("사이즈가 없을때", new ImageUploadRequest("images", null))); - } - - @WithMockUser() - @DisplayName("인증된 이미지 업로드 경로 요청 할 수 있다.") - @ParameterizedTest(name = "{0}") - @MethodSource("provider_1") - void test_1(String description, ImageUploadRequest request) throws Exception { - System.out.println("test -" + description); - // given - Long size = request.uploadSize(); - List infos = - List.of( - ImageUploadItem.builder() - .order(1L) - .uploadUrl("https://bottlenote.s3.ap-northeast-2.amazonaws.com/images/1") - .viewUrl("https://d1d1d1d1.cloudfront.net/images/1") - .build(), - ImageUploadItem.builder() - .order(2L) - .uploadUrl("https://bottlenote.s3.ap-northeast-2.amazonaws.com/images/2") - .viewUrl("https://d1d1d1d1.cloudfront.net/images/2") - .build()); - ImageUploadResponse response = - ImageUploadResponse.builder() - .imageUploadInfo(infos) - .uploadSize(size.intValue()) - .bucketName("image-bucket") - .expiryTime(5) - .build(); - - // when - given(imageUploadService.getPreSignUrl(request)).willReturn(response); - - // then - ResultActions resultActions = - mockMvc - .perform( - get("/api/v1/s3/presign-url") - .param("rootPath", request.rootPath()) - .param("uploadSize", String.valueOf(request.uploadSize())) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.uploadSize").value(size)); - resultActions.andExpect(jsonPath("$.data.imageUploadInfo[0].order").value(1)); - resultActions.andExpect( - jsonPath("$.data.imageUploadInfo[0].uploadUrl") - .value("https://bottlenote.s3.ap-northeast-2.amazonaws.com/images/1")); - resultActions.andExpect( - jsonPath("$.data.imageUploadInfo[0].viewUrl") - .value("https://d1d1d1d1.cloudfront.net/images/1")); - } - - @Test - @WithAnonymousUser - @DisplayName("인증되지 않은 유저가 요청할 경우 예외가 발생한다.") - void test_2() throws Exception { - // given - ImageUploadRequest request = new ImageUploadRequest("images", 2L); - // then - mockMvc - .perform( - get("/api/v1/s3/presign-url") - .param("rootPath", request.rootPath()) - .param("uploadSize", String.valueOf(request.uploadSize()))) - .andExpect(status().isUnauthorized()) - .andDo(print()); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java index cbcbafd4f..df12b9aa7 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java @@ -1,120 +1,212 @@ package app.bottlenote.common.file.upload; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.Mockito.when; +import static java.time.format.DateTimeFormatter.ofPattern; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import app.bottlenote.common.file.dto.request.ImageUploadRequest; import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; +import app.bottlenote.common.file.exception.FileException; import app.bottlenote.common.file.service.ImageUploadService; -import com.amazonaws.HttpMethod; -import com.amazonaws.services.s3.AmazonS3; -import java.net.URL; -import java.util.List; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.junit.jupiter.api.Assertions; +import app.bottlenote.common.file.service.ResourceCommandService; +import app.bottlenote.common.file.upload.fixture.FakeAmazonS3; +import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; +import java.time.LocalDate; +import java.util.Calendar; +import java.util.concurrent.TimeUnit; 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.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.test.util.ReflectionTestUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Tag("unit") -@DisplayName("[unit] [service] [mock] CoreImageUpload") -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) +@DisplayName("[unit] [service] ImageUploadService") class ImageUploadServiceTest { - private static final Logger log = LogManager.getLogger(ImageUploadServiceTest.class); - @InjectMocks private ImageUploadService imageUploadService; + private static final Logger log = LoggerFactory.getLogger(ImageUploadServiceTest.class); + private static final String BUCKET_NAME = "test-bucket"; + private static final String CLOUD_FRONT_URL = "https://cdn.example.com"; + private static final String AWS_URL = "https://" + BUCKET_NAME + ".s3.amazonaws.com/"; + private static final String UPLOAD_DATE = LocalDate.of(2024, 5, 1).format(ofPattern("yyyyMMdd")); + private static final String FAKE_UUID = "ddd8d2d8-7b0c-47e9-91d0-d21251f891e8"; - @Mock private ApplicationEventPublisher eventPublisher; - - @Mock private AmazonS3 amazonS3; + private ImageUploadService imageUploadService; + private InMemoryResourceLogRepository resourceLogRepository; @BeforeEach void setUp() { - ReflectionTestUtils.setField(imageUploadService, "imageBucketName", "image-bucket"); - ReflectionTestUtils.setField( - imageUploadService, "cloudFrontUrl", "https://testUrl.cloudfront.net"); + resourceLogRepository = new InMemoryResourceLogRepository(); + ResourceCommandService resourceCommandService = + new ResourceCommandService(resourceLogRepository); + imageUploadService = + new ImageUploadService( + resourceCommandService, new FakeAmazonS3(), BUCKET_NAME, CLOUD_FRONT_URL) { + @Override + public String getImageKey(String rootPath, Long index) { + if (rootPath.startsWith(PATH_DELIMITER)) { + rootPath = rootPath.substring(1); + } + if (rootPath.endsWith(PATH_DELIMITER)) { + rootPath = rootPath.substring(0, rootPath.length() - 1); + } + String imageId = index + KEY_DELIMITER + FAKE_UUID + "." + EXTENSION; + return rootPath + PATH_DELIMITER + UPLOAD_DATE + PATH_DELIMITER + imageId; + } + }; } - @Test - @DisplayName("단건 이미지 업로드 URL을 생성할 수 있다.") - void test_1() throws Exception { - // Given - String amazonUrl = "https://bottlenote.s3.ap-northeast-2.amazonaws.com/"; - String viewUrl = "https://testUrl.cloudfront.net/"; - String rootPath = "image-upload"; - Long uploadSize = 1L; - String updateAt = "20240524"; - String imageName = "ddd8d2d8-7b0c-47e9-91d0-d21251f891e8.jpg"; - String key1 = rootPath + "/" + updateAt + "/" + "1-" + imageName; - - ImageUploadRequest 요청객체 = new ImageUploadRequest(rootPath, uploadSize); // 요청 사이즈를 2로 수정 - ImageUploadResponse 응답객체 = - new ImageUploadResponse( - "image-bucket", - 1, - 5, - List.of( - new ImageUploadItem( - 1L, - viewUrl + key1, - amazonUrl - + key1) /*,new ImageUploadItem(2L, viewUrl + key2, amazonUrl + key2)*/)); - - // when - when(amazonS3.generatePresignedUrl(anyString(), anyString(), any(), any(HttpMethod.class))) - .thenReturn(new URL(amazonUrl + key1)); - willDoNothing().given(eventPublisher).publishEvent(any()); - - ImageUploadResponse 실제_반환값 = imageUploadService.getPreSignUrl(요청객체); - - // Then - ImageUploadItem 비교_대상 = 응답객체.imageUploadInfo().stream().findFirst().get(); - ImageUploadItem 실제_비교_대상 = 실제_반환값.imageUploadInfo().stream().findFirst().get(); - - Assertions.assertNotNull(실제_반환값); - Assertions.assertEquals(응답객체.bucketName(), 실제_반환값.bucketName()); - Assertions.assertEquals(응답객체.expiryTime(), 실제_반환값.expiryTime()); - Assertions.assertEquals(비교_대상.order(), 실제_비교_대상.order()); - Assertions.assertEquals(비교_대상.uploadUrl(), 실제_비교_대상.uploadUrl()); - Assertions.assertTrue(실제_비교_대상.viewUrl().startsWith(viewUrl)); + @Nested + @DisplayName("PreSigned URL 생성 테스트") + class PreSignedUrlTest { + + @Test + @DisplayName("PreSignUrl을 생성할 수 있다") + void test_1() { + // given + String imageKey = imageUploadService.getImageKey("review", 1L); + + // when + String preSignUrl = imageUploadService.generatePreSignUrl(imageKey); + + // then + log.info("PreSignUrl: {}", preSignUrl); + assertNotNull(preSignUrl); + assertEquals(AWS_URL + imageKey, preSignUrl); + } + + @Test + @DisplayName("업로드용 인증 URL을 생성할 수 있다") + void test_2() { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 2L); + + // when + ImageUploadResponse response = imageUploadService.getPreSignUrl(request); + + // then + assertNotNull(response); + assertEquals(request.uploadSize(), response.uploadSize()); + assertEquals(5, response.expiryTime()); + assertEquals(BUCKET_NAME, response.bucketName()); + + for (Long index = 1L; index <= response.imageUploadInfo().size(); index++) { + String imageKey = imageUploadService.getImageKey(request.rootPath(), index); + String uploadUrlExpected = imageUploadService.generatePreSignUrl(imageKey); + String viewUrlExpected = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey); + + ImageUploadItem info = response.imageUploadInfo().get((int) (index - 1)); + + log.info("[{}] ImageUploadItem: {}", index, info); + assertEquals(index, info.order()); + assertEquals(uploadUrlExpected, info.uploadUrl()); + assertEquals(viewUrlExpected, info.viewUrl()); + } + } + + @Test + @DisplayName("단건 이미지 업로드 URL을 생성할 수 있다") + void test_3() { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 1L); + + // when + ImageUploadResponse response = imageUploadService.getPreSignUrl(request); + + // then + assertNotNull(response); + assertEquals(1, response.uploadSize()); + assertEquals(BUCKET_NAME, response.bucketName()); + + ImageUploadItem item = response.imageUploadInfo().get(0); + assertTrue(item.uploadUrl().startsWith(AWS_URL)); + assertTrue(item.viewUrl().startsWith(CLOUD_FRONT_URL)); + } } - @Test - @DisplayName("이미지 view URL을 생성할 수 있다.") - void test_2() { - String cloudFrontUrl = "https://testUrl.cloudfront.net"; - String imageKey = "1-test.jpg"; - String viewUrl = imageUploadService.generateViewUrl(cloudFrontUrl, imageKey); + @Nested + @DisplayName("View URL 생성 테스트") + class ViewUrlTest { - Assertions.assertEquals("https://testUrl.cloudfront.net/1-test.jpg", viewUrl); - } + @Test + @DisplayName("조회용 URL을 생성할 수 있다") + void test_1() { + // given + String imageKey = imageUploadService.getImageKey("review", 1L); - @Test - @DisplayName("업로드 이미지 경로를 생성할 수 있다.") - void test_3() throws Exception { - // Given - String imageKey = imageUploadService.getImageKey("test", 1L); - String amazonUrl = "https://bottlenote.s3.ap-northeast-2.amazonaws.com/"; - when(amazonS3.generatePresignedUrl(anyString(), anyString(), any(), any(HttpMethod.class))) - .thenReturn(new URL(amazonUrl + imageKey)); + // when + String viewUrl = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey); - // when - String preSignUrl = imageUploadService.generatePreSignUrl(imageKey); + // then + log.info("ViewUrl: {}", viewUrl); + assertNotNull(viewUrl); + assertEquals(CLOUD_FRONT_URL + "/" + imageKey, viewUrl); + } + } + + @Nested + @DisplayName("이미지 키 생성 테스트") + class ImageKeyTest { + + @Test + @DisplayName("이미지 루트 경로와 인덱스를 제공해 이미지 키를 생성할 수 있다") + void test_1() { + // given & when + String imageKey = imageUploadService.getImageKey("review", 1L); + String expected = "review/" + UPLOAD_DATE + "/1-" + FAKE_UUID + ".jpg"; + + // then + log.info("ImageKey: {}", imageKey); + assertNotNull(imageKey); + assertEquals(expected, imageKey); + } + } - // then - Assertions.assertEquals((amazonUrl + imageKey), preSignUrl); + @Nested + @DisplayName("만료 시간 테스트") + class ExpiryTimeTest { + + @Test + @DisplayName("기본 만료 시간은 5분이다") + void test_1() { + // given + Calendar expectedExpiryTime = Calendar.getInstance(); + expectedExpiryTime.add(Calendar.MINUTE, 5); + + // when + Calendar actualExpiryTime = imageUploadService.getUploadExpiryTime(null); + + // then + log.info("ExpiryTime: {}", actualExpiryTime); + long diffInMillis = + Math.abs(expectedExpiryTime.getTimeInMillis() - actualExpiryTime.getTimeInMillis()); + assertTrue( + diffInMillis < TimeUnit.SECONDS.toMillis(1), + "The difference should be less than 1 second"); + } + + @Test + @DisplayName("최대 만료 시간은 10분이다") + void test_2() { + // given + Calendar expectedExpiryTime = Calendar.getInstance(); + expectedExpiryTime.add(Calendar.MINUTE, 10); + + // when + Calendar actualExpiryTime = imageUploadService.getUploadExpiryTime(10); + + // then + long diffInMillis = + Math.abs(expectedExpiryTime.getTimeInMillis() - actualExpiryTime.getTimeInMillis()); + assertTrue( + diffInMillis < TimeUnit.SECONDS.toMillis(1), + "The difference should be less than 1 second"); + assertThrows(FileException.class, () -> imageUploadService.getUploadExpiryTime(11)); + } } } From d6588bbe1c373b7999c7336e1ea0b74b903a5faa Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 6 Jan 2026 11:23:23 +0900 Subject: [PATCH 09/95] =?UTF-8?q?test:=20PreSigned=20URL=20API=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadIntegrationTest 추가 - 인증된 사용자의 PreSigned URL 생성 테스트 - 다중 이미지 업로드 URL 생성 테스트 - 기본값 처리 테스트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ImageUploadIntegrationTest.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java new file mode 100644 index 000000000..b95f9f2eb --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -0,0 +1,135 @@ +package app.bottlenote.common.file.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.common.file.dto.response.ImageUploadResponse; +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.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +@Tag("integration") +@DisplayName("[integration] ImageUpload") +class ImageUploadIntegrationTest extends IntegrationTestSupport { + + @Nested + @DisplayName("PreSigned URL 생성 테스트") + class PreSignedUrlTest { + + @Test + @DisplayName("인증된 사용자가 PreSigned URL 생성에 성공한다") + void test_1() throws Exception { + // given + String rootPath = "review"; + Long uploadSize = 2L; + + // when + MvcResult result = + mockMvc + .perform( + get("/api/v1/s3/presign-url") + .param("rootPath", rootPath) + .param("uploadSize", String.valueOf(uploadSize)) + .header("Authorization", "Bearer " + getToken()) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.uploadSize").value(uploadSize)) + .andExpect(jsonPath("$.data.imageUploadInfo").isArray()) + .andReturn(); + + // then + ImageUploadResponse response = extractData(result, ImageUploadResponse.class); + assertNotNull(response); + assertEquals(uploadSize.intValue(), response.uploadSize()); + assertEquals(uploadSize.intValue(), response.imageUploadInfo().size()); + assertNotNull(response.bucketName()); + assertEquals(5, response.expiryTime()); + + response + .imageUploadInfo() + .forEach( + item -> { + assertNotNull(item.uploadUrl()); + assertNotNull(item.viewUrl()); + assertTrue(item.uploadUrl().contains("s3")); + }); + + log.info("PreSigned URL 생성 응답: {}", response); + } + + @Test + @DisplayName("여러 개의 이미지 업로드 URL을 한번에 생성할 수 있다") + void test_2() throws Exception { + // given + String rootPath = "review"; + Long uploadSize = 3L; + + // when + MvcResult result = + mockMvc + .perform( + get("/api/v1/s3/presign-url") + .param("rootPath", rootPath) + .param("uploadSize", String.valueOf(uploadSize)) + .header("Authorization", "Bearer " + getToken()) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.uploadSize").value(uploadSize)) + .andExpect(jsonPath("$.data.imageUploadInfo").isArray()) + .andReturn(); + + // then + ImageUploadResponse response = extractData(result, ImageUploadResponse.class); + assertEquals(uploadSize.intValue(), response.imageUploadInfo().size()); + + for (int i = 0; i < uploadSize; i++) { + assertEquals(i + 1, response.imageUploadInfo().get(i).order()); + assertTrue(response.imageUploadInfo().get(i).viewUrl().contains(rootPath)); + } + + log.info("생성된 업로드 URL 수: {}", response.imageUploadInfo().size()); + } + + @Test + @DisplayName("uploadSize 없이 요청하면 기본값 1로 처리된다") + void test_3() throws Exception { + // given + String rootPath = "review"; + + // when + MvcResult result = + mockMvc + .perform( + get("/api/v1/s3/presign-url") + .param("rootPath", rootPath) + .header("Authorization", "Bearer " + getToken()) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.uploadSize").value(1)) + .andReturn(); + + // then + ImageUploadResponse response = extractData(result, ImageUploadResponse.class); + assertEquals(1, response.uploadSize()); + assertEquals(1, response.imageUploadInfo().size()); + } + } +} From 85d3a47758d2c5bf21cd53941c7a53c30c31ba02 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 6 Jan 2026 12:03:29 +0900 Subject: [PATCH 10/95] =?UTF-8?q?refactor:=20ImageUpload=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20MockMvcTester?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20ResourceLog=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MockMvc -> MockMvcTester 마이그레이션 - ResourceLog 저장 검증 테스트 추가 (Awaitility 사용) - 비동기 로그 저장 대기 처리 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ImageUploadIntegrationTest.java | 137 ++++++++++++------ 1 file changed, 89 insertions(+), 48 deletions(-) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index b95f9f2eb..e15baa0b3 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -1,27 +1,33 @@ package app.bottlenote.common.file.integration; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.domain.ResourceLogRepository; import app.bottlenote.common.file.dto.response.ImageUploadResponse; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; 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.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] ImageUpload") class ImageUploadIntegrationTest extends IntegrationTestSupport { + @Autowired private ResourceLogRepository resourceLogRepository; + @Nested @DisplayName("PreSigned URL 생성 테스트") class PreSignedUrlTest { @@ -34,22 +40,16 @@ void test_1() throws Exception { Long uploadSize = 2L; // when - MvcResult result = - mockMvc - .perform( - get("/api/v1/s3/presign-url") - .param("rootPath", rootPath) - .param("uploadSize", String.valueOf(uploadSize)) - .header("Authorization", "Bearer " + getToken()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.uploadSize").value(uploadSize)) - .andExpect(jsonPath("$.data.imageUploadInfo").isArray()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", rootPath) + .param("uploadSize", String.valueOf(uploadSize)) + .header("Authorization", "Bearer " + getToken()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); // then ImageUploadResponse response = extractData(result, ImageUploadResponse.class); @@ -79,20 +79,16 @@ void test_2() throws Exception { Long uploadSize = 3L; // when - MvcResult result = - mockMvc - .perform( - get("/api/v1/s3/presign-url") - .param("rootPath", rootPath) - .param("uploadSize", String.valueOf(uploadSize)) - .header("Authorization", "Bearer " + getToken()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.uploadSize").value(uploadSize)) - .andExpect(jsonPath("$.data.imageUploadInfo").isArray()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", rootPath) + .param("uploadSize", String.valueOf(uploadSize)) + .header("Authorization", "Bearer " + getToken()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); // then ImageUploadResponse response = extractData(result, ImageUploadResponse.class); @@ -113,18 +109,15 @@ void test_3() throws Exception { String rootPath = "review"; // when - MvcResult result = - mockMvc - .perform( - get("/api/v1/s3/presign-url") - .param("rootPath", rootPath) - .header("Authorization", "Bearer " + getToken()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.uploadSize").value(1)) - .andReturn(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", rootPath) + .header("Authorization", "Bearer " + getToken()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); // then ImageUploadResponse response = extractData(result, ImageUploadResponse.class); @@ -132,4 +125,52 @@ void test_3() throws Exception { assertEquals(1, response.imageUploadInfo().size()); } } + + @Nested + @DisplayName("ResourceLog 저장 테스트") + class ResourceLogTest { + + @Test + @DisplayName("인증된 사용자가 PreSigned URL 생성 시 ResourceLog에 CREATED 이벤트가 저장된다") + void test_1() throws Exception { + // given + String rootPath = "review"; + Long uploadSize = 2L; + String token = getToken(); + Long userId = getTokenUserId(); + + // when + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", rootPath) + .param("uploadSize", String.valueOf(uploadSize)) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then - 비동기 로그 저장 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertFalse(logs.isEmpty()); + assertEquals(uploadSize.intValue(), logs.size()); + }); + + List logs = resourceLogRepository.findByUserId(userId); + logs.forEach( + resourceLog -> { + assertEquals(ResourceEventType.CREATED, resourceLog.getEventType()); + assertEquals("IMAGE", resourceLog.getResourceType()); + assertEquals(userId, resourceLog.getUserId()); + assertTrue(resourceLog.getResourceKey().startsWith(rootPath)); + assertNotNull(resourceLog.getViewUrl()); + }); + + log.info("저장된 ResourceLog 수: {}", logs.size()); + } + } } From c3e173dc2dd18ed470fd2337f6dd10ee15c0f95a Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 9 Jan 2026 16:12:03 +0900 Subject: [PATCH 11/95] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20=ED=99=9C=EC=84=B1=ED=99=94=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20ResourceLog?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageResourceActivatedEvent 이벤트 클래스 추가 - ResourceEventListener로 이벤트 수신 및 ResourceLog ACTIVATED 상태 저장 - ReviewService, HelpService, BusinessSupportService, UserBasicService에 이벤트 발행 추가 - ImageUtil.extractResourceKey() 메서드 추가 - 서비스별 이벤트 발행 및 ResourceLog 상태 변경 단위 테스트 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../event/listener/ResourceEventListener.java | 37 ++ .../payload/ImageResourceActivatedEvent.java | 24 + .../bottlenote/common/image/ImageUtil.java | 15 + .../review/service/ReviewService.java | 31 ++ .../service/BusinessSupportService.java | 28 + .../support/help/service/HelpService.java | 28 + .../user/service/UserBasicService.java | 12 + .../FakeApplicationEventPublisher.java | 41 ++ ...mageResourceActivatedEventPublishTest.java | 486 ++++++++++++++++++ .../ImageResourceActivatedEventTest.java | 126 +++++ .../file/event/ResourceEventListenerTest.java | 160 ++++++ .../common/image/ImageUtilTest.java | 128 +++++ .../fixture/InMemoryReviewRepository.java | 4 +- .../service/BusinessSupportServiceTest.java | 6 +- .../help/fixture/InMemoryHelpRepository.java | 78 +++ .../support/help/service/HelpServiceTest.java | 2 + .../user/fixture/InMemoryUserRepository.java | 76 +++ 17 files changed, 1280 insertions(+), 2 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/event/fixture/FakeApplicationEventPublisher.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/image/ImageUtilTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/support/help/fixture/InMemoryHelpRepository.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java new file mode 100644 index 000000000..4f4a1ea42 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java @@ -0,0 +1,37 @@ +package app.bottlenote.common.file.event.listener; + +import static app.bottlenote.common.annotation.DomainEventListener.ProcessingType.ASYNCHRONOUS; + +import app.bottlenote.common.annotation.DomainEventListener; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.service.ResourceCommandService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@DomainEventListener(type = ASYNCHRONOUS) +public class ResourceEventListener { + + private final ResourceCommandService resourceCommandService; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener + public void handleImageResourceActivated(ImageResourceActivatedEvent event) { + log.info( + "이미지 리소스 활성화 이벤트 수신 - referenceId: {}, referenceType: {}, resourceKeys: {}", + event.referenceId(), + event.referenceType(), + event.resourceKeys().size()); + + for (String resourceKey : event.resourceKeys()) { + resourceCommandService.activateImageResource( + resourceKey, event.referenceId(), event.referenceType()); + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java new file mode 100644 index 000000000..7fe5fc19a --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java @@ -0,0 +1,24 @@ +package app.bottlenote.common.file.event.payload; + +import java.util.List; +import java.util.Objects; + +public record ImageResourceActivatedEvent( + List resourceKeys, Long referenceId, String referenceType) { + + public ImageResourceActivatedEvent { + Objects.requireNonNull(resourceKeys, "resourceKeys must not be null"); + Objects.requireNonNull(referenceId, "referenceId must not be null"); + Objects.requireNonNull(referenceType, "referenceType must not be null"); + } + + public static ImageResourceActivatedEvent of( + List resourceKeys, Long referenceId, String referenceType) { + return new ImageResourceActivatedEvent(resourceKeys, referenceId, referenceType); + } + + public static ImageResourceActivatedEvent of( + String resourceKey, Long referenceId, String referenceType) { + return new ImageResourceActivatedEvent(List.of(resourceKey), referenceId, referenceType); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/image/ImageUtil.java b/bottlenote-mono/src/main/java/app/bottlenote/common/image/ImageUtil.java index 03716c6e4..e4cff3103 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/image/ImageUtil.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/image/ImageUtil.java @@ -23,4 +23,19 @@ public static String getImageName(String imageUrl) { String[] split = splitPath(imageUrl); return split[2]; } + + public static String extractResourceKey(String viewUrl) { + if (viewUrl == null || viewUrl.isBlank()) { + return null; + } + int protocolEnd = viewUrl.indexOf("://"); + if (protocolEnd == -1) { + return viewUrl; + } + int firstSlashAfterHost = viewUrl.indexOf("/", protocolEnd + 3); + if (firstSlashAfterHost == -1) { + return ""; + } + return viewUrl.substring(firstSlashAfterHost + 1); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java index 7d5a6ef5d..bcc5fc868 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java @@ -9,6 +9,8 @@ import app.bottlenote.alcohols.facade.AlcoholFacade; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.image.ImageUtil; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.history.event.publisher.HistoryEventPublisher; import app.bottlenote.observability.service.TracingService; @@ -30,9 +32,12 @@ import app.bottlenote.review.exception.ReviewException; import app.bottlenote.review.facade.payload.ReviewInfo; import app.bottlenote.user.facade.UserFacade; +import java.util.Collections; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,11 +46,14 @@ @RequiredArgsConstructor public class ReviewService { + private static final String REFERENCE_TYPE_REVIEW = "REVIEW"; + private final AlcoholFacade alcoholFacade; private final UserFacade userDomainSupport; private final ReviewRepository reviewRepository; private final HistoryEventPublisher reviewEventPublisher; private final TracingService tracingService; + private final ApplicationEventPublisher eventPublisher; /** Read */ @Transactional(readOnly = true) @@ -118,6 +126,8 @@ public ReviewCreateResponse createReview( saveReview.getContent()); reviewEventPublisher.publishReviewHistoryEvent(event); + publishImageActivatedEvent(reviewCreateRequest.imageUrlList(), saveReview.getId()); + log.info( "리뷰 생성 - reviewId: {}, userId: {}, alcoholId: {}, rating: {}, status: {}, traceId: {}", saveReview.getId(), @@ -151,6 +161,9 @@ public ReviewResultResponse modifyReview( review.update(reviewModifyRequestWrapperItem); review.imageInitialization(reviewImageInfoRequests); review.updateTastingTags(request.tastingTagList()); + + publishImageActivatedEvent(reviewImageInfoRequests, reviewId); + return ReviewResultResponse.response(MODIFY_SUCCESS, reviewId); } @@ -188,4 +201,22 @@ public ReviewResultResponse changeStatus( ? ReviewResultResponse.response(PUBLIC_SUCCESS, review.getId()) : ReviewResultResponse.response(PRIVATE_SUCCESS, review.getId()); } + + private void publishImageActivatedEvent(List imageList, Long reviewId) { + List images = + Objects.requireNonNullElse(imageList, Collections.emptyList()); + if (images.isEmpty() || reviewId == null) { + return; + } + List resourceKeys = + images.stream() + .map(ReviewImageInfoRequest::viewUrl) + .map(ImageUtil::extractResourceKey) + .filter(Objects::nonNull) + .toList(); + if (!resourceKeys.isEmpty()) { + eventPublisher.publishEvent( + ImageResourceActivatedEvent.of(resourceKeys, reviewId, REFERENCE_TYPE_REVIEW)); + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java b/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java index 1b5a2d8a2..cbe772fc4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java @@ -7,6 +7,8 @@ import static app.bottlenote.support.business.exception.BusinessSupportExceptionCode.BUSINESS_SUPPORT_NOT_AUTHORIZED; import static app.bottlenote.support.business.exception.BusinessSupportExceptionCode.BUSINESS_SUPPORT_NOT_FOUND; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.image.ImageUtil; import app.bottlenote.common.profanity.ProfanityClient; import app.bottlenote.global.data.response.CollectionResponse; import app.bottlenote.support.business.domain.BusinessSupport; @@ -20,7 +22,9 @@ import app.bottlenote.support.business.exception.BusinessSupportException; import app.bottlenote.user.facade.UserFacade; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,9 +32,12 @@ @RequiredArgsConstructor public class BusinessSupportService { + private static final String REFERENCE_TYPE_BUSINESS = "BUSINESS"; + private final BusinessSupportRepository repository; private final UserFacade userFacade; private final ProfanityClient profanityClient; + private final ApplicationEventPublisher eventPublisher; @Transactional public BusinessSupportResultResponse register(BusinessSupportUpsertRequest req, Long userId) { @@ -51,6 +58,8 @@ public BusinessSupportResultResponse register(BusinessSupportUpsertRequest req, // 이미지 저장 bs.saveImages(req.imageUrlList(), saved.getId()); + publishImageActivatedEvent(req.imageUrlList(), saved.getId()); + return BusinessSupportResultResponse.response(REGISTER_SUCCESS, saved.getId()); } @@ -70,6 +79,9 @@ public BusinessSupportResultResponse modify( req.contact(), req.businessSupportType(), req.imageUrlList()); + + publishImageActivatedEvent(req.imageUrlList(), bs.getId()); + return BusinessSupportResultResponse.response(MODIFY_SUCCESS, bs.getId()); } @@ -125,4 +137,20 @@ public BusinessSupportDetailItem getDetail(Long id, Long userId) { .lastModifyAt(bs.getLastModifyAt()) .build(); } + + private void publishImageActivatedEvent(List imageList, Long businessId) { + if (imageList == null || imageList.isEmpty() || businessId == null) { + return; + } + List resourceKeys = + imageList.stream() + .map(BusinessImageItem::viewUrl) + .map(ImageUtil::extractResourceKey) + .filter(Objects::nonNull) + .toList(); + if (!resourceKeys.isEmpty()) { + eventPublisher.publishEvent( + ImageResourceActivatedEvent.of(resourceKeys, businessId, REFERENCE_TYPE_BUSINESS)); + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java index 918d25bc7..c7bc32baa 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java @@ -6,6 +6,8 @@ import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_AUTHORIZED; import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_FOUND; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.image.ImageUtil; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.support.help.domain.Help; import app.bottlenote.support.help.domain.HelpRepository; @@ -17,8 +19,11 @@ import app.bottlenote.support.help.dto.response.HelpResultResponse; import app.bottlenote.support.help.exception.HelpException; import app.bottlenote.user.facade.UserFacade; +import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,8 +32,11 @@ @Slf4j public class HelpService { + private static final String REFERENCE_TYPE_HELP = "HELP"; + private final UserFacade userDomainSupport; private final HelpRepository helpRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public HelpResultResponse registerHelp(HelpUpsertRequest helpUpsertRequest, Long currentUserId) { @@ -48,6 +56,8 @@ public HelpResultResponse registerHelp(HelpUpsertRequest helpUpsertRequest, Long // 문의글 이미지 저장 help.saveImages(helpUpsertRequest.imageUrlList(), help.getId()); + publishImageActivatedEvent(helpUpsertRequest.imageUrlList(), saveHelp.getId()); + return HelpResultResponse.response(REGISTER_SUCCESS, saveHelp.getId()); } @@ -68,6 +78,8 @@ public HelpResultResponse modifyHelp( helpUpsertRequest.imageUrlList(), helpUpsertRequest.type()); + publishImageActivatedEvent(helpUpsertRequest.imageUrlList(), help.getId()); + return HelpResultResponse.response(MODIFY_SUCCESS, help.getId()); } @@ -120,4 +132,20 @@ public HelpDetailItem getDetailHelp(Long helpId, Long currentUserId) { .lastModifyAt(help.getLastModifyAt()) .build(); } + + private void publishImageActivatedEvent(List imageList, Long helpId) { + if (imageList == null || imageList.isEmpty() || helpId == null) { + return; + } + List resourceKeys = + imageList.stream() + .map(HelpImageItem::viewUrl) + .map(ImageUtil::extractResourceKey) + .filter(Objects::nonNull) + .toList(); + if (!resourceKeys.isEmpty()) { + eventPublisher.publishEvent( + ImageResourceActivatedEvent.of(resourceKeys, helpId, REFERENCE_TYPE_HELP)); + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java b/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java index ab9a05e3e..d25195265 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java @@ -6,6 +6,8 @@ import static app.bottlenote.user.exception.UserExceptionCode.MYPAGE_NOT_ACCESSIBLE; import static app.bottlenote.user.exception.UserExceptionCode.USER_NOT_FOUND; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.image.ImageUtil; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.user.constant.MyBottleType; import app.bottlenote.user.domain.User; @@ -22,6 +24,7 @@ import app.bottlenote.user.exception.UserExceptionCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,8 +33,11 @@ @Service public class UserBasicService { + private static final String REFERENCE_TYPE_PROFILE = "PROFILE"; + private final UserRepository userRepository; private final UserFilterManager userFilterManager; + private final ApplicationEventPublisher eventPublisher; @Transactional public NicknameChangeResponse nicknameChange(Long userId, NicknameChangeRequest request) { @@ -73,6 +79,12 @@ public ProfileImageChangeResponse profileImageChange(Long userId, String viewUrl user.changeProfileImage(viewUrl); + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + if (resourceKey != null && user.getId() != null) { + eventPublisher.publishEvent( + ImageResourceActivatedEvent.of(resourceKey, user.getId(), REFERENCE_TYPE_PROFILE)); + } + return new ProfileImageChangeResponse(user.getId(), user.getImageUrl()); }); } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/event/fixture/FakeApplicationEventPublisher.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/event/fixture/FakeApplicationEventPublisher.java new file mode 100644 index 000000000..3e16bc325 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/event/fixture/FakeApplicationEventPublisher.java @@ -0,0 +1,41 @@ +package app.bottlenote.common.event.fixture; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; + +public class FakeApplicationEventPublisher implements ApplicationEventPublisher { + + private final List publishedEvents = new ArrayList<>(); + + @Override + public void publishEvent(ApplicationEvent event) { + publishedEvents.add(event); + } + + @Override + public void publishEvent(Object event) { + publishedEvents.add(event); + } + + public List getPublishedEvents() { + return List.copyOf(publishedEvents); + } + + public List getPublishedEventsOfType(Class eventType) { + return publishedEvents.stream().filter(eventType::isInstance).map(eventType::cast).toList(); + } + + public void clear() { + publishedEvents.clear(); + } + + public int getPublishedEventCount() { + return publishedEvents.size(); + } + + public boolean hasPublishedEventOfType(Class eventType) { + return publishedEvents.stream().anyMatch(eventType::isInstance); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java new file mode 100644 index 000000000..81c702276 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java @@ -0,0 +1,486 @@ +package app.bottlenote.common.file.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import app.bottlenote.alcohols.fixture.FakeAlcoholFacade; +import app.bottlenote.common.event.fixture.FakeApplicationEventPublisher; +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.dto.request.ResourceLogRequest; +import app.bottlenote.common.file.event.listener.ResourceEventListener; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.service.ResourceCommandService; +import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; +import app.bottlenote.common.profanity.FakeProfanityClient; +import app.bottlenote.history.fixture.FakeHistoryEventPublisher; +import app.bottlenote.observability.service.LocalTracingService; +import app.bottlenote.review.dto.request.LocationInfoRequest; +import app.bottlenote.review.dto.request.ReviewCreateRequest; +import app.bottlenote.review.dto.request.ReviewImageInfoRequest; +import app.bottlenote.review.dto.request.ReviewModifyRequest; +import app.bottlenote.review.dto.response.ReviewCreateResponse; +import app.bottlenote.review.fixture.InMemoryReviewRepository; +import app.bottlenote.review.service.ReviewService; +import app.bottlenote.support.business.constant.BusinessSupportType; +import app.bottlenote.support.business.dto.request.BusinessImageItem; +import app.bottlenote.support.business.dto.request.BusinessSupportUpsertRequest; +import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; +import app.bottlenote.support.business.fixture.InMemoryBusinessSupportRepository; +import app.bottlenote.support.business.service.BusinessSupportService; +import app.bottlenote.support.help.constant.HelpType; +import app.bottlenote.support.help.dto.request.HelpImageItem; +import app.bottlenote.support.help.dto.request.HelpUpsertRequest; +import app.bottlenote.support.help.dto.response.HelpResultResponse; +import app.bottlenote.support.help.fixture.InMemoryHelpRepository; +import app.bottlenote.support.help.service.HelpService; +import app.bottlenote.user.facade.payload.UserProfileItem; +import app.bottlenote.user.fixture.FakeUserFacade; +import java.util.List; +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; + +@Tag("unit") +@DisplayName("[unit] [service] 이미지 리소스 활성화 이벤트 발행 테스트") +class ImageResourceActivatedEventPublishTest { + + @Nested + @DisplayName("ReviewService 리뷰 이미지") + class ReviewServiceTest { + + private ReviewService reviewService; + private InMemoryReviewRepository reviewRepository; + private FakeApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + reviewRepository = new InMemoryReviewRepository(); + eventPublisher = new FakeApplicationEventPublisher(); + + reviewService = + new ReviewService( + new FakeAlcoholFacade(), + new FakeUserFacade(UserProfileItem.create(1L, "user1", "")), + reviewRepository, + new FakeHistoryEventPublisher(), + new LocalTracingService(), + eventPublisher); + } + + @Test + @DisplayName("이미지가 포함된 리뷰를 생성할 때 ImageResourceActivatedEvent가 발행된다") + void test_create_review_with_images_publishes_event() { + // given + List images = + List.of( + new ReviewImageInfoRequest(1L, "https://cdn.bottlenote.com/review/img1.jpg"), + new ReviewImageInfoRequest(2L, "https://cdn.bottlenote.com/review/img2.jpg")); + ReviewCreateRequest request = createReviewRequest(images); + + // when + ReviewCreateResponse response = reviewService.createReview(request, 1L); + + // then + List events = + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class); + assertEquals(1, events.size()); + + ImageResourceActivatedEvent event = events.get(0); + assertEquals(2, event.resourceKeys().size()); + assertEquals("review/img1.jpg", event.resourceKeys().get(0)); + assertEquals("review/img2.jpg", event.resourceKeys().get(1)); + assertEquals(response.getId(), event.referenceId()); + assertEquals("REVIEW", event.referenceType()); + } + + @Test + @DisplayName("이미지 없이 리뷰를 생성할 때 이벤트가 발행되지 않는다") + void test_create_review_without_images_does_not_publish_event() { + // given + ReviewCreateRequest request = createReviewRequest(List.of()); + + // when + reviewService.createReview(request, 1L); + + // then + assertTrue( + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class).isEmpty()); + } + + @Test + @DisplayName("이미지가 포함된 리뷰를 수정할 때 ImageResourceActivatedEvent가 발행된다") + void test_modify_review_with_images_publishes_event() { + // given + ReviewCreateRequest createRequest = createReviewRequest(List.of()); + ReviewCreateResponse createResponse = reviewService.createReview(createRequest, 1L); + eventPublisher.clear(); + + List newImages = + List.of(new ReviewImageInfoRequest(1L, "https://cdn.bottlenote.com/review/new-img.jpg")); + ReviewModifyRequest modifyRequest = + new ReviewModifyRequest( + "수정된 내용", null, null, newImages, null, null, LocationInfoRequest.empty()); + + // when + reviewService.modifyReview(modifyRequest, createResponse.getId(), 1L); + + // then + List events = + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class); + assertEquals(1, events.size()); + + ImageResourceActivatedEvent event = events.get(0); + assertEquals("review/new-img.jpg", event.resourceKeys().get(0)); + assertEquals(createResponse.getId(), event.referenceId()); + assertEquals("REVIEW", event.referenceType()); + } + + private ReviewCreateRequest createReviewRequest(List images) { + return new ReviewCreateRequest( + 1L, null, "테스트 리뷰 내용", null, null, LocationInfoRequest.empty(), images, List.of(), 4.5); + } + } + + @Nested + @DisplayName("HelpService 문의 이미지") + class HelpServiceTest { + + private HelpService helpService; + private InMemoryHelpRepository helpRepository; + private FakeApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + helpRepository = new InMemoryHelpRepository(); + eventPublisher = new FakeApplicationEventPublisher(); + + helpService = + new HelpService( + new FakeUserFacade(UserProfileItem.create(1L, "user1", "")), + helpRepository, + eventPublisher); + } + + @Test + @DisplayName("이미지가 포함된 문의를 등록할 때 ImageResourceActivatedEvent가 발행된다") + void test_register_help_with_images_publishes_event() { + // given + List images = + List.of( + new HelpImageItem(1L, "https://cdn.bottlenote.com/help/img1.jpg"), + new HelpImageItem(2L, "https://cdn.bottlenote.com/help/img2.jpg")); + HelpUpsertRequest request = new HelpUpsertRequest("제목", "내용", HelpType.USER, images); + + // when + HelpResultResponse response = helpService.registerHelp(request, 1L); + + // then + List events = + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class); + assertEquals(1, events.size()); + + ImageResourceActivatedEvent event = events.get(0); + assertEquals(2, event.resourceKeys().size()); + assertEquals("help/img1.jpg", event.resourceKeys().get(0)); + assertEquals("help/img2.jpg", event.resourceKeys().get(1)); + assertEquals(response.helpId(), event.referenceId()); + assertEquals("HELP", event.referenceType()); + } + + @Test + @DisplayName("이미지 없이 문의를 등록할 때 이벤트가 발행되지 않는다") + void test_register_help_without_images_does_not_publish_event() { + // given + HelpUpsertRequest request = new HelpUpsertRequest("제목", "내용", HelpType.USER, List.of()); + + // when + helpService.registerHelp(request, 1L); + + // then + assertTrue( + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class).isEmpty()); + } + + @Test + @DisplayName("이미지가 포함된 문의를 수정할 때 ImageResourceActivatedEvent가 발행된다") + void test_modify_help_with_images_publishes_event() { + // given + HelpUpsertRequest createRequest = new HelpUpsertRequest("제목", "내용", HelpType.USER, List.of()); + HelpResultResponse createResponse = helpService.registerHelp(createRequest, 1L); + eventPublisher.clear(); + + List newImages = + List.of(new HelpImageItem(1L, "https://cdn.bottlenote.com/help/new-img.jpg")); + HelpUpsertRequest modifyRequest = + new HelpUpsertRequest("수정 제목", "수정 내용", HelpType.USER, newImages); + + // when + helpService.modifyHelp(modifyRequest, 1L, createResponse.helpId()); + + // then + List events = + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class); + assertEquals(1, events.size()); + + ImageResourceActivatedEvent event = events.get(0); + assertEquals("help/new-img.jpg", event.resourceKeys().get(0)); + assertEquals(createResponse.helpId(), event.referenceId()); + assertEquals("HELP", event.referenceType()); + } + } + + @Nested + @DisplayName("BusinessSupportService 사업 제휴 이미지") + class BusinessSupportServiceTest { + + private BusinessSupportService businessSupportService; + private InMemoryBusinessSupportRepository businessSupportRepository; + private FakeApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + businessSupportRepository = new InMemoryBusinessSupportRepository(); + eventPublisher = new FakeApplicationEventPublisher(); + + businessSupportService = + new BusinessSupportService( + businessSupportRepository, + new FakeUserFacade(UserProfileItem.create(1L, "user1", "")), + new FakeProfanityClient(), + eventPublisher); + } + + @Test + @DisplayName("이미지가 포함된 사업 제휴를 등록할 때 ImageResourceActivatedEvent가 발행된다") + void test_register_business_with_images_publishes_event() { + // given + List images = + List.of( + BusinessImageItem.create(1L, "https://cdn.bottlenote.com/business/img1.jpg"), + BusinessImageItem.create(2L, "https://cdn.bottlenote.com/business/img2.jpg")); + BusinessSupportUpsertRequest request = + new BusinessSupportUpsertRequest( + "제목", "내용", "test@test.com", BusinessSupportType.EVENT, images); + + // when + BusinessSupportResultResponse response = businessSupportService.register(request, 1L); + + // then + List events = + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class); + assertEquals(1, events.size()); + + ImageResourceActivatedEvent event = events.get(0); + assertEquals(2, event.resourceKeys().size()); + assertEquals("business/img1.jpg", event.resourceKeys().get(0)); + assertEquals("business/img2.jpg", event.resourceKeys().get(1)); + assertEquals(response.id(), event.referenceId()); + assertEquals("BUSINESS", event.referenceType()); + } + + @Test + @DisplayName("이미지 없이 사업 제휴를 등록할 때 이벤트가 발행되지 않는다") + void test_register_business_without_images_does_not_publish_event() { + // given + BusinessSupportUpsertRequest request = + new BusinessSupportUpsertRequest( + "제목", "내용", "test@test.com", BusinessSupportType.EVENT, List.of()); + + // when + businessSupportService.register(request, 1L); + + // then + assertTrue( + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class).isEmpty()); + } + + @Test + @DisplayName("이미지가 포함된 사업 제휴를 수정할 때 ImageResourceActivatedEvent가 발행된다") + void test_modify_business_with_images_publishes_event() { + // given + BusinessSupportUpsertRequest createRequest = + new BusinessSupportUpsertRequest( + "제목", "내용", "test@test.com", BusinessSupportType.EVENT, List.of()); + BusinessSupportResultResponse createResponse = + businessSupportService.register(createRequest, 1L); + eventPublisher.clear(); + + List newImages = + List.of(BusinessImageItem.create(1L, "https://cdn.bottlenote.com/business/new-img.jpg")); + BusinessSupportUpsertRequest modifyRequest = + new BusinessSupportUpsertRequest( + "수정 제목", "수정 내용", "test@test.com", BusinessSupportType.EVENT, newImages); + + // when + businessSupportService.modify(createResponse.id(), modifyRequest, 1L); + + // then + List events = + eventPublisher.getPublishedEventsOfType(ImageResourceActivatedEvent.class); + assertEquals(1, events.size()); + + ImageResourceActivatedEvent event = events.get(0); + assertEquals("business/new-img.jpg", event.resourceKeys().get(0)); + assertEquals(createResponse.id(), event.referenceId()); + assertEquals("BUSINESS", event.referenceType()); + } + } + + @Nested + @DisplayName("이벤트 발행 후 ResourceLog 상태 변경 검증") + class ResourceLogActivationTest { + + private InMemoryResourceLogRepository resourceLogRepository; + private ResourceCommandService resourceCommandService; + private ResourceEventListener resourceEventListener; + + @BeforeEach + void setUp() { + resourceLogRepository = new InMemoryResourceLogRepository(); + resourceCommandService = new ResourceCommandService(resourceLogRepository); + resourceEventListener = new ResourceEventListener(resourceCommandService); + } + + @Test + @DisplayName("이벤트를 수신할 때 ResourceLog에 ACTIVATED 상태로 로그가 저장된다") + void test_event_listener_creates_activated_log() { + // given + String resourceKey = "review/test-image.jpg"; + Long referenceId = 100L; + String referenceType = "REVIEW"; + + resourceCommandService + .saveImageResourceCreated( + new ResourceLogRequest( + 1L, + resourceKey, + "https://cdn.bottlenote.com/" + resourceKey, + "review", + "test-bucket")) + .join(); + + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of(resourceKey, referenceId, referenceType); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs = resourceLogRepository.findByResourceKey(resourceKey); + assertFalse(logs.isEmpty()); + + ResourceLog activatedLog = + logs.stream() + .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) + .findFirst() + .orElse(null); + + assertNotNull(activatedLog); + assertEquals(ResourceEventType.ACTIVATED, activatedLog.getEventType()); + assertEquals(referenceId, activatedLog.getReferenceId()); + assertEquals(referenceType, activatedLog.getReferenceType()); + assertEquals(resourceKey, activatedLog.getResourceKey()); + } + + @Test + @DisplayName("여러 이미지 리소스 활성화 이벤트를 수신할 때 각각 ACTIVATED 로그가 저장된다") + void test_multiple_resources_create_multiple_activated_logs() { + // given + String resourceKey1 = "help/image1.jpg"; + String resourceKey2 = "help/image2.jpg"; + Long referenceId = 200L; + String referenceType = "HELP"; + + resourceCommandService + .saveImageResourceCreated( + new ResourceLogRequest( + 1L, + resourceKey1, + "https://cdn.bottlenote.com/" + resourceKey1, + "help", + "test-bucket")) + .join(); + resourceCommandService + .saveImageResourceCreated( + new ResourceLogRequest( + 1L, + resourceKey2, + "https://cdn.bottlenote.com/" + resourceKey2, + "help", + "test-bucket")) + .join(); + + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of( + List.of(resourceKey1, resourceKey2), referenceId, referenceType); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs1 = resourceLogRepository.findByResourceKey(resourceKey1); + List logs2 = resourceLogRepository.findByResourceKey(resourceKey2); + + ResourceLog activated1 = + logs1.stream() + .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) + .findFirst() + .orElse(null); + ResourceLog activated2 = + logs2.stream() + .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) + .findFirst() + .orElse(null); + + assertNotNull(activated1); + assertNotNull(activated2); + assertEquals(referenceId, activated1.getReferenceId()); + assertEquals(referenceId, activated2.getReferenceId()); + assertEquals(referenceType, activated1.getReferenceType()); + assertEquals(referenceType, activated2.getReferenceType()); + } + + @Test + @DisplayName("CREATED 로그가 있는 리소스를 활성화할 때 CREATED와 ACTIVATED 로그가 모두 존재한다") + void test_resource_log_sequence_created_to_activated() { + // given + String resourceKey = "business/document.pdf"; + Long userId = 1L; + Long referenceId = 300L; + String referenceType = "BUSINESS"; + + resourceCommandService + .saveImageResourceCreated( + new ResourceLogRequest( + userId, + resourceKey, + "https://cdn.bottlenote.com/" + resourceKey, + "business", + "test-bucket")) + .join(); + + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of(resourceKey, referenceId, referenceType); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs = resourceLogRepository.findByResourceKey(resourceKey); + assertEquals(2, logs.size()); + + boolean hasCreated = + logs.stream().anyMatch(log -> log.getEventType() == ResourceEventType.CREATED); + boolean hasActivated = + logs.stream().anyMatch(log -> log.getEventType() == ResourceEventType.ACTIVATED); + + assertTrue(hasCreated); + assertTrue(hasActivated); + } + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventTest.java new file mode 100644 index 000000000..2cb7814b8 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventTest.java @@ -0,0 +1,126 @@ +package app.bottlenote.common.file.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("[unit] [event] ImageResourceActivatedEvent") +class ImageResourceActivatedEventTest { + + @Nested + @DisplayName("이벤트 생성 테스트") + class CreateEventTest { + + @Test + @DisplayName("단일 리소스 키로 이벤트를 생성할 수 있다") + void test_create_with_single_key() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + Long referenceId = 100L; + String referenceType = "REVIEW"; + + // when + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of(resourceKey, referenceId, referenceType); + + // then + assertEquals(1, event.resourceKeys().size()); + assertEquals(resourceKey, event.resourceKeys().get(0)); + assertEquals(referenceId, event.referenceId()); + assertEquals(referenceType, event.referenceType()); + } + + @Test + @DisplayName("여러 리소스 키로 이벤트를 생성할 수 있다") + void test_create_with_multiple_keys() { + // given + List resourceKeys = + List.of( + "review/20251231/1-uuid1.jpg", + "review/20251231/2-uuid2.jpg", + "review/20251231/3-uuid3.jpg"); + Long referenceId = 200L; + String referenceType = "REVIEW"; + + // when + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of(resourceKeys, referenceId, referenceType); + + // then + assertEquals(3, event.resourceKeys().size()); + assertEquals(resourceKeys, event.resourceKeys()); + assertEquals(referenceId, event.referenceId()); + assertEquals(referenceType, event.referenceType()); + } + + @Test + @DisplayName("resourceKeys가 null이면 NullPointerException이 발생한다") + void test_null_resource_keys() { + // when & then + assertThrows( + NullPointerException.class, + () -> ImageResourceActivatedEvent.of((List) null, 100L, "REVIEW")); + } + + @Test + @DisplayName("referenceId가 null이면 NullPointerException이 발생한다") + void test_null_reference_id() { + // when & then + assertThrows( + NullPointerException.class, () -> ImageResourceActivatedEvent.of("key", null, "REVIEW")); + } + + @Test + @DisplayName("referenceType이 null이면 NullPointerException이 발생한다") + void test_null_reference_type() { + // when & then + assertThrows( + NullPointerException.class, () -> ImageResourceActivatedEvent.of("key", 100L, null)); + } + } + + @Nested + @DisplayName("참조 타입별 이벤트 생성 테스트") + class ReferenceTypeTest { + + @Test + @DisplayName("PROFILE 타입의 이벤트를 생성할 수 있다") + void test_profile_type() { + // when + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of("profile/user/avatar.jpg", 1L, "PROFILE"); + + // then + assertEquals("PROFILE", event.referenceType()); + } + + @Test + @DisplayName("HELP 타입의 이벤트를 생성할 수 있다") + void test_help_type() { + // when + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of("help/20251231/image.jpg", 50L, "HELP"); + + // then + assertEquals("HELP", event.referenceType()); + } + + @Test + @DisplayName("BUSINESS 타입의 이벤트를 생성할 수 있다") + void test_business_type() { + // when + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of("business/20251231/doc.jpg", 30L, "BUSINESS"); + + // then + assertEquals("BUSINESS", event.referenceType()); + } + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java new file mode 100644 index 000000000..0002312df --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java @@ -0,0 +1,160 @@ +package app.bottlenote.common.file.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import app.bottlenote.common.file.constant.ResourceEventType; +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.dto.request.ResourceLogRequest; +import app.bottlenote.common.file.event.listener.ResourceEventListener; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.service.ResourceCommandService; +import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; +import java.util.List; +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; + +@Tag("unit") +@DisplayName("[unit] [event] ResourceEventListener") +class ResourceEventListenerTest { + + private ResourceEventListener resourceEventListener; + private ResourceCommandService resourceCommandService; + private InMemoryResourceLogRepository resourceLogRepository; + + @BeforeEach + void setUp() { + resourceLogRepository = new InMemoryResourceLogRepository(); + resourceCommandService = new ResourceCommandService(resourceLogRepository); + resourceEventListener = new ResourceEventListener(resourceCommandService); + } + + private void createResourceLog(String resourceKey, Long userId) { + ResourceLogRequest request = + ResourceLogRequest.builder() + .userId(userId) + .resourceKey(resourceKey) + .viewUrl("https://cdn.example.com/" + resourceKey) + .rootPath("review") + .bucketName("test-bucket") + .build(); + resourceCommandService.saveImageResourceCreated(request).join(); + } + + @Nested + @DisplayName("이미지 리소스 활성화 이벤트 처리 테스트") + class HandleImageResourceActivatedTest { + + @Test + @DisplayName("단일 리소스 키에 대해 ACTIVATED 로그가 저장된다") + void test_single_resource_key() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + createResourceLog(resourceKey, 1L); + + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of(resourceKey, 100L, "REVIEW"); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs = + resourceLogRepository.findByReferenceIdAndReferenceType(100L, "REVIEW"); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.ACTIVATED, logs.get(0).getEventType()); + assertEquals(100L, logs.get(0).getReferenceId()); + assertEquals("REVIEW", logs.get(0).getReferenceType()); + } + + @Test + @DisplayName("여러 리소스 키에 대해 각각 ACTIVATED 로그가 저장된다") + void test_multiple_resource_keys() { + // given + String resourceKey1 = "review/20251231/1-uuid1.jpg"; + String resourceKey2 = "review/20251231/2-uuid2.jpg"; + String resourceKey3 = "review/20251231/3-uuid3.jpg"; + createResourceLog(resourceKey1, 1L); + createResourceLog(resourceKey2, 1L); + createResourceLog(resourceKey3, 1L); + + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of( + List.of(resourceKey1, resourceKey2, resourceKey3), 200L, "REVIEW"); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs = + resourceLogRepository.findByReferenceIdAndReferenceType(200L, "REVIEW"); + assertEquals(3, logs.size()); + logs.forEach( + log -> { + assertEquals(ResourceEventType.ACTIVATED, log.getEventType()); + assertEquals(200L, log.getReferenceId()); + assertEquals("REVIEW", log.getReferenceType()); + }); + } + + @Test + @DisplayName("PROFILE 타입의 리소스에 대해 ACTIVATED 로그가 저장된다") + void test_profile_reference_type() { + // given + String resourceKey = "profile/20251231/1-uuid.jpg"; + createResourceLog(resourceKey, 1L); + + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of(resourceKey, 1L, "PROFILE"); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs = + resourceLogRepository.findByReferenceIdAndReferenceType(1L, "PROFILE"); + assertEquals(1, logs.size()); + assertEquals("PROFILE", logs.get(0).getReferenceType()); + } + + @Test + @DisplayName("HELP 타입의 리소스에 대해 ACTIVATED 로그가 저장된다") + void test_help_reference_type() { + // given + String resourceKey = "help/20251231/1-uuid.jpg"; + createResourceLog(resourceKey, 1L); + + ImageResourceActivatedEvent event = ImageResourceActivatedEvent.of(resourceKey, 50L, "HELP"); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs = resourceLogRepository.findByReferenceIdAndReferenceType(50L, "HELP"); + assertEquals(1, logs.size()); + assertEquals("HELP", logs.get(0).getReferenceType()); + } + + @Test + @DisplayName("BUSINESS 타입의 리소스에 대해 ACTIVATED 로그가 저장된다") + void test_business_reference_type() { + // given + String resourceKey = "business/20251231/1-uuid.jpg"; + createResourceLog(resourceKey, 1L); + + ImageResourceActivatedEvent event = + ImageResourceActivatedEvent.of(resourceKey, 30L, "BUSINESS"); + + // when + resourceEventListener.handleImageResourceActivated(event); + + // then + List logs = + resourceLogRepository.findByReferenceIdAndReferenceType(30L, "BUSINESS"); + assertEquals(1, logs.size()); + assertEquals("BUSINESS", logs.get(0).getReferenceType()); + } + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/image/ImageUtilTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/image/ImageUtilTest.java new file mode 100644 index 000000000..9302f6991 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/image/ImageUtilTest.java @@ -0,0 +1,128 @@ +package app.bottlenote.common.image; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("[unit] [util] ImageUtil") +class ImageUtilTest { + + @Nested + @DisplayName("extractResourceKey 메서드 테스트") + class ExtractResourceKeyTest { + + @Test + @DisplayName("CloudFront URL에서 리소스 키를 추출한다") + void test_cloudfront_url() { + // given + String viewUrl = "https://cdn.bottlenote.com/review/20251231/1-uuid.jpg"; + + // when + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + + // then + assertEquals("review/20251231/1-uuid.jpg", resourceKey); + } + + @Test + @DisplayName("S3 URL에서 리소스 키를 추출한다") + void test_s3_url() { + // given + String viewUrl = + "https://bottlenote.s3.ap-northeast-2.amazonaws.com/profile/20251231/user-avatar.png"; + + // when + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + + // then + assertEquals("profile/20251231/user-avatar.png", resourceKey); + } + + @Test + @DisplayName("HTTP URL에서도 리소스 키를 추출한다") + void test_http_url() { + // given + String viewUrl = "http://localhost:8080/help/20251231/image.jpg"; + + // when + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + + // then + assertEquals("help/20251231/image.jpg", resourceKey); + } + + @Test + @DisplayName("프로토콜이 없는 경로는 그대로 반환한다") + void test_path_without_protocol() { + // given + String viewUrl = "review/20251231/1-uuid.jpg"; + + // when + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + + // then + assertEquals("review/20251231/1-uuid.jpg", resourceKey); + } + + @Test + @DisplayName("null 입력시 null을 반환한다") + void test_null_input() { + // when + String resourceKey = ImageUtil.extractResourceKey(null); + + // then + assertNull(resourceKey); + } + + @Test + @DisplayName("빈 문자열 입력시 null을 반환한다") + void test_empty_string() { + // when + String resourceKey = ImageUtil.extractResourceKey(""); + + // then + assertNull(resourceKey); + } + + @Test + @DisplayName("공백 문자열 입력시 null을 반환한다") + void test_blank_string() { + // when + String resourceKey = ImageUtil.extractResourceKey(" "); + + // then + assertNull(resourceKey); + } + + @Test + @DisplayName("호스트만 있는 URL은 빈 문자열을 반환한다") + void test_host_only_url() { + // given + String viewUrl = "https://cdn.bottlenote.com"; + + // when + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + + // then + assertEquals("", resourceKey); + } + + @Test + @DisplayName("깊은 경로의 URL에서 리소스 키를 추출한다") + void test_deep_path_url() { + // given + String viewUrl = "https://cdn.bottlenote.com/a/b/c/d/e/image.jpg"; + + // when + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + + // then + assertEquals("a/b/c/d/e/image.jpg", resourceKey); + } + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java index 6661a0b07..d27d6379f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java @@ -66,7 +66,9 @@ public PageResponse getReviewsByMe( @Override public Optional findByIdAndUserId(Long reviewId, Long userId) { - return Optional.empty(); + return database.values().stream() + .filter(r -> r.getId().equals(reviewId) && r.getUserId().equals(userId)) + .findFirst(); } @Override diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java index dc8d458f0..b90f8c7cf 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/business/service/BusinessSupportServiceTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import app.bottlenote.common.event.fixture.FakeApplicationEventPublisher; import app.bottlenote.common.profanity.FakeProfanityClient; import app.bottlenote.common.profanity.ProfanityClient; import app.bottlenote.global.data.response.CollectionResponse; @@ -30,6 +31,7 @@ import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; @Tag("unit") @DisplayName("[unit] [service] BusinessSupport") @@ -41,6 +43,7 @@ class BusinessSupportServiceTest { private InMemoryBusinessSupportRepository repository; private UserFacade userFacade; private ProfanityClient profanityClient; + private ApplicationEventPublisher eventPublisher; @BeforeEach void setUp() { @@ -51,7 +54,8 @@ void setUp() { UserProfileItem.create(3L, "user3", "")); repository = new InMemoryBusinessSupportRepository(); profanityClient = new FakeProfanityClient(); - service = new BusinessSupportService(repository, userFacade, profanityClient); + eventPublisher = new FakeApplicationEventPublisher(); + service = new BusinessSupportService(repository, userFacade, profanityClient, eventPublisher); } @Test diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/help/fixture/InMemoryHelpRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/help/fixture/InMemoryHelpRepository.java new file mode 100644 index 000000000..92650000c --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/help/fixture/InMemoryHelpRepository.java @@ -0,0 +1,78 @@ +package app.bottlenote.support.help.fixture; + +import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.support.help.constant.HelpType; +import app.bottlenote.support.help.domain.Help; +import app.bottlenote.support.help.domain.HelpRepository; +import app.bottlenote.support.help.dto.request.AdminHelpPageableRequest; +import app.bottlenote.support.help.dto.request.HelpPageableRequest; +import app.bottlenote.support.help.dto.response.AdminHelpListResponse; +import app.bottlenote.support.help.dto.response.HelpListResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.test.util.ReflectionTestUtils; + +public class InMemoryHelpRepository implements HelpRepository { + + private final Map database = new HashMap<>(); + private long sequence = 1L; + + @Override + public Help save(Help help) { + Long id = help.getId(); + if (Objects.isNull(id)) { + id = sequence++; + ReflectionTestUtils.setField(help, "id", id); + } + database.put(id, help); + return help; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(database.get(id)); + } + + @Override + public List findAll() { + return List.copyOf(database.values()); + } + + @Override + public List findAllByUserId(Long userId) { + return database.values().stream().filter(h -> h.getUserId().equals(userId)).toList(); + } + + @Override + public List findAllByUserIdAndType(Long userId, HelpType helpType) { + return database.values().stream() + .filter(h -> h.getUserId().equals(userId) && h.getType().equals(helpType)) + .toList(); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return database.values().stream() + .filter(h -> h.getId().equals(id) && h.getUserId().equals(userId)) + .findFirst(); + } + + @Override + public PageResponse getHelpList( + HelpPageableRequest helpPageableRequest, Long currentUserId) { + return null; + } + + @Override + public PageResponse getAdminHelpList(AdminHelpPageableRequest request) { + return null; + } + + public void clear() { + database.clear(); + sequence = 1L; + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/help/service/HelpServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/help/service/HelpServiceTest.java index 106b906f0..b6bc7e247 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/help/service/HelpServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/help/service/HelpServiceTest.java @@ -36,6 +36,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @Tag("unit") @DisplayName("[unit] [service] HelpService") @@ -51,6 +52,7 @@ class HelpServiceTest { @InjectMocks private HelpService helpService; @Mock private HelpRepository helpRepository; @Mock private UserFacade userDomainSupport; + @Mock private ApplicationEventPublisher eventPublisher; @DisplayName("회원은 문의글을 작성할 수 있다.") @Test diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java new file mode 100644 index 000000000..8b9292377 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java @@ -0,0 +1,76 @@ +package app.bottlenote.user.fixture; + +import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.domain.UserRepository; +import app.bottlenote.user.dto.dsl.MyBottlePageableCriteria; +import app.bottlenote.user.dto.response.MyBottleResponse; +import app.bottlenote.user.dto.response.MyPageResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.test.util.ReflectionTestUtils; + +public class InMemoryUserRepository implements UserRepository { + + private final Map database = new HashMap<>(); + private long sequence = 1L; + + @Override + public User save(User user) { + Long id = user.getId(); + if (Objects.isNull(id)) { + id = sequence++; + ReflectionTestUtils.setField(user, "id", id); + } + database.put(id, user); + return user; + } + + @Override + public Optional findById(Long userId) { + return Optional.ofNullable(database.get(userId)); + } + + @Override + public List findAll() { + return List.copyOf(database.values()); + } + + @Override + public boolean existsByUserId(Long userId) { + return database.containsKey(userId); + } + + @Override + public boolean existsByNickName(String nickname) { + return database.values().stream().anyMatch(u -> u.getNickName().equals(nickname)); + } + + @Override + public MyPageResponse getMyPage(Long userId, Long currentUserId) { + return null; + } + + @Override + public PageResponse getReviewMyBottle(MyBottlePageableCriteria criteria) { + return null; + } + + @Override + public PageResponse getRatingMyBottle(MyBottlePageableCriteria criteria) { + return null; + } + + @Override + public PageResponse getPicksMyBottle(MyBottlePageableCriteria criteria) { + return null; + } + + public void clear() { + database.clear(); + sequence = 1L; + } +} From aa9b0fd6d8ce40049280c9308b993208f1975cb4 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 9 Jan 2026 20:33:31 +0900 Subject: [PATCH 12/95] =?UTF-8?q?test:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20ACTIVATED=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리뷰 생성 시 이미지 포함되면 ACTIVATED 로그 저장 검증 - 이미지 없이 리뷰 생성 시 ACTIVATED 이벤트 미발생 검증 - CREATED -> ACTIVATED 전체 흐름 테스트 추가 - Awaitility를 사용한 비동기 이벤트 대기 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ImageUploadIntegrationTest.java | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index e15baa0b3..c21cd8919 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -8,10 +8,20 @@ import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.common.file.constant.ResourceEventType; import app.bottlenote.common.file.domain.ResourceLog; import app.bottlenote.common.file.domain.ResourceLogRepository; +import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; +import app.bottlenote.review.constant.ReviewDisplayStatus; +import app.bottlenote.review.constant.SizeType; +import app.bottlenote.review.dto.request.LocationInfoRequest; +import app.bottlenote.review.dto.request.ReviewCreateRequest; +import app.bottlenote.review.dto.request.ReviewImageInfoRequest; +import app.bottlenote.review.dto.response.ReviewCreateResponse; +import java.math.BigDecimal; import java.util.List; import java.util.concurrent.TimeUnit; import org.awaitility.Awaitility; @@ -27,6 +37,12 @@ class ImageUploadIntegrationTest extends IntegrationTestSupport { @Autowired private ResourceLogRepository resourceLogRepository; + @Autowired private AlcoholTestFactory alcoholTestFactory; + + private LocationInfoRequest createTestLocationInfo() { + return new LocationInfoRequest( + "테스트 장소", "12345", "서울시 강남구", "상세 주소", "BAR", "https://map.test.com", "37.123", "127.456"); + } @Nested @DisplayName("PreSigned URL 생성 테스트") @@ -173,4 +189,246 @@ void test_1() throws Exception { log.info("저장된 ResourceLog 수: {}", logs.size()); } } + + @Nested + @DisplayName("이미지 리소스 활성화 테스트") + class ResourceActivationTest { + + @Test + @DisplayName("리뷰 생성 시 이미지가 포함되면 ResourceLog에 ACTIVATED 이벤트가 저장된다") + void test_review_with_images_creates_activated_log() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + // PreSigned URL 생성 (CREATED 로그 저장) + MvcTestResult presignResult = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "2") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse uploadResponse = extractData(presignResult, ImageUploadResponse.class); + List uploadInfos = uploadResponse.imageUploadInfo(); + + // CREATED 로그 저장 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(2, logs.size()); + }); + + // 리뷰 생성 요청 (이미지 URL 포함) + List imageRequests = + List.of( + new ReviewImageInfoRequest(1L, uploadInfos.get(0).viewUrl()), + new ReviewImageInfoRequest(2L, uploadInfos.get(1).viewUrl())); + + ReviewCreateRequest reviewRequest = + new ReviewCreateRequest( + alcohol.getId(), + ReviewDisplayStatus.PUBLIC, + "테스트 리뷰 내용입니다.", + SizeType.GLASS, + BigDecimal.valueOf(30000), + createTestLocationInfo(), + imageRequests, + List.of("테이스팅태그"), + 4.5); + + // when + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); + assertNotNull(reviewResponse.getId()); + + // then - ACTIVATED 로그 저장 대기 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long activatedCount = + logs.stream() + .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) + .count(); + assertEquals(2, activatedCount); + }); + + // ACTIVATED 로그 검증 + List allLogs = resourceLogRepository.findByUserId(userId); + List activatedLogs = + allLogs.stream() + .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) + .toList(); + + assertEquals(2, activatedLogs.size()); + activatedLogs.forEach( + activatedLog -> { + assertEquals(ResourceEventType.ACTIVATED, activatedLog.getEventType()); + assertEquals(reviewResponse.getId(), activatedLog.getReferenceId()); + assertEquals("REVIEW", activatedLog.getReferenceType()); + assertTrue(activatedLog.getResourceKey().startsWith("review/")); + }); + + log.info("ACTIVATED 로그 수: {}", activatedLogs.size()); + } + + @Test + @DisplayName("이미지 없이 리뷰를 생성할 때 ACTIVATED 이벤트가 발생하지 않는다") + void test_review_without_images_does_not_create_activated_log() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + ReviewCreateRequest reviewRequest = + new ReviewCreateRequest( + alcohol.getId(), + ReviewDisplayStatus.PUBLIC, + "이미지 없는 테스트 리뷰", + SizeType.BOTTLE, + BigDecimal.valueOf(50000), + createTestLocationInfo(), + List.of(), + List.of(), + 3.0); + + // when + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); + assertNotNull(reviewResponse.getId()); + + // then - 잠시 대기 후 ACTIVATED 로그가 없는지 확인 + Thread.sleep(1000); + List logs = resourceLogRepository.findByUserId(userId); + long activatedCount = + logs.stream().filter(log -> log.getEventType() == ResourceEventType.ACTIVATED).count(); + + assertEquals(0, activatedCount); + log.info("이미지 없는 리뷰 생성 후 ACTIVATED 로그 수: {}", activatedCount); + } + + @Test + @DisplayName("PreSigned URL 생성부터 리뷰 생성까지 전체 흐름에서 CREATED와 ACTIVATED 로그가 순차적으로 저장된다") + void test_full_flow_created_to_activated() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + // 1. PreSigned URL 생성 -> CREATED 로그 + MvcTestResult presignResult = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "1") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse uploadResponse = extractData(presignResult, ImageUploadResponse.class); + String viewUrl = uploadResponse.imageUploadInfo().get(0).viewUrl(); + + // CREATED 로그 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.CREATED, logs.get(0).getEventType()); + }); + + // 2. 리뷰 생성 -> ACTIVATED 로그 + ReviewCreateRequest reviewRequest = + new ReviewCreateRequest( + alcohol.getId(), + ReviewDisplayStatus.PUBLIC, + "전체 흐름 테스트 리뷰", + SizeType.GLASS, + BigDecimal.valueOf(25000), + createTestLocationInfo(), + List.of(new ReviewImageInfoRequest(1L, viewUrl)), + List.of(), + 4.0); + + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); + + // ACTIVATED 로그 대기 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(2, logs.size()); + }); + + // then - 전체 로그 검증 + List allLogs = resourceLogRepository.findByUserId(userId); + assertEquals(2, allLogs.size()); + + ResourceLog createdLog = + allLogs.stream() + .filter(log -> log.getEventType() == ResourceEventType.CREATED) + .findFirst() + .orElseThrow(); + ResourceLog activatedLog = + allLogs.stream() + .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) + .findFirst() + .orElseThrow(); + + // CREATED 로그 검증 + assertEquals(ResourceEventType.CREATED, createdLog.getEventType()); + assertEquals(userId, createdLog.getUserId()); + assertTrue(createdLog.getResourceKey().startsWith("review/")); + + // ACTIVATED 로그 검증 + assertEquals(ResourceEventType.ACTIVATED, activatedLog.getEventType()); + assertEquals(reviewResponse.getId(), activatedLog.getReferenceId()); + assertEquals("REVIEW", activatedLog.getReferenceType()); + assertEquals(createdLog.getResourceKey(), activatedLog.getResourceKey()); + + log.info( + "전체 흐름 테스트 완료 - CREATED: {}, ACTIVATED: {}", createdLog.getId(), activatedLog.getId()); + } + } } From 08aacc9fffce39986607cbefeb7ebb08e57de299 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 9 Jan 2026 20:53:51 +0900 Subject: [PATCH 13/95] =?UTF-8?q?docs:=20CLAUDE.md=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=A8=ED=84=B4=20=EB=B0=8F=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EA=B0=80=EC=9D=B4=EB=93=9C=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 - 단위 테스트 패턴: Fake/Stub, InMemory 레포지토리 - 통합 테스트 패턴: IntegrationTestSupport, Awaitility - 이벤트 기반 아키텍처 가이드 추가 - 서브모듈 초기화 명령어 및 설명 추가 - @DisplayName 형식 명시 (~할 때 ~한다) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index bab67e321..7e47186c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,9 @@ flowchart TD ## 빌드 및 실행 ```bash +# 서브모듈 초기화 (최초 클론 후 필수) +git submodule update --init --recursive + ./gradlew build # 전체 빌드 ./gradlew test # 기본 테스트 (integration, data-jpa-test 제외) ./gradlew unit_test # 단위 테스트 (@Tag("unit")) @@ -93,6 +96,12 @@ flowchart TD ./gradlew bootRun # 애플리케이션 실행 ``` +### 서브모듈 + +- **git.environment-variables**: 환경 설정 및 초기화 스크립트 포함 + - `storage/mysql/init/*.sql`: TestContainers용 DB 초기화 스크립트 + - 통합 테스트 실행 전 서브모듈 초기화 필수 + ## 코드 작성 규칙 ### 아키텍처 패턴 @@ -157,7 +166,7 @@ flowchart TD - `@Tag("unit")`: 단위 테스트, `@Tag("integration")`: 통합 테스트, `@Tag("rule")`: 아키텍처 규칙 - 클래스명: `{기능명}ServiceTest`, 메서드명: `{기능명}할_수_있다` -- `@DisplayName`: 한글로 테스트 목적 명시 +- `@DisplayName`: 한글로 테스트 목적 명시 (형식: `~할 때 ~한다`) ### 테스트 구조 @@ -166,6 +175,45 @@ flowchart TD - TestContainers 사용 (실제 DB 환경) - 테스트 데이터: `src/test/resources/init-script/` 디렉토리 +### 단위 테스트 패턴 + +- **Fake/Stub 패턴 선호**: Mock 대신 InMemory 구현체 사용 +- **네이밍**: `InMemory{도메인명}Repository`, `Fake{서비스명}` +- **위치**: `{도메인}.fixture` 패키지 +- **이벤트 테스트**: `FakeApplicationEventPublisher`로 발행된 이벤트 검증 + +```java +// 예시: InMemory 레포지토리 +public class InMemoryReviewRepository implements ReviewRepository { + private final Map database = new HashMap<>(); + // 도메인 레포지토리 인터페이스 구현 +} +``` + +### 통합 테스트 패턴 + +- **베이스 클래스**: `IntegrationTestSupport` 상속 +- **API 테스트**: `MockMvcTester` 사용, `extractData()` 메서드로 응답 추출 +- **비동기 대기**: `Awaitility`로 이벤트 처리 대기 +- **테스트 데이터 생성**: `{도메인명}TestFactory` 사용 (`@Autowired`) + +```java +// 예시: 비동기 이벤트 대기 +Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + List logs = repository.findByUserId(userId); + assertEquals(2, logs.size()); + }); +``` + +### 이벤트 기반 아키텍처 + +- **이벤트 발행**: `ApplicationEventPublisher.publishEvent()` +- **이벤트 수신**: `@TransactionalEventListener` + `@Async` 조합 +- **트랜잭션 분리**: `@Transactional(propagation = Propagation.REQUIRES_NEW)` +- **이벤트 클래스**: `{도메인명}{동작}Event` record로 정의 + ## 데이터베이스 설계 ### JPA 엔티티 From a74703c5ea956d279cf06e5d2f88551b726f740e Mon Sep 17 00:00:00 2001 From: hgkim Date: Sun, 11 Jan 2026 14:18:39 +0900 Subject: [PATCH 14/95] chore: version update --- bottlenote-admin-api/VERSION | 2 +- git.environment-variables | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index af0b7ddbf..238d6e882 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.6 +1.0.7 diff --git a/git.environment-variables b/git.environment-variables index 6e446486d..e583979bb 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 6e446486d4292f92b13cf3790ab9cc8da10ba2fe +Subproject commit e583979bb8b22cfb1628b2d83ec6bf652b8b36b1 From 173d4fce9de2cfb4c546ab64add26dca5da123da Mon Sep 17 00:00:00 2001 From: hgkim Date: Sun, 11 Jan 2026 14:19:40 +0900 Subject: [PATCH 15/95] chore: version update --- bottlenote-admin-api/VERSION | 2 +- git.environment-variables | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 238d6e882..b17c3b3fb 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.7 +1.0.7+1 diff --git a/git.environment-variables b/git.environment-variables index e583979bb..3d9fd5362 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit e583979bb8b22cfb1628b2d83ec6bf652b8b36b1 +Subproject commit 3d9fd5362b4f0578ef4e03aa82154921783de146 From 085a52a6d4be76386c6a07b7d5ad63f3c2ee59bf Mon Sep 17 00:00:00 2001 From: hgkim Date: Sun, 11 Jan 2026 15:26:16 +0900 Subject: [PATCH 16/95] chore: version update --- bottlenote-admin-api/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index b17c3b3fb..f7a39b954 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.7+1 +1.0.7+2 From a996df5922aca10e8acd148640eb62d2cb504abe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 07:22:25 +0000 Subject: [PATCH 17/95] =?UTF-8?q?feat(alcohols):=20TastingTag=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EA=B8=B0=EB=8A=A5=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TastingTagRepository 도메인 인터페이스 생성 - JpaTastingTagRepository JPA 구현체 생성 - TastingTagService 스켈레톤 생성 (Aho-Corasick 적용 예정) --- .../alcohols/domain/TastingTagRepository.java | 8 ++++++ .../repository/JpaTastingTagRepository.java | 11 ++++++++ .../alcohols/service/TastingTagService.java | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java 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 new file mode 100644 index 000000000..a23b4e3d8 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/TastingTagRepository.java @@ -0,0 +1,8 @@ +package app.bottlenote.alcohols.domain; + +import java.util.List; + +public interface TastingTagRepository { + + List findAll(); +} 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 new file mode 100644 index 000000000..eb7551e27 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaTastingTagRepository.java @@ -0,0 +1,11 @@ +package app.bottlenote.alcohols.repository; + +import app.bottlenote.alcohols.domain.TastingTag; +import app.bottlenote.alcohols.domain.TastingTagRepository; +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import org.springframework.data.repository.CrudRepository; + +@JpaRepositoryImpl +public interface JpaTastingTagRepository extends TastingTagRepository, CrudRepository { + +} 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 new file mode 100644 index 000000000..7d0b54269 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/TastingTagService.java @@ -0,0 +1,26 @@ +package app.bottlenote.alcohols.service; + +import app.bottlenote.alcohols.domain.TastingTagRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TastingTagService { + + private final TastingTagRepository tastingTagRepository; + + /** + * 문장에서 태그 이름 목록을 추출한다. + * + * @param text 분석할 문장 + * @return 매칭된 태그 이름 목록 + */ + public List extractTagNames(String text) { + // TODO: Aho-Corasick 알고리즘 적용 예정 + return List.of(); + } +} From b0e19506161d2f923d18a9777d84ed6dd13994c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 11 Jan 2026 10:02:10 +0000 Subject: [PATCH 18/95] chore: apply code formatting [skip ci] --- .../alcohols/domain/TastingTagRepository.java | 2 +- .../repository/JpaTastingTagRepository.java | 5 ++--- .../alcohols/service/TastingTagService.java | 22 +++++++++---------- 3 files changed, 14 insertions(+), 15 deletions(-) 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 a23b4e3d8..fd20bf58e 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 @@ -4,5 +4,5 @@ public interface TastingTagRepository { - List findAll(); + List findAll(); } 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 eb7551e27..fbbf74463 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 @@ -6,6 +6,5 @@ import org.springframework.data.repository.CrudRepository; @JpaRepositoryImpl -public interface JpaTastingTagRepository extends TastingTagRepository, CrudRepository { - -} +public interface JpaTastingTagRepository + extends TastingTagRepository, CrudRepository {} 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 7d0b54269..40488bf67 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 @@ -11,16 +11,16 @@ @RequiredArgsConstructor public class TastingTagService { - private final TastingTagRepository tastingTagRepository; + private final TastingTagRepository tastingTagRepository; - /** - * 문장에서 태그 이름 목록을 추출한다. - * - * @param text 분석할 문장 - * @return 매칭된 태그 이름 목록 - */ - public List extractTagNames(String text) { - // TODO: Aho-Corasick 알고리즘 적용 예정 - return List.of(); - } + /** + * 문장에서 태그 이름 목록을 추출한다. + * + * @param text 분석할 문장 + * @return 매칭된 태그 이름 목록 + */ + public List extractTagNames(String text) { + // TODO: Aho-Corasick 알고리즘 적용 예정 + return List.of(); + } } From 65fa8757d1f2d7e14206a779e4245e24004348dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 06:28:23 +0000 Subject: [PATCH 19/95] =?UTF-8?q?feat(alcohols):=20Aho-Corasick=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20TastingTag=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ahocorasick 라이브러리 의존성 추가 (0.6.3) - ApplicationReadyEvent + @Scheduled로 Trie 초기화 - extractTagNames: 1차 Aho-Corasick 매칭 + 2차 한글 경계 검증 --- bottlenote-mono/build.gradle | 3 ++ .../alcohols/service/TastingTagService.java | 52 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/bottlenote-mono/build.gradle b/bottlenote-mono/build.gradle index d915d237d..4b5f930c2 100644 --- a/bottlenote-mono/build.gradle +++ b/bottlenote-mono/build.gradle @@ -21,6 +21,9 @@ dependencies { implementation libs.spring.boot.starter.cache implementation libs.caffeine + // ===== Text Processing ===== + implementation 'org.ahocorasick:ahocorasick:0.6.3' + // ===== Security ===== implementation libs.spring.boot.starter.security testImplementation libs.spring.security.test 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 40488bf67..5be1202e0 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,9 +1,15 @@ package app.bottlenote.alcohols.service; +import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.domain.TastingTagRepository; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.ahocorasick.trie.Emit; +import org.ahocorasick.trie.Trie; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @Slf4j @@ -13,6 +19,23 @@ public class TastingTagService { private final TastingTagRepository tastingTagRepository; + private volatile Trie trie; + + @EventListener(ApplicationReadyEvent.class) + @Scheduled(cron = "0 0 0 * * *") + public void initializeTrie() { + List tags = tastingTagRepository.findAll(); + + Trie.TrieBuilder builder = Trie.builder().ignoreCase(); + for (TastingTag tag : tags) { + builder.addKeyword(tag.getKorName()); + builder.addKeyword(tag.getEngName()); + } + + this.trie = builder.build(); + log.info("TastingTag Trie 초기화 완료: {}개 태그 등록", tags.size()); + } + /** * 문장에서 태그 이름 목록을 추출한다. * @@ -20,7 +43,32 @@ public class TastingTagService { * @return 매칭된 태그 이름 목록 */ public List extractTagNames(String text) { - // TODO: Aho-Corasick 알고리즘 적용 예정 - return List.of(); + if (trie == null || text == null || text.isBlank()) { + return List.of(); + } + + return trie.parseText(text).stream() + .filter(emit -> isWholeWord(text, emit)) + .map(Emit::getKeyword) + .distinct() + .toList(); + } + + private boolean isWholeWord(String text, Emit emit) { + int start = emit.getStart(); + int end = emit.getEnd() + 1; + + if (start > 0 && isKorean(text.charAt(start - 1))) { + return false; + } + if (end < text.length() && isKorean(text.charAt(end))) { + return false; + } + + return true; + } + + private boolean isKorean(char c) { + return (c >= 0xAC00 && c <= 0xD7A3) || (c >= 0x1100 && c <= 0x11FF); } } From ef236e14fd65c1364a7463053153f9af1a197e29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 06:31:06 +0000 Subject: [PATCH 20/95] =?UTF-8?q?refactor:=20aho-corasick=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20toml=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bottlenote-mono/build.gradle | 2 +- gradle/libs.versions.toml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/bottlenote-mono/build.gradle b/bottlenote-mono/build.gradle index 4b5f930c2..14e4a02e7 100644 --- a/bottlenote-mono/build.gradle +++ b/bottlenote-mono/build.gradle @@ -22,7 +22,7 @@ dependencies { implementation libs.caffeine // ===== Text Processing ===== - implementation 'org.ahocorasick:ahocorasick:0.6.3' + implementation libs.ahocorasick // ===== Security ===== implementation libs.spring.boot.starter.security diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfe9377d6..930899f51 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,9 @@ jetbrains-annotations = "26.0.2" # Caching caffeine = "3.1.8" +# Text Processing +ahocorasick = "0.6.3" + # AWS aws-java-sdk-s3 = "1.12.725" @@ -114,6 +117,9 @@ commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "co # Caching caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } +# Text Processing +ahocorasick = { module = "org.ahocorasick:ahocorasick", version.ref = "ahocorasick" } + # AWS aws-s3 = { module = "com.amazonaws:aws-java-sdk-s3", version.ref = "aws-java-sdk-s3" } From 1df1b4165919c00482ec84e67cc4dc977de0c32d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 06:42:40 +0000 Subject: [PATCH 21/95] =?UTF-8?q?test(alcohols):=20TastingTagService=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 - InMemoryTastingTagRepository Fake 구현체 추가 - extractTagNames 메서드 테스트 케이스 작성 - 정상 매칭, 부분 매칭 제외, 영문/대소문자, 중복 제거 등 --- .../fixture/InMemoryTastingTagRepository.java | 32 ++++ .../service/TastingTagServiceTest.java | 160 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java 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 new file mode 100644 index 000000000..ce3837b94 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryTastingTagRepository.java @@ -0,0 +1,32 @@ +package app.bottlenote.alcohols.fixture; + +import app.bottlenote.alcohols.domain.TastingTag; +import app.bottlenote.alcohols.domain.TastingTagRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.springframework.test.util.ReflectionTestUtils; + +public class InMemoryTastingTagRepository implements TastingTagRepository { + + private final List tags = new ArrayList<>(); + + @Override + public List findAll() { + return List.copyOf(tags); + } + + public TastingTag save(TastingTag tag) { + Long id = tag.getId(); + if (Objects.isNull(id)) { + id = (long) (tags.size() + 1); + ReflectionTestUtils.setField(tag, "id", id); + } + tags.add(tag); + return tag; + } + + public void clear() { + tags.clear(); + } +} 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 new file mode 100644 index 000000000..f271bba7b --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/service/TastingTagServiceTest.java @@ -0,0 +1,160 @@ +package app.bottlenote.alcohols.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.alcohols.domain.TastingTag; +import app.bottlenote.alcohols.fixture.InMemoryTastingTagRepository; +import java.util.List; +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; + +@Tag("unit") +@DisplayName("TastingTagService 단위 테스트") +class TastingTagServiceTest { + + InMemoryTastingTagRepository tastingTagRepository; + TastingTagService tastingTagService; + + @BeforeEach + void setUp() { + tastingTagRepository = new InMemoryTastingTagRepository(); + tastingTagService = new TastingTagService(tastingTagRepository); + + tastingTagRepository.save(createTag("바닐라", "vanilla")); + tastingTagRepository.save(createTag("꿀", "honey")); + tastingTagRepository.save(createTag("스모키", "smoky")); + tastingTagRepository.save(createTag("피트", "peat")); + + tastingTagService.initializeTrie(); + } + + @Nested + @DisplayName("extractTagNames 메서드") + class ExtractTagNames { + + @Test + @DisplayName("정상적으로 태그를 추출한다") + void 정상_매칭() { + // given + String text = "바닐라 향이 좋아요"; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).containsExactly("바닐라"); + } + + @Test + @DisplayName("여러 태그를 추출한다") + void 여러_태그_매칭() { + // given + String text = "바닐라, 꿀 향이 나고 스모키해요"; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).containsExactlyInAnyOrder("바닐라", "꿀"); + } + + @Test + @DisplayName("부분 매칭은 제외한다") + void 부분_매칭_제외() { + // given + String text = "바닐라빈과 꿀물"; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("영문 태그를 추출한다") + void 영문_매칭() { + // given + String text = "vanilla and honey flavor"; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).containsExactlyInAnyOrder("vanilla", "honey"); + } + + @Test + @DisplayName("대소문자를 무시하고 추출한다") + void 대소문자_무시() { + // given + String text = "VANILLA HONEY"; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).containsExactlyInAnyOrder("vanilla", "honey"); + } + + @Test + @DisplayName("중복 태그는 제거한다") + void 중복_제거() { + // given + String text = "바닐라 향과 바닐라 맛"; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).hasSize(1); + assertThat(result).containsExactly("바닐라"); + } + + @Test + @DisplayName("빈 문자열은 빈 리스트를 반환한다") + void 빈_문자열() { + // given + String text = ""; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("null은 빈 리스트를 반환한다") + void null_입력() { + // when + List result = tastingTagService.extractTagNames(null); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("매칭되는 태그가 없으면 빈 리스트를 반환한다") + void 매칭_없음() { + // given + String text = "아무런 태그도 없는 문장"; + + // when + List result = tastingTagService.extractTagNames(text); + + // then + assertThat(result).isEmpty(); + } + } + + private TastingTag createTag(String korName, String engName) { + return TastingTag.builder() + .korName(korName) + .engName(engName) + .build(); + } +} From f7b977ee122b4e4b146a5859ee569f98c90e15ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Jan 2026 06:43:22 +0000 Subject: [PATCH 22/95] chore: apply code formatting [skip ci] --- .../bottlenote/alcohols/service/TastingTagServiceTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 f271bba7b..b45156faa 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 @@ -152,9 +152,6 @@ class ExtractTagNames { } private TastingTag createTag(String korName, String engName) { - return TastingTag.builder() - .korName(korName) - .engName(engName) - .build(); + return TastingTag.builder().korName(korName).engName(engName).build(); } } From 753dd32c113644afb3c2f66dfa5250e0f148c7e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 06:45:00 +0000 Subject: [PATCH 23/95] =?UTF-8?q?test(alcohols):=20TastingTagServiceTest?= =?UTF-8?q?=20ParameterizedTest=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리뷰 문장 테스트 케이스 10개 추가 (한글/영문) - 부분 매칭 제외 케이스 5개 추가 - @NullAndEmptySource로 null/빈문자열 테스트 통합 --- .../service/TastingTagServiceTest.java | 135 +++++++++--------- 1 file changed, 67 insertions(+), 68 deletions(-) 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 b45156faa..f498412c3 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 @@ -5,11 +5,16 @@ import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.fixture.InMemoryTastingTagRepository; import java.util.List; +import java.util.stream.Stream; 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; @Tag("unit") @DisplayName("TastingTagService 단위 테스트") @@ -27,6 +32,10 @@ void setUp() { tastingTagRepository.save(createTag("꿀", "honey")); tastingTagRepository.save(createTag("스모키", "smoky")); tastingTagRepository.save(createTag("피트", "peat")); + tastingTagRepository.save(createTag("오크", "oak")); + tastingTagRepository.save(createTag("카라멜", "caramel")); + tastingTagRepository.save(createTag("시트러스", "citrus")); + tastingTagRepository.save(createTag("초콜릿", "chocolate")); tastingTagService.initializeTrie(); } @@ -35,76 +44,78 @@ void setUp() { @DisplayName("extractTagNames 메서드") class ExtractTagNames { - @Test - @DisplayName("정상적으로 태그를 추출한다") - void 정상_매칭() { - // given - String text = "바닐라 향이 좋아요"; - - // when - List result = tastingTagService.extractTagNames(text); - - // then - assertThat(result).containsExactly("바닐라"); - } - - @Test - @DisplayName("여러 태그를 추출한다") - void 여러_태그_매칭() { - // given - String text = "바닐라, 꿀 향이 나고 스모키해요"; - - // when - List result = tastingTagService.extractTagNames(text); - - // then - assertThat(result).containsExactlyInAnyOrder("바닐라", "꿀"); + static Stream 리뷰_문장_테스트_케이스() { + return Stream.of( + Arguments.of( + "바닐라 향이 은은하게 퍼지면서 꿀 같은 단맛이 느껴져요", + List.of("바닐라", "꿀")), + Arguments.of( + "스모키 하면서도 피트 향이 강렬한 아일라 위스키입니다", + List.of("스모키", "피트")), + Arguments.of( + "오크 통 숙성의 깊은 맛과 카라멜 풍미가 일품이에요", + List.of("오크", "카라멜")), + Arguments.of( + "입안에서 시트러스 향이 톡 터지고 바닐라 피니시가 길게 이어집니다", + List.of("시트러스", "바닐라")), + Arguments.of( + "초콜릿, 꿀, 바닐라 삼박자가 완벽한 밸런스를 이룹니다", + List.of("초콜릿", "꿀", "바닐라")), + Arguments.of( + "This whisky has a nice vanilla and honey sweetness with a hint of oak", + List.of("vanilla", "honey", "oak")), + Arguments.of( + "Smoky peat flavor with caramel undertones", + List.of("smoky", "peat", "caramel")), + Arguments.of( + "달콤한 꿀 향 뒤로 은은한 스모키 함이 느껴지는 복합적인 위스키", + List.of("꿀", "스모키")), + Arguments.of( + "첫 모금에 바닐라, 중반에 오크, 피니시에 카라멜 - 완벽한 3단 변화", + List.of("바닐라", "오크", "카라멜")), + Arguments.of( + "가격 대비 훌륭해요. 시트러스 향과 꿀 맛의 조화가 좋습니다", + List.of("시트러스", "꿀")) + ); } - @Test - @DisplayName("부분 매칭은 제외한다") - void 부분_매칭_제외() { - // given - String text = "바닐라빈과 꿀물"; - + @ParameterizedTest(name = "\"{0}\" → {1}") + @MethodSource("리뷰_문장_테스트_케이스") + @DisplayName("리뷰 문장에서 태그를 추출한다") + void 리뷰_문장에서_태그_추출(String review, List expectedTags) { // when - List result = tastingTagService.extractTagNames(text); + List result = tastingTagService.extractTagNames(review); // then - assertThat(result).isEmpty(); + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedTags); } - @Test - @DisplayName("영문 태그를 추출한다") - void 영문_매칭() { - // given - String text = "vanilla and honey flavor"; - - // when - List result = tastingTagService.extractTagNames(text); - - // then - assertThat(result).containsExactlyInAnyOrder("vanilla", "honey"); + static Stream 부분_매칭_제외_케이스() { + return Stream.of( + Arguments.of("바닐라빈 향이 좋아요", List.of()), + Arguments.of("꿀물처럼 달콤해요", List.of()), + Arguments.of("스모키한 느낌", List.of()), + Arguments.of("카라멜라이즈된 설탕 맛", List.of()), + Arguments.of("초콜릿케이크 같은 맛", List.of()) + ); } - @Test - @DisplayName("대소문자를 무시하고 추출한다") - void 대소문자_무시() { - // given - String text = "VANILLA HONEY"; - + @ParameterizedTest(name = "\"{0}\" → {1}") + @MethodSource("부분_매칭_제외_케이스") + @DisplayName("부분 매칭은 제외한다") + void 부분_매칭_제외(String text, List expectedTags) { // when List result = tastingTagService.extractTagNames(text); // then - assertThat(result).containsExactlyInAnyOrder("vanilla", "honey"); + assertThat(result).containsExactlyInAnyOrderElementsOf(expectedTags); } @Test @DisplayName("중복 태그는 제거한다") void 중복_제거() { // given - String text = "바닐라 향과 바닐라 맛"; + String text = "바닐라 향과 바닐라 맛이 바닐라 피니시로 이어져요"; // when List result = tastingTagService.extractTagNames(text); @@ -114,12 +125,10 @@ class ExtractTagNames { assertThat(result).containsExactly("바닐라"); } - @Test - @DisplayName("빈 문자열은 빈 리스트를 반환한다") - void 빈_문자열() { - // given - String text = ""; - + @ParameterizedTest + @NullAndEmptySource + @DisplayName("null 또는 빈 문자열은 빈 리스트를 반환한다") + void null_또는_빈_문자열(String text) { // when List result = tastingTagService.extractTagNames(text); @@ -127,21 +136,11 @@ class ExtractTagNames { assertThat(result).isEmpty(); } - @Test - @DisplayName("null은 빈 리스트를 반환한다") - void null_입력() { - // when - List result = tastingTagService.extractTagNames(null); - - // then - assertThat(result).isEmpty(); - } - @Test @DisplayName("매칭되는 태그가 없으면 빈 리스트를 반환한다") void 매칭_없음() { // given - String text = "아무런 태그도 없는 문장"; + String text = "그냥 평범한 위스키입니다. 특별한 향은 못 느꼈어요."; // when List result = tastingTagService.extractTagNames(text); From 09e8e6b330b5a2859c9311dfa209461a026824e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Jan 2026 06:45:53 +0000 Subject: [PATCH 24/95] chore: apply code formatting [skip ci] --- .../service/TastingTagServiceTest.java | 39 +++++-------------- 1 file changed, 10 insertions(+), 29 deletions(-) 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 f498412c3..059d84c70 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 @@ -46,37 +46,19 @@ class ExtractTagNames { static Stream 리뷰_문장_테스트_케이스() { return Stream.of( - Arguments.of( - "바닐라 향이 은은하게 퍼지면서 꿀 같은 단맛이 느껴져요", - List.of("바닐라", "꿀")), - Arguments.of( - "스모키 하면서도 피트 향이 강렬한 아일라 위스키입니다", - List.of("스모키", "피트")), - Arguments.of( - "오크 통 숙성의 깊은 맛과 카라멜 풍미가 일품이에요", - List.of("오크", "카라멜")), - Arguments.of( - "입안에서 시트러스 향이 톡 터지고 바닐라 피니시가 길게 이어집니다", - List.of("시트러스", "바닐라")), - Arguments.of( - "초콜릿, 꿀, 바닐라 삼박자가 완벽한 밸런스를 이룹니다", - List.of("초콜릿", "꿀", "바닐라")), + Arguments.of("바닐라 향이 은은하게 퍼지면서 꿀 같은 단맛이 느껴져요", List.of("바닐라", "꿀")), + Arguments.of("스모키 하면서도 피트 향이 강렬한 아일라 위스키입니다", List.of("스모키", "피트")), + Arguments.of("오크 통 숙성의 깊은 맛과 카라멜 풍미가 일품이에요", List.of("오크", "카라멜")), + Arguments.of("입안에서 시트러스 향이 톡 터지고 바닐라 피니시가 길게 이어집니다", List.of("시트러스", "바닐라")), + Arguments.of("초콜릿, 꿀, 바닐라 삼박자가 완벽한 밸런스를 이룹니다", List.of("초콜릿", "꿀", "바닐라")), Arguments.of( "This whisky has a nice vanilla and honey sweetness with a hint of oak", List.of("vanilla", "honey", "oak")), Arguments.of( - "Smoky peat flavor with caramel undertones", - List.of("smoky", "peat", "caramel")), - Arguments.of( - "달콤한 꿀 향 뒤로 은은한 스모키 함이 느껴지는 복합적인 위스키", - List.of("꿀", "스모키")), - Arguments.of( - "첫 모금에 바닐라, 중반에 오크, 피니시에 카라멜 - 완벽한 3단 변화", - List.of("바닐라", "오크", "카라멜")), - Arguments.of( - "가격 대비 훌륭해요. 시트러스 향과 꿀 맛의 조화가 좋습니다", - List.of("시트러스", "꿀")) - ); + "Smoky peat flavor with caramel undertones", List.of("smoky", "peat", "caramel")), + Arguments.of("달콤한 꿀 향 뒤로 은은한 스모키 함이 느껴지는 복합적인 위스키", List.of("꿀", "스모키")), + Arguments.of("첫 모금에 바닐라, 중반에 오크, 피니시에 카라멜 - 완벽한 3단 변화", List.of("바닐라", "오크", "카라멜")), + Arguments.of("가격 대비 훌륭해요. 시트러스 향과 꿀 맛의 조화가 좋습니다", List.of("시트러스", "꿀"))); } @ParameterizedTest(name = "\"{0}\" → {1}") @@ -96,8 +78,7 @@ class ExtractTagNames { Arguments.of("꿀물처럼 달콤해요", List.of()), Arguments.of("스모키한 느낌", List.of()), Arguments.of("카라멜라이즈된 설탕 맛", List.of()), - Arguments.of("초콜릿케이크 같은 맛", List.of()) - ); + Arguments.of("초콜릿케이크 같은 맛", List.of())); } @ParameterizedTest(name = "\"{0}\" → {1}") From dc3a89eac95c72a84a288922d72a6c690cbd431d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 06:53:02 +0000 Subject: [PATCH 25/95] =?UTF-8?q?fix:=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EA=B7=9C=EC=B9=99=20=EC=A4=80=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TastingTagService public 메서드에 @Transactional 추가 - ServiceLayerRules에 ahocorasick 패키지 허용 추가 --- .../app/bottlenote/alcohols/service/TastingTagService.java | 3 +++ .../src/test/java/app/rule/api/ServiceLayerRules.java | 1 + 2 files changed, 4 insertions(+) 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 5be1202e0..178a7c445 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 @@ -11,6 +11,7 @@ import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -21,6 +22,7 @@ public class TastingTagService { private volatile Trie trie; + @Transactional(readOnly = true) @EventListener(ApplicationReadyEvent.class) @Scheduled(cron = "0 0 0 * * *") public void initializeTrie() { @@ -42,6 +44,7 @@ public void initializeTrie() { * @param text 분석할 문장 * @return 매칭된 태그 이름 목록 */ + @Transactional(readOnly = true) public List extractTagNames(String text) { if (trie == null || text == null || text.isBlank()) { return List.of(); diff --git a/bottlenote-product-api/src/test/java/app/rule/api/ServiceLayerRules.java b/bottlenote-product-api/src/test/java/app/rule/api/ServiceLayerRules.java index 5da5be0f7..ada065ddd 100644 --- a/bottlenote-product-api/src/test/java/app/rule/api/ServiceLayerRules.java +++ b/bottlenote-product-api/src/test/java/app/rule/api/ServiceLayerRules.java @@ -297,6 +297,7 @@ enum AllowedPackageType { LOMBOK("Lombok 라이브러리", "lombok."), SLF4J("로깅 라이브러리", "org.slf4j"), APACHE_COMMONS("Apache Commons 라이브러리", "org.apache.commons."), + AHOCORASICK("Aho-Corasick 라이브러리", "org.ahocorasick."), // 애플리케이션 계층 패키지 (MEDIUM: 아키텍처에 따라 허용되는 패키지) SERVICE("서비스 계층", ".service."), From 663fc6a37f7a8f23f2daab17b9351ca9cd9290e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 07:03:55 +0000 Subject: [PATCH 26/95] =?UTF-8?q?feat(alcohols):=20TastingTag=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/tasting-tags/extract?text={text} - 문장에서 테이스팅 태그 목록 추출 --- .../controller/TastingTagController.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java new file mode 100644 index 000000000..61b3b161f --- /dev/null +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java @@ -0,0 +1,25 @@ +package app.bottlenote.alcohols.controller; + +import app.bottlenote.alcohols.service.TastingTagService; +import app.bottlenote.global.data.response.GlobalResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/tasting-tags") +@RequiredArgsConstructor +public class TastingTagController { + + private final TastingTagService tastingTagService; + + @GetMapping("/extract") + public ResponseEntity extractTags(@RequestParam String text) { + List tags = tastingTagService.extractTagNames(text); + return GlobalResponse.ok(tags); + } +} From 6102a8d01cf3220fc1fc9886aa74c8a814ac9216 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 07:14:46 +0000 Subject: [PATCH 27/95] =?UTF-8?q?fix:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=AA=85=EB=AA=85=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractTags → getExtractedTags --- .../bottlenote/alcohols/controller/TastingTagController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java index 61b3b161f..4cf4cd387 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/TastingTagController.java @@ -18,7 +18,7 @@ public class TastingTagController { private final TastingTagService tastingTagService; @GetMapping("/extract") - public ResponseEntity extractTags(@RequestParam String text) { + public ResponseEntity getExtractedTags(@RequestParam String text) { List tags = tastingTagService.extractTagNames(text); return GlobalResponse.ok(tags); } From ff216c8fe5a3573a835c4aba5ad477c7402c1a76 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 07:29:55 +0000 Subject: [PATCH 28/95] =?UTF-8?q?test(alcohols):=20TastingTag=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20REST=20Do?= =?UTF-8?q?cs=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AlcoholMetadataTestFactory 생성 (Region, Distillery, TastingTag) - TastingTagControllerRestDocsTest API 문서화 테스트 - TastingTagIntegrationTest 통합 테스트 4개 케이스 --- .../fixture/AlcoholMetadataTestFactory.java | 113 +++++++++++++++++ .../TastingTagControllerRestDocsTest.java | 70 ++++++++++ .../TastingTagIntegrationTest.java | 120 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java new file mode 100644 index 000000000..fb34f83a5 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java @@ -0,0 +1,113 @@ +package app.bottlenote.alcohols.fixture; + +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.domain.TastingTag; +import jakarta.persistence.EntityManager; +import java.security.SecureRandom; +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 AlcoholMetadataTestFactory { + + private final Random random = new SecureRandom(); + + @Autowired + private EntityManager em; + + // ========== TastingTag ========== + + @Transactional + @NotNull + public TastingTag persistTastingTag() { + return persistTastingTag("테스트태그", "test-tag"); + } + + @Transactional + @NotNull + public TastingTag persistTastingTag(@NotNull String korName, @NotNull String engName) { + TastingTag tag = TastingTag.builder() + .korName(korName) + .engName(engName) + .build(); + em.persist(tag); + em.flush(); + return tag; + } + + @Transactional + @NotNull + public List persistTastingTags(@NotNull List tagNames) { + return tagNames.stream() + .map(names -> persistTastingTag(names[0], names[1])) + .toList(); + } + + @Transactional + @NotNull + public List persistDefaultTastingTags() { + return persistTastingTags(List.of( + new String[]{"바닐라", "vanilla"}, + new String[]{"꿀", "honey"}, + new String[]{"스모키", "smoky"}, + new String[]{"피트", "peat"}, + new String[]{"오크", "oak"}, + new String[]{"카라멜", "caramel"}, + new String[]{"시트러스", "citrus"}, + new String[]{"초콜릿", "chocolate"} + )); + } + + // ========== Region ========== + + @Transactional + @NotNull + public Region persistRegion() { + return persistRegion("스코틀랜드", "Scotland"); + } + + @Transactional + @NotNull + public Region persistRegion(@NotNull String korName, @NotNull String engName) { + Region region = Region.builder() + .korName(korName + "-" + generateRandomSuffix()) + .engName(engName + "-" + generateRandomSuffix()) + .continent("Europe") + .build(); + em.persist(region); + em.flush(); + return region; + } + + // ========== Distillery ========== + + @Transactional + @NotNull + public Distillery persistDistillery() { + return persistDistillery("맥캘란", "Macallan"); + } + + @Transactional + @NotNull + public Distillery persistDistillery(@NotNull String korName, @NotNull String engName) { + Distillery distillery = Distillery.builder() + .korName(korName + "-" + generateRandomSuffix()) + .engName(engName + "-" + generateRandomSuffix()) + .logoImgPath("https://example.com/logo.jpg") + .build(); + em.persist(distillery); + em.flush(); + return distillery; + } + + private String generateRandomSuffix() { + return String.valueOf(random.nextInt(10000)); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java new file mode 100644 index 000000000..84debb563 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java @@ -0,0 +1,70 @@ +package app.bottlenote.alcohols.controller; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.bottlenote.alcohols.service.TastingTagService; +import app.external.docs.AbstractRestDocs; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.ResultActions; + +@Tag("rest-docs") +@DisplayName("TastingTag API 문서화 테스트") +class TastingTagControllerRestDocsTest extends AbstractRestDocs { + + private final TastingTagService tastingTagService = mock(TastingTagService.class); + + @Override + protected Object initController() { + return new TastingTagController(tastingTagService); + } + + @Test + @DisplayName("문장에서 테이스팅 태그를 추출할 수 있다") + void getExtractedTags() throws Exception { + // given + List tags = List.of("바닐라", "꿀", "스모키"); + when(tastingTagService.extractTagNames(anyString())).thenReturn(tags); + + // when + ResultActions resultActions = mockMvc.perform( + get("/api/v1/tasting-tags/extract") + .param("text", "바닐라 향이 좋고 꿀 같은 단맛에 스모키 함이 느껴져요")); + + // then + resultActions + .andExpect(status().isOk()) + .andDo( + document( + "tasting-tags-extract", + queryParameters( + parameterWithName("text").description("태그를 추출할 문장 (리뷰 내용 등)")), + responseFields( + fieldWithPath("success").type(BOOLEAN).description("성공 여부"), + fieldWithPath("code").type(NUMBER).description("응답 코드"), + fieldWithPath("data").type(ARRAY).description("추출된 태그 이름 목록"), + fieldWithPath("data[]").type(STRING).description("태그 이름"), + fieldWithPath("meta").type(OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(STRING).description("서버 버전"), + fieldWithPath("meta.serverEncoding").type(STRING).description("서버 인코딩"), + fieldWithPath("meta.serverResponseTime").type(ARRAY).description("응답 시간"), + fieldWithPath("meta.serverPathVersion").type(STRING).description("API 경로 버전"), + fieldWithPath("errors").description("에러 정보").optional()))); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java new file mode 100644 index 000000000..c6f32e03d --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java @@ -0,0 +1,120 @@ +package app.bottlenote.alcohols.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.fixture.AlcoholMetadataTestFactory; +import com.fasterxml.jackson.core.type.TypeReference; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.assertj.MvcTestResult; + +@Tag("integration") +@DisplayName("[integration] [controller] TastingTag") +class TastingTagIntegrationTest extends IntegrationTestSupport { + + @Autowired + private AlcoholMetadataTestFactory metadataTestFactory; + + @BeforeEach + void setUp() { + metadataTestFactory.persistDefaultTastingTags(); + } + + @Test + @DisplayName("문장에서 태그를 추출할 수 있다") + void extractTags() throws Exception { + // given + String text = "바닐라 향이 좋고 꿀 같은 단맛이 느껴져요"; + + // when + MvcTestResult result = mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + List tags = extractDataAsList(result); + assertThat(tags).containsExactlyInAnyOrder("바닐라", "꿀"); + } + + @Test + @DisplayName("여러 태그가 포함된 문장에서 모든 태그를 추출한다") + void extractMultipleTags() throws Exception { + // given + String text = "스모키 하면서 피트 향이 강하고 오크 통 숙성의 깊은 맛"; + + // when + MvcTestResult result = mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + List tags = extractDataAsList(result); + assertThat(tags).containsExactlyInAnyOrder("스모키", "피트", "오크"); + } + + @Test + @DisplayName("부분 매칭은 제외된다") + void excludePartialMatch() throws Exception { + // given + String text = "바닐라빈 향이 좋고 꿀물처럼 달콤해요"; + + // when + MvcTestResult result = mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + List tags = extractDataAsList(result); + assertThat(tags).isEmpty(); + } + + @Test + @DisplayName("매칭되는 태그가 없으면 빈 리스트를 반환한다") + void returnEmptyWhenNoMatch() throws Exception { + // given + String text = "그냥 평범한 위스키입니다"; + + // when + MvcTestResult result = mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + List tags = extractDataAsList(result); + assertThat(tags).isEmpty(); + } + + private List extractDataAsList(MvcTestResult result) throws Exception { + result.assertThat().hasStatusOk(); + String responseString = result.getResponse().getContentAsString(); + var response = mapper.readTree(responseString); + return mapper.convertValue(response.get("data"), new TypeReference<>() {}); + } +} From 6194e3485b13727432d7f93725a0298109167c47 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Jan 2026 07:30:42 +0000 Subject: [PATCH 29/95] chore: apply code formatting [skip ci] --- .../fixture/AlcoholMetadataTestFactory.java | 54 +++++++------- .../TastingTagControllerRestDocsTest.java | 9 ++- .../TastingTagIntegrationTest.java | 71 ++++++++++--------- 3 files changed, 66 insertions(+), 68 deletions(-) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java index fb34f83a5..ed50b6f03 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholMetadataTestFactory.java @@ -19,8 +19,7 @@ public class AlcoholMetadataTestFactory { private final Random random = new SecureRandom(); - @Autowired - private EntityManager em; + @Autowired private EntityManager em; // ========== TastingTag ========== @@ -33,10 +32,7 @@ public TastingTag persistTastingTag() { @Transactional @NotNull public TastingTag persistTastingTag(@NotNull String korName, @NotNull String engName) { - TastingTag tag = TastingTag.builder() - .korName(korName) - .engName(engName) - .build(); + TastingTag tag = TastingTag.builder().korName(korName).engName(engName).build(); em.persist(tag); em.flush(); return tag; @@ -45,24 +41,22 @@ public TastingTag persistTastingTag(@NotNull String korName, @NotNull String eng @Transactional @NotNull public List persistTastingTags(@NotNull List tagNames) { - return tagNames.stream() - .map(names -> persistTastingTag(names[0], names[1])) - .toList(); + return tagNames.stream().map(names -> persistTastingTag(names[0], names[1])).toList(); } @Transactional @NotNull public List persistDefaultTastingTags() { - return persistTastingTags(List.of( - new String[]{"바닐라", "vanilla"}, - new String[]{"꿀", "honey"}, - new String[]{"스모키", "smoky"}, - new String[]{"피트", "peat"}, - new String[]{"오크", "oak"}, - new String[]{"카라멜", "caramel"}, - new String[]{"시트러스", "citrus"}, - new String[]{"초콜릿", "chocolate"} - )); + return persistTastingTags( + List.of( + new String[] {"바닐라", "vanilla"}, + new String[] {"꿀", "honey"}, + new String[] {"스모키", "smoky"}, + new String[] {"피트", "peat"}, + new String[] {"오크", "oak"}, + new String[] {"카라멜", "caramel"}, + new String[] {"시트러스", "citrus"}, + new String[] {"초콜릿", "chocolate"})); } // ========== Region ========== @@ -76,11 +70,12 @@ public Region persistRegion() { @Transactional @NotNull public Region persistRegion(@NotNull String korName, @NotNull String engName) { - Region region = Region.builder() - .korName(korName + "-" + generateRandomSuffix()) - .engName(engName + "-" + generateRandomSuffix()) - .continent("Europe") - .build(); + Region region = + Region.builder() + .korName(korName + "-" + generateRandomSuffix()) + .engName(engName + "-" + generateRandomSuffix()) + .continent("Europe") + .build(); em.persist(region); em.flush(); return region; @@ -97,11 +92,12 @@ public Distillery persistDistillery() { @Transactional @NotNull public Distillery persistDistillery(@NotNull String korName, @NotNull String engName) { - Distillery distillery = Distillery.builder() - .korName(korName + "-" + generateRandomSuffix()) - .engName(engName + "-" + generateRandomSuffix()) - .logoImgPath("https://example.com/logo.jpg") - .build(); + Distillery distillery = + Distillery.builder() + .korName(korName + "-" + generateRandomSuffix()) + .engName(engName + "-" + generateRandomSuffix()) + .logoImgPath("https://example.com/logo.jpg") + .build(); em.persist(distillery); em.flush(); return distillery; diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java index 84debb563..4bad57945 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java @@ -43,9 +43,9 @@ void getExtractedTags() throws Exception { when(tastingTagService.extractTagNames(anyString())).thenReturn(tags); // when - ResultActions resultActions = mockMvc.perform( - get("/api/v1/tasting-tags/extract") - .param("text", "바닐라 향이 좋고 꿀 같은 단맛에 스모키 함이 느껴져요")); + ResultActions resultActions = + mockMvc.perform( + get("/api/v1/tasting-tags/extract").param("text", "바닐라 향이 좋고 꿀 같은 단맛에 스모키 함이 느껴져요")); // then resultActions @@ -53,8 +53,7 @@ void getExtractedTags() throws Exception { .andDo( document( "tasting-tags-extract", - queryParameters( - parameterWithName("text").description("태그를 추출할 문장 (리뷰 내용 등)")), + queryParameters(parameterWithName("text").description("태그를 추출할 문장 (리뷰 내용 등)")), responseFields( fieldWithPath("success").type(BOOLEAN).description("성공 여부"), fieldWithPath("code").type(NUMBER).description("응답 코드"), diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java index c6f32e03d..b6a421b40 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java @@ -19,8 +19,7 @@ @DisplayName("[integration] [controller] TastingTag") class TastingTagIntegrationTest extends IntegrationTestSupport { - @Autowired - private AlcoholMetadataTestFactory metadataTestFactory; + @Autowired private AlcoholMetadataTestFactory metadataTestFactory; @BeforeEach void setUp() { @@ -34,14 +33,15 @@ void extractTags() throws Exception { String text = "바닐라 향이 좋고 꿀 같은 단맛이 느껴져요"; // when - MvcTestResult result = mockMvcTester - .get() - .uri("/api/v1/tasting-tags/extract") - .param("text", text) - .contentType(APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) - .with(csrf()) - .exchange(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); // then List tags = extractDataAsList(result); @@ -55,14 +55,15 @@ void extractMultipleTags() throws Exception { String text = "스모키 하면서 피트 향이 강하고 오크 통 숙성의 깊은 맛"; // when - MvcTestResult result = mockMvcTester - .get() - .uri("/api/v1/tasting-tags/extract") - .param("text", text) - .contentType(APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) - .with(csrf()) - .exchange(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); // then List tags = extractDataAsList(result); @@ -76,14 +77,15 @@ void excludePartialMatch() throws Exception { String text = "바닐라빈 향이 좋고 꿀물처럼 달콤해요"; // when - MvcTestResult result = mockMvcTester - .get() - .uri("/api/v1/tasting-tags/extract") - .param("text", text) - .contentType(APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) - .with(csrf()) - .exchange(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); // then List tags = extractDataAsList(result); @@ -97,14 +99,15 @@ void returnEmptyWhenNoMatch() throws Exception { String text = "그냥 평범한 위스키입니다"; // when - MvcTestResult result = mockMvcTester - .get() - .uri("/api/v1/tasting-tags/extract") - .param("text", text) - .contentType(APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) - .with(csrf()) - .exchange(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/tasting-tags/extract") + .param("text", text) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); // then List tags = extractDataAsList(result); From 33b560d9300581b329398c8697f9d472950f8371 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 13 Jan 2026 11:04:06 +0900 Subject: [PATCH 30/95] =?UTF-8?q?fix(alcohols):=20TastingTag=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20Trie=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 실행 시 Trie가 앱 컨텍스트 로드 시점에 초기화되어 @BeforeEach에서 추가한 태그를 인식하지 못하는 문제 해결 Co-Authored-By: Claude Opus 4.5 --- .../alcohols/integration/TastingTagIntegrationTest.java | 3 +++ git.environment-variables | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java index b6a421b40..ee56882bb 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java @@ -6,6 +6,7 @@ import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.fixture.AlcoholMetadataTestFactory; +import app.bottlenote.alcohols.service.TastingTagService; import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -20,10 +21,12 @@ class TastingTagIntegrationTest extends IntegrationTestSupport { @Autowired private AlcoholMetadataTestFactory metadataTestFactory; + @Autowired private TastingTagService tastingTagService; @BeforeEach void setUp() { metadataTestFactory.persistDefaultTastingTags(); + tastingTagService.initializeTrie(); } @Test diff --git a/git.environment-variables b/git.environment-variables index 3d9fd5362..34d7741c2 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 3d9fd5362b4f0578ef4e03aa82154921783de146 +Subproject commit 34d7741c26d901f1e639a29d3606a5079326e0c4 From f28affaae68049193ff7e593ac8b61719013ae01 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 13 Jan 2026 16:44:05 +0900 Subject: [PATCH 31/95] =?UTF-8?q?fix(alcohols):=20TastingTag=20REST=20Docs?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20HTTP=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REST Docs에서 data[] 필드 제거 (단순 문자열 배열은 data만 ARRAY로 문서화) - 테이스팅 태그 추출 API용 HTTP 테스트 파일 추가 Co-Authored-By: Claude Opus 4.5 --- .../TastingTagControllerRestDocsTest.java | 3 +-- git.environment-variables | 2 +- ...\352\267\270\354\266\224\354\266\234.http" | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 "http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java index 4bad57945..47ee891a5 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java @@ -57,8 +57,7 @@ void getExtractedTags() throws Exception { responseFields( fieldWithPath("success").type(BOOLEAN).description("성공 여부"), fieldWithPath("code").type(NUMBER).description("응답 코드"), - fieldWithPath("data").type(ARRAY).description("추출된 태그 이름 목록"), - fieldWithPath("data[]").type(STRING).description("태그 이름"), + fieldWithPath("data").type(ARRAY).description("추출된 태그 이름 목록 (문자열 배열)"), fieldWithPath("meta").type(OBJECT).description("메타 정보"), fieldWithPath("meta.serverVersion").type(STRING).description("서버 버전"), fieldWithPath("meta.serverEncoding").type(STRING).description("서버 인코딩"), diff --git a/git.environment-variables b/git.environment-variables index 34d7741c2..ae1e40bff 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 34d7741c26d901f1e639a29d3606a5079326e0c4 +Subproject commit ae1e40bffa04b793c005e18a1693365e4d07a3d3 diff --git "a/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" new file mode 100644 index 000000000..09b570e45 --- /dev/null +++ "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" @@ -0,0 +1,26 @@ +### 토큰 발급 +POST {{host}}/api/v1/oauth/login +Content-Type: application/json + +{ + "email": "dev.bottle-note@gmail.com", + "socialType": "GOOGLE", + "gender": "MALE", + "age": 25 +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); +%} + +### 테이스팅 태그 추출 - 기본 +GET {{host}}/api/v1/tasting-tags/extract?text=바닐라 향이 좋고 꿀 같은 단맛이 느껴져요 +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그 추출 - 여러 태그 +GET {{host}}/api/v1/tasting-tags/extract?text=스모키 하면서 피트 향이 강하고 오크 통 숙성의 깊은 맛 +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그 추출 - 매칭 없음 +GET {{host}}/api/v1/tasting-tags/extract?text=그냥 평범한 위스키입니다 +Authorization: Bearer {{accessToken}} \ No newline at end of file From a433ea207e9ea0a73864e7bcd98b172cc19d5a1f Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 13 Jan 2026 16:57:27 +0900 Subject: [PATCH 32/95] =?UTF-8?q?docs(alcohols):=20TastingTag=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tasting-tags.adoc 스니펫 파일 추가 - product-api.adoc에 테이스팅 태그 추출 API 문서 include Co-Authored-By: Claude Opus 4.5 --- .../asciidoc/api/alcohols/tasting-tags.adoc | 19 +++++++++++++++++++ .../src/docs/asciidoc/product-api.adoc | 3 +++ 2 files changed, 22 insertions(+) create mode 100644 bottlenote-product-api/src/docs/asciidoc/api/alcohols/tasting-tags.adoc diff --git a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/tasting-tags.adoc b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/tasting-tags.adoc new file mode 100644 index 000000000..a267085d1 --- /dev/null +++ b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/tasting-tags.adoc @@ -0,0 +1,19 @@ +=== 테이스팅 태그 추출 === + +문장(리뷰 내용 등)에서 테이스팅 태그를 추출합니다. + +* 입력된 텍스트에서 등록된 테이스팅 태그와 매칭되는 단어를 추출합니다. +* 부분 매칭은 제외됩니다. (예: "바닐라빈"에서 "바닐라"는 추출되지 않음) +* 매칭되는 태그가 없으면 빈 배열이 반환됩니다. + +[discrete] +==== 요청 파라미터 ==== + +include::{snippets}/tasting-tags-extract/httpie-request.adoc[] +include::{snippets}/tasting-tags-extract/query-parameters.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +include::{snippets}/tasting-tags-extract/response-fields.adoc[] +include::{snippets}/tasting-tags-extract/response-body.adoc[] \ No newline at end of file diff --git a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc index 6c54696eb..b832c4c1a 100644 --- a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc @@ -102,6 +102,9 @@ include::api/alcohols/curations.adoc[] ''' include::api/alcohols/popular.adoc[] +''' +include::api/alcohols/tasting-tags.adoc[] + == 리뷰 (review) 관련 API include::api/review/review-create.adoc[] From b2ce6f7c9b73d732b3ef919f069b610ededbdc48 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 13 Jan 2026 18:46:26 +0900 Subject: [PATCH 33/95] refactor: best review selection query and payload structure Simplified the SQL query and "BestReviewPayload" structure by removing unused fields like popularityScore, likeCount, and other statistics. Adjusted filtering logic in the query to refine the best review selection criteria for efficiency and clarity. --- .../data/payload/BestReviewPayload.java | 32 ++++--------------- git.environment-variables | 2 +- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java index c5c6fa6de..68a5d0598 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java @@ -7,29 +7,11 @@ import java.sql.SQLException; @Builder -public record BestReviewPayload( - Long id, - Long alcoholId, - Double popularityScore, - Integer likeCount, - Integer unlikeCount, - Integer replyCount, - Integer imageCount, - Integer reviewCount -) { - public static class BestReviewMapper implements RowMapper { - @Override - public BestReviewPayload mapRow(ResultSet rs, int rowNum) throws SQLException { - return BestReviewPayload.builder() - .id(rs.getLong("id")) - .alcoholId(rs.getLong("alcohol_id")) - .popularityScore(rs.getDouble("popularityScore")) - .likeCount(rs.getInt("likeCount")) - .unlikeCount(rs.getInt("unlikeCount")) - .replyCount(rs.getInt("replyCount")) - .imageCount(rs.getInt("imageCount")) - .reviewCount(rs.getInt("reviewCount")) - .build(); - } - } +public record BestReviewPayload(Long id, Long alcoholId) { + public static class BestReviewMapper implements RowMapper { + @Override + public BestReviewPayload mapRow(ResultSet rs, int rowNum) throws SQLException { + return new BestReviewPayload(rs.getLong("id"), rs.getLong("alcohol_id")); + } + } } diff --git a/git.environment-variables b/git.environment-variables index ae1e40bff..a1c88ae19 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit ae1e40bffa04b793c005e18a1693365e4d07a3d3 +Subproject commit a1c88ae199387c3485b81419110525e02ad6078b From 8d4fc248f69ae2e43e827cbfb51024f0adca1d8d Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 13 Jan 2026 18:50:12 +0900 Subject: [PATCH 34/95] chore: bump bottlenote-product-api version to 1.0.3 --- bottlenote-product-api/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 6d7de6e6a..21e8796a0 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.2 +1.0.3 From 92fd5167533266d5bffab553121d9480568f8c47 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 13 Jan 2026 19:54:13 +0900 Subject: [PATCH 35/95] =?UTF-8?q?feat(alcohols):=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/popular/view/week: 주간 조회수 기반 인기 위스키 조회 - GET /api/v1/popular/view/monthly: 월간 조회수 기반 인기 위스키 조회 - 조회수 부족 시 평점 높은 주류로 채움 (최대 20개) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../domain/PopularQueryRepository.java | 4 + .../CustomPopularQueryRepository.java | 14 ++ .../CustomPopularQueryRepositoryImpl.java | 140 ++++++++++++++++++ .../repository/JpaPopularQueryRepository.java | 2 +- .../service/AlcoholPopularService.java | 10 ++ .../AlcoholPopularQueryController.java | 20 +++ .../PopularViewIntegrationTest.java | 124 ++++++++++++++++ .../RestPopularViewControllerTest.java | 128 ++++++++++++++++ .../init-alcohols_view_history.sql | 37 +++++ .../resources/init-script/init-rating.sql | 43 ++++++ 10 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java create mode 100644 bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java create mode 100644 bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql create mode 100644 bottlenote-product-api/src/test/resources/init-script/init-rating.sql diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/PopularQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/PopularQueryRepository.java index 48c1ca1c7..4e48d76b5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/PopularQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/PopularQueryRepository.java @@ -10,4 +10,8 @@ public interface PopularQueryRepository { List getSpringItems( Long userId, List tags, List excludedTags, Pageable size); + + List getPopularByViewsWeekly(Long userId, int limit); + + List getPopularByViewsMonthly(Long userId, int limit); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java new file mode 100644 index 000000000..ec8c83cb2 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java @@ -0,0 +1,14 @@ +package app.bottlenote.alcohols.repository; + +import app.bottlenote.alcohols.dto.response.PopularItem; +import java.util.List; + +/** + * 조회수 기반 인기 주류 조회를 위한 QueryDSL Custom Repository + */ +public interface CustomPopularQueryRepository { + + List getPopularByViewsWeekly(Long userId, int limit); + + List getPopularByViewsMonthly(Long userId, int limit); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java new file mode 100644 index 000000000..d503d463b --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java @@ -0,0 +1,140 @@ +package app.bottlenote.alcohols.repository; + +import static app.bottlenote.alcohols.domain.QAlcohol.alcohol; +import static app.bottlenote.history.domain.QAlcoholsViewHistory.alcoholsViewHistory; +import static app.bottlenote.picks.domain.QPicks.picks; +import static app.bottlenote.rating.domain.QRating.rating; + +import app.bottlenote.alcohols.dto.response.PopularItem; +import app.bottlenote.picks.constant.PicksStatus; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; + +/** + * 조회수 기반 인기 주류 조회를 위한 QueryDSL 구현체 + */ +@RequiredArgsConstructor +public class CustomPopularQueryRepositoryImpl implements CustomPopularQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List getPopularByViewsWeekly(Long userId, int limit) { + LocalDateTime weekStart = LocalDate.now() + .with(DayOfWeek.MONDAY) + .atStartOfDay(); + + return getPopularByViews(userId, limit, weekStart); + } + + @Override + public List getPopularByViewsMonthly(Long userId, int limit) { + LocalDateTime monthStart = LocalDate.now() + .withDayOfMonth(1) + .atStartOfDay(); + + return getPopularByViews(userId, limit, monthStart); + } + + private List getPopularByViews(Long userId, int limit, LocalDateTime startDate) { + // 1. 조회수 기반 인기 주류 조회 + List viewBasedResults = queryFactory + .select(Projections.constructor( + PopularItem.class, + alcohol.id, + alcohol.korName, + alcohol.engName, + rating.ratingPoint.rating.avg().coalesce(0.0), + rating.id.alcoholId.count(), + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl, + ExpressionUtils.as( + JPAExpressions + .selectOne() + .from(picks) + .where( + picks.alcoholId.eq(alcohol.id), + picks.userId.eq(userId), + picks.status.eq(PicksStatus.PICK)) + .exists(), + "isPicked"), + alcoholsViewHistory.id.alcoholId.count().castToNum(Double.class))) + .from(alcoholsViewHistory) + .join(alcohol).on(alcoholsViewHistory.id.alcoholId.eq(alcohol.id)) + .leftJoin(rating).on(alcohol.id.eq(rating.id.alcoholId)) + .where(alcoholsViewHistory.viewAt.goe(startDate)) + .groupBy( + alcohol.id, + alcohol.korName, + alcohol.engName, + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl) + .orderBy(alcoholsViewHistory.id.alcoholId.count().desc()) + .limit(limit) + .fetch(); + + // 2. 부족분은 평점 높은 주류로 채움 + if (viewBasedResults.size() < limit) { + List excludeIds = viewBasedResults.stream() + .map(PopularItem::alcoholId) + .toList(); + + int remaining = limit - viewBasedResults.size(); + + List ratingBasedResults = queryFactory + .select(Projections.constructor( + PopularItem.class, + alcohol.id, + alcohol.korName, + alcohol.engName, + rating.ratingPoint.rating.avg().coalesce(0.0), + rating.id.alcoholId.count(), + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl, + ExpressionUtils.as( + JPAExpressions + .selectOne() + .from(picks) + .where( + picks.alcoholId.eq(alcohol.id), + picks.userId.eq(userId), + picks.status.eq(PicksStatus.PICK)) + .exists(), + "isPicked"), + Expressions.asNumber(0.0))) + .from(alcohol) + .join(rating).on(alcohol.id.eq(rating.id.alcoholId)) + .where(excludeIds.isEmpty() + ? null + : alcohol.id.notIn(excludeIds)) + .groupBy( + alcohol.id, + alcohol.korName, + alcohol.engName, + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl) + .orderBy(rating.ratingPoint.rating.avg().desc()) + .limit(remaining) + .fetch(); + + List result = new ArrayList<>(viewBasedResults); + result.addAll(ratingBasedResults); + return result; + } + + return viewBasedResults; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaPopularQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaPopularQueryRepository.java index b50322f2f..7d80a87a7 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaPopularQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaPopularQueryRepository.java @@ -12,7 +12,7 @@ @JpaRepositoryImpl public interface JpaPopularQueryRepository - extends PopularQueryRepository, JpaRepository { + extends PopularQueryRepository, CustomPopularQueryRepository, JpaRepository { @Override @Query( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholPopularService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholPopularService.java index 7161cdd9d..6e539a2ff 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholPopularService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholPopularService.java @@ -39,4 +39,14 @@ public List getSpringItems(Long userId) { 34L, 41L, 45L, 49L, 63L, 102L, 105L, 118L, 139L, 140L, 153L, 162L, 167L, 170L, 172L); return popularQueryRepository.getSpringItems(userId, tags, excludedTags, pageable); } + + @Transactional(readOnly = true) + public List getPopularByViewsWeekly(Integer top, Long userId) { + return popularQueryRepository.getPopularByViewsWeekly(userId, top); + } + + @Transactional(readOnly = true) + public List getPopularByViewsMonthly(Integer top, Long userId) { + return popularQueryRepository.getPopularByViewsMonthly(userId, top); + } } diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java index aad1a3240..6b3b127f7 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java @@ -42,4 +42,24 @@ public ResponseEntity getSpringItems() { var response = alcoholPopularService.getSpringItems(userId); return GlobalResponse.ok(response); } + + /** 주간 조회수 기반 인기 위스키 리스트 조회 */ + @GetMapping("/popular/view/week") + public ResponseEntity getPopularByViewsWeekly( + @RequestParam(defaultValue = "20") Integer top) { + Long userId = getUserIdByContext().orElse(-1L); + var populars = alcoholPopularService.getPopularByViewsWeekly(top, userId); + var response = PopularsOfWeekResponse.of(populars.size(), populars); + return GlobalResponse.ok(response); + } + + /** 월간 조회수 기반 인기 위스키 리스트 조회 */ + @GetMapping("/popular/view/monthly") + public ResponseEntity getPopularByViewsMonthly( + @RequestParam(defaultValue = "20") Integer top) { + Long userId = getUserIdByContext().orElse(-1L); + var populars = alcoholPopularService.getPopularByViewsMonthly(top, userId); + var response = PopularsOfWeekResponse.of(populars.size(), populars); + return GlobalResponse.ok(response); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java new file mode 100644 index 000000000..2fa1f7525 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java @@ -0,0 +1,124 @@ +package app.bottlenote.alcohols.integration; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.global.data.response.GlobalResponse; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MvcResult; + +@Tag("integration") +@DisplayName("[integration] [controller] Popular View") +class PopularViewIntegrationTest extends IntegrationTestSupport { + + @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") + @Sql( + scripts = { + "/init-script/init-alcohol.sql", + "/init-script/init-user.sql", + "/init-script/init-alcohols_view_history.sql", + "/init-script/init-rating.sql" + }) + @Test + void test_getPopularViewWeekly() throws Exception { + // given + int top = 5; + + // when && then + MvcResult result = + mockMvc + .perform( + get("/api/v1/popular/view/week") + .contentType(MediaType.APPLICATION_JSON) + .param("top", String.valueOf(top)) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.alcohols").isArray()) + .andExpect(jsonPath("$.data.alcohols.length()").value(top)) + .andReturn(); + + String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); + log.info("response : {}", response); + } + + @DisplayName("조회 기록이 부족하면 평점 높은 주류로 채워서 반환한다") + @Sql( + scripts = { + "/init-script/init-alcohol.sql", + "/init-script/init-user.sql", + "/init-script/init-alcohols_view_history.sql", + "/init-script/init-rating.sql" + }) + @Test + void test_getPopularViewWeekly_fillWithRating() throws Exception { + // given - 조회 기록 5개, 요청 10개 -> 5개는 평점 기반으로 채워짐 + int top = 10; + + // when && then + MvcResult result = + mockMvc + .perform( + get("/api/v1/popular/view/week") + .contentType(MediaType.APPLICATION_JSON) + .param("top", String.valueOf(top)) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.alcohols").isArray()) + .andExpect(jsonPath("$.data.totalCount").value(top)) + .andReturn(); + + String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); + log.info("response : {}", response); + } + + @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") + @Sql( + scripts = { + "/init-script/init-alcohol.sql", + "/init-script/init-user.sql", + "/init-script/init-alcohols_view_history.sql", + "/init-script/init-rating.sql" + }) + @Test + void test_getPopularViewMonthly() throws Exception { + // given + int top = 5; + + // when && then + MvcResult result = + mockMvc + .perform( + get("/api/v1/popular/view/monthly") + .contentType(MediaType.APPLICATION_JSON) + .param("top", String.valueOf(top)) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.alcohols").isArray()) + .andExpect(jsonPath("$.data.alcohols.length()").value(top)) + .andReturn(); + + String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); + log.info("response : {}", response); + } +} diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java new file mode 100644 index 000000000..0439b1735 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java @@ -0,0 +1,128 @@ +package app.docs.alcohols; + +import static app.bottlenote.alcohols.fixture.PopularsObjectFixture.getFixturePopulars; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.bottlenote.alcohols.controller.AlcoholPopularQueryController; +import app.bottlenote.alcohols.dto.response.PopularItem; +import app.bottlenote.alcohols.service.AlcoholPopularService; +import app.docs.AbstractRestDocs; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@DisplayName("조회수 기반 인기 위스키 RestDocs 테스트") +class RestPopularViewControllerTest extends AbstractRestDocs { + + private final AlcoholPopularService alcoholPopularService = mock(AlcoholPopularService.class); + + @Override + protected Object initController() { + return new AlcoholPopularQueryController(alcoholPopularService); + } + + @Test + @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") + void docs_getPopularViewWeekly() throws Exception { + // given + List populars = + List.of( + getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), + getFixturePopulars(2L, "맥캘란", "Macallan"), + getFixturePopulars(3L, "글렌리벳", "Glenlivet"), + getFixturePopulars(4L, "발베니", "Balvenie"), + getFixturePopulars(5L, "라프로익", "Laphroaig")); + + when(alcoholPopularService.getPopularByViewsWeekly(anyInt(), any())).thenReturn(populars); + + // when & then + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/popular/view/week").param("top", "5")) + .andExpect(status().isOk()) + .andDo( + document( + "alcohols/populars/view/week", + queryParameters( + parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data.totalCount").description("조회된 위스키 개수"), + fieldWithPath("data.alcohols").description("주간 조회수 기반 인기 위스키 리스트"), + fieldWithPath("data.alcohols[].alcoholId").description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").description("위스키 한글명"), + fieldWithPath("data.alcohols[].engName").description("위스키 영문명"), + fieldWithPath("data.alcohols[].rating").description("평균 평점"), + fieldWithPath("data.alcohols[].ratingCount").description("평점 참여자 수"), + fieldWithPath("data.alcohols[].korCategory").description("카테고리 한글명"), + fieldWithPath("data.alcohols[].engCategory").description("카테고리 영문명"), + fieldWithPath("data.alcohols[].imageUrl").description("이미지 URL"), + fieldWithPath("data.alcohols[].isPicked").description("찜 여부"), + fieldWithPath("data.alcohols[].popularScore").description("인기도 점수 (조회수 기반)"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + + verify(alcoholPopularService).getPopularByViewsWeekly(5, -1L); + } + + @Test + @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") + void docs_getPopularViewMonthly() throws Exception { + // given + List populars = + List.of( + getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), + getFixturePopulars(2L, "맥캘란", "Macallan"), + getFixturePopulars(3L, "글렌리벳", "Glenlivet"), + getFixturePopulars(4L, "발베니", "Balvenie"), + getFixturePopulars(5L, "라프로익", "Laphroaig")); + + when(alcoholPopularService.getPopularByViewsMonthly(anyInt(), any())).thenReturn(populars); + + // when & then + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/popular/view/monthly").param("top", "5")) + .andExpect(status().isOk()) + .andDo( + document( + "alcohols/populars/view/monthly", + queryParameters( + parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data.totalCount").description("조회된 위스키 개수"), + fieldWithPath("data.alcohols").description("월간 조회수 기반 인기 위스키 리스트"), + fieldWithPath("data.alcohols[].alcoholId").description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").description("위스키 한글명"), + fieldWithPath("data.alcohols[].engName").description("위스키 영문명"), + fieldWithPath("data.alcohols[].rating").description("평균 평점"), + fieldWithPath("data.alcohols[].ratingCount").description("평점 참여자 수"), + fieldWithPath("data.alcohols[].korCategory").description("카테고리 한글명"), + fieldWithPath("data.alcohols[].engCategory").description("카테고리 영문명"), + fieldWithPath("data.alcohols[].imageUrl").description("이미지 URL"), + fieldWithPath("data.alcohols[].isPicked").description("찜 여부"), + fieldWithPath("data.alcohols[].popularScore").description("인기도 점수 (조회수 기반)"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + + verify(alcoholPopularService).getPopularByViewsMonthly(5, -1L); + } +} diff --git a/bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql b/bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql new file mode 100644 index 000000000..c9b7e4506 --- /dev/null +++ b/bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql @@ -0,0 +1,37 @@ +-- 조회수 기반 인기 주류 테스트 데이터 +-- 이번 주 기준으로 조회 기록 생성 + +-- alcohol_id 1: 5명 조회 (가장 인기) +INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) +VALUES (1, 1, DATE_SUB(NOW(), INTERVAL 1 DAY)), + (2, 1, DATE_SUB(NOW(), INTERVAL 1 DAY)), + (3, 1, DATE_SUB(NOW(), INTERVAL 2 DAY)), + (4, 1, DATE_SUB(NOW(), INTERVAL 2 DAY)), + (5, 1, DATE_SUB(NOW(), INTERVAL 3 DAY)) +ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); + +-- alcohol_id 2: 4명 조회 +INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) +VALUES (1, 2, DATE_SUB(NOW(), INTERVAL 1 DAY)), + (2, 2, DATE_SUB(NOW(), INTERVAL 2 DAY)), + (3, 2, DATE_SUB(NOW(), INTERVAL 3 DAY)), + (4, 2, DATE_SUB(NOW(), INTERVAL 4 DAY)) +ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); + +-- alcohol_id 3: 3명 조회 +INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) +VALUES (1, 3, DATE_SUB(NOW(), INTERVAL 1 DAY)), + (2, 3, DATE_SUB(NOW(), INTERVAL 2 DAY)), + (3, 3, DATE_SUB(NOW(), INTERVAL 3 DAY)) +ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); + +-- alcohol_id 4: 2명 조회 +INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) +VALUES (1, 4, DATE_SUB(NOW(), INTERVAL 1 DAY)), + (2, 4, DATE_SUB(NOW(), INTERVAL 2 DAY)) +ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); + +-- alcohol_id 5: 1명 조회 +INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) +VALUES (1, 5, DATE_SUB(NOW(), INTERVAL 1 DAY)) +ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-rating.sql b/bottlenote-product-api/src/test/resources/init-script/init-rating.sql new file mode 100644 index 000000000..6d10d6997 --- /dev/null +++ b/bottlenote-product-api/src/test/resources/init-script/init-rating.sql @@ -0,0 +1,43 @@ +-- 평점 테스트 데이터 (부족분 채우기용) + +-- alcohol 6~10: 평점 높은 순 (조회수 데이터 없음 -> 평점 기반으로 채워질 대상) +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 6, 5.0, NOW(), NOW()), + (2, 6, 4.5, NOW(), NOW()), + (3, 6, 5.0, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); + +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 7, 4.5, NOW(), NOW()), + (2, 7, 4.5, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); + +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 8, 4.0, NOW(), NOW()), + (2, 8, 4.0, NOW(), NOW()), + (3, 8, 4.5, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); + +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 9, 3.5, NOW(), NOW()), + (2, 9, 4.0, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); + +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 10, 3.0, NOW(), NOW()), + (2, 10, 3.5, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); + +-- alcohol 1~5: 조회수 있는 주류들의 평점 +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 1, 4.0, NOW(), NOW()), + (2, 1, 4.5, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); + +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 2, 3.5, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); + +INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) +VALUES (1, 3, 4.0, NOW(), NOW()) +ON DUPLICATE KEY UPDATE rating = VALUES(rating); From 06e095820de93379b93c3f461fdfcd121e991f05 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 13 Jan 2026 10:56:43 +0000 Subject: [PATCH 36/95] chore: apply code formatting [skip ci] --- .../CustomPopularQueryRepository.java | 4 +- .../CustomPopularQueryRepositoryImpl.java | 165 +++++++++--------- .../AlcoholPopularQueryController.java | 3 +- .../RestPopularViewControllerTest.java | 6 +- 4 files changed, 84 insertions(+), 94 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java index ec8c83cb2..e23300ebf 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepository.java @@ -3,9 +3,7 @@ import app.bottlenote.alcohols.dto.response.PopularItem; import java.util.List; -/** - * 조회수 기반 인기 주류 조회를 위한 QueryDSL Custom Repository - */ +/** 조회수 기반 인기 주류 조회를 위한 QueryDSL Custom Repository */ public interface CustomPopularQueryRepository { List getPopularByViewsWeekly(Long userId, int limit); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java index d503d463b..2356aa1a2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomPopularQueryRepositoryImpl.java @@ -19,9 +19,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; -/** - * 조회수 기반 인기 주류 조회를 위한 QueryDSL 구현체 - */ +/** 조회수 기반 인기 주류 조회를 위한 QueryDSL 구현체 */ @RequiredArgsConstructor public class CustomPopularQueryRepositoryImpl implements CustomPopularQueryRepository { @@ -29,106 +27,103 @@ public class CustomPopularQueryRepositoryImpl implements CustomPopularQueryRepos @Override public List getPopularByViewsWeekly(Long userId, int limit) { - LocalDateTime weekStart = LocalDate.now() - .with(DayOfWeek.MONDAY) - .atStartOfDay(); + LocalDateTime weekStart = LocalDate.now().with(DayOfWeek.MONDAY).atStartOfDay(); return getPopularByViews(userId, limit, weekStart); } @Override public List getPopularByViewsMonthly(Long userId, int limit) { - LocalDateTime monthStart = LocalDate.now() - .withDayOfMonth(1) - .atStartOfDay(); + LocalDateTime monthStart = LocalDate.now().withDayOfMonth(1).atStartOfDay(); return getPopularByViews(userId, limit, monthStart); } private List getPopularByViews(Long userId, int limit, LocalDateTime startDate) { // 1. 조회수 기반 인기 주류 조회 - List viewBasedResults = queryFactory - .select(Projections.constructor( - PopularItem.class, - alcohol.id, - alcohol.korName, - alcohol.engName, - rating.ratingPoint.rating.avg().coalesce(0.0), - rating.id.alcoholId.count(), - alcohol.korCategory, - alcohol.engCategory, - alcohol.imageUrl, - ExpressionUtils.as( - JPAExpressions - .selectOne() - .from(picks) - .where( - picks.alcoholId.eq(alcohol.id), - picks.userId.eq(userId), - picks.status.eq(PicksStatus.PICK)) - .exists(), - "isPicked"), - alcoholsViewHistory.id.alcoholId.count().castToNum(Double.class))) - .from(alcoholsViewHistory) - .join(alcohol).on(alcoholsViewHistory.id.alcoholId.eq(alcohol.id)) - .leftJoin(rating).on(alcohol.id.eq(rating.id.alcoholId)) - .where(alcoholsViewHistory.viewAt.goe(startDate)) - .groupBy( - alcohol.id, - alcohol.korName, - alcohol.engName, - alcohol.korCategory, - alcohol.engCategory, - alcohol.imageUrl) - .orderBy(alcoholsViewHistory.id.alcoholId.count().desc()) - .limit(limit) - .fetch(); + List viewBasedResults = + queryFactory + .select( + Projections.constructor( + PopularItem.class, + alcohol.id, + alcohol.korName, + alcohol.engName, + rating.ratingPoint.rating.avg().coalesce(0.0), + rating.id.alcoholId.count(), + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl, + ExpressionUtils.as( + JPAExpressions.selectOne() + .from(picks) + .where( + picks.alcoholId.eq(alcohol.id), + picks.userId.eq(userId), + picks.status.eq(PicksStatus.PICK)) + .exists(), + "isPicked"), + alcoholsViewHistory.id.alcoholId.count().castToNum(Double.class))) + .from(alcoholsViewHistory) + .join(alcohol) + .on(alcoholsViewHistory.id.alcoholId.eq(alcohol.id)) + .leftJoin(rating) + .on(alcohol.id.eq(rating.id.alcoholId)) + .where(alcoholsViewHistory.viewAt.goe(startDate)) + .groupBy( + alcohol.id, + alcohol.korName, + alcohol.engName, + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl) + .orderBy(alcoholsViewHistory.id.alcoholId.count().desc()) + .limit(limit) + .fetch(); // 2. 부족분은 평점 높은 주류로 채움 if (viewBasedResults.size() < limit) { - List excludeIds = viewBasedResults.stream() - .map(PopularItem::alcoholId) - .toList(); + List excludeIds = viewBasedResults.stream().map(PopularItem::alcoholId).toList(); int remaining = limit - viewBasedResults.size(); - List ratingBasedResults = queryFactory - .select(Projections.constructor( - PopularItem.class, - alcohol.id, - alcohol.korName, - alcohol.engName, - rating.ratingPoint.rating.avg().coalesce(0.0), - rating.id.alcoholId.count(), - alcohol.korCategory, - alcohol.engCategory, - alcohol.imageUrl, - ExpressionUtils.as( - JPAExpressions - .selectOne() - .from(picks) - .where( - picks.alcoholId.eq(alcohol.id), - picks.userId.eq(userId), - picks.status.eq(PicksStatus.PICK)) - .exists(), - "isPicked"), - Expressions.asNumber(0.0))) - .from(alcohol) - .join(rating).on(alcohol.id.eq(rating.id.alcoholId)) - .where(excludeIds.isEmpty() - ? null - : alcohol.id.notIn(excludeIds)) - .groupBy( - alcohol.id, - alcohol.korName, - alcohol.engName, - alcohol.korCategory, - alcohol.engCategory, - alcohol.imageUrl) - .orderBy(rating.ratingPoint.rating.avg().desc()) - .limit(remaining) - .fetch(); + List ratingBasedResults = + queryFactory + .select( + Projections.constructor( + PopularItem.class, + alcohol.id, + alcohol.korName, + alcohol.engName, + rating.ratingPoint.rating.avg().coalesce(0.0), + rating.id.alcoholId.count(), + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl, + ExpressionUtils.as( + JPAExpressions.selectOne() + .from(picks) + .where( + picks.alcoholId.eq(alcohol.id), + picks.userId.eq(userId), + picks.status.eq(PicksStatus.PICK)) + .exists(), + "isPicked"), + Expressions.asNumber(0.0))) + .from(alcohol) + .join(rating) + .on(alcohol.id.eq(rating.id.alcoholId)) + .where(excludeIds.isEmpty() ? null : alcohol.id.notIn(excludeIds)) + .groupBy( + alcohol.id, + alcohol.korName, + alcohol.engName, + alcohol.korCategory, + alcohol.engCategory, + alcohol.imageUrl) + .orderBy(rating.ratingPoint.rating.avg().desc()) + .limit(remaining) + .fetch(); List result = new ArrayList<>(viewBasedResults); result.addAll(ratingBasedResults); diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java index 6b3b127f7..e45378f1b 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholPopularQueryController.java @@ -45,8 +45,7 @@ public ResponseEntity getSpringItems() { /** 주간 조회수 기반 인기 위스키 리스트 조회 */ @GetMapping("/popular/view/week") - public ResponseEntity getPopularByViewsWeekly( - @RequestParam(defaultValue = "20") Integer top) { + public ResponseEntity getPopularByViewsWeekly(@RequestParam(defaultValue = "20") Integer top) { Long userId = getUserIdByContext().orElse(-1L); var populars = alcoholPopularService.getPopularByViewsWeekly(top, userId); var response = PopularsOfWeekResponse.of(populars.size(), populars); diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java index 0439b1735..abec56aa8 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java @@ -53,8 +53,7 @@ void docs_getPopularViewWeekly() throws Exception { .andDo( document( "alcohols/populars/view/week", - queryParameters( - parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), + queryParameters(parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), responseFields( fieldWithPath("success").description("응답 성공 여부"), fieldWithPath("code").description("응답 코드(http status code)"), @@ -100,8 +99,7 @@ void docs_getPopularViewMonthly() throws Exception { .andDo( document( "alcohols/populars/view/monthly", - queryParameters( - parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), + queryParameters(parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), responseFields( fieldWithPath("success").description("응답 성공 여부"), fieldWithPath("code").description("응답 코드(http status code)"), From 262858293c44d03d37e4f34e05fd3a72f5c1d8ed Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 14 Jan 2026 10:21:42 +0900 Subject: [PATCH 37/95] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alcohols/fixture/AlcoholTestFactory.java | 51 ++ .../AlcoholsViewHistoryTestFactory.java | 52 ++ .../AlcoholQueryControllerTest.java | 135 --- .../integration/PopularIntegrationTest.java | 188 +++++ .../controller/FollowControllerTest.java | 80 -- .../FollowUpdateControllerTest.java | 187 ----- .../controller/UserHistoryControllerTest.java | 3 - .../PicksCommandControllerTest.java | 3 - .../controller/RatingControllerTest.java | 167 ---- .../controller/ReviewControllerTest.java | 767 ------------------ .../controller/ReviewReplyControllerTest.java | 222 ----- .../controller/HelpCommandControllerTest.java | 386 --------- .../ReportCommandControllerTest.java | 188 ----- .../user/controller/OauthControllerTest.java | 226 ------ .../controller/UserBasicControllerTest.java | 101 --- ...UserProfileImagesChangeControllerTest.java | 87 -- .../RestAlcoholReferenceControllerTest.java} | 5 +- ....java => RestReferenceControllerTest.java} | 0 .../RestTastingTagControllerTest.java} | 5 +- git.environment-variables | 2 +- 20 files changed, 298 insertions(+), 2557 deletions(-) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/history/fixture/AlcoholsViewHistoryTestFactory.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholQueryControllerTest.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowUpdateControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/history/controller/UserHistoryControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/picks/controller/PicksCommandControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/rating/controller/RatingControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewReplyControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/support/help/controller/HelpCommandControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/support/report/controller/ReportCommandControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserBasicControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserProfileImagesChangeControllerTest.java rename bottlenote-product-api/src/test/java/app/{bottlenote/alcohols/controller/AlcoholReferenceControllerRestDocsTest.java => docs/alcohols/RestAlcoholReferenceControllerTest.java} (98%) rename bottlenote-product-api/src/test/java/app/docs/alcohols/{RestRegionControllerTest.java => RestReferenceControllerTest.java} (100%) rename bottlenote-product-api/src/test/java/app/{bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java => docs/alcohols/RestTastingTagControllerTest.java} (95%) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java index fffb831f3..f2399b803 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java @@ -6,10 +6,13 @@ import app.bottlenote.alcohols.domain.AlcoholsTastingTags; import app.bottlenote.alcohols.domain.CurationKeyword; import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.PopularAlcohol; import app.bottlenote.alcohols.domain.Region; import app.bottlenote.alcohols.domain.TastingTag; import jakarta.persistence.EntityManager; +import java.math.BigDecimal; import java.security.SecureRandom; +import java.time.LocalDate; import java.util.HashSet; import java.util.List; import java.util.Random; @@ -455,4 +458,52 @@ public CurationKeyword persistCurationKeyword( em.flush(); return curation; } + + /** 기본 PopularAlcohol 생성 (오늘 날짜 기준) */ + @Transactional + @NotNull + public PopularAlcohol persistPopularAlcohol( + @NotNull Long alcoholId, @NotNull BigDecimal popularScore) { + LocalDate today = LocalDate.now(); + PopularAlcohol popularAlcohol = + PopularAlcohol.builder() + .alcoholId(alcoholId) + .year(today.getYear()) + .month(today.getMonthValue()) + .day(today.getDayOfMonth()) + .reviewScore(BigDecimal.ZERO) + .ratingScore(BigDecimal.ZERO) + .pickScore(BigDecimal.ZERO) + .popularScore(popularScore) + .build(); + em.persist(popularAlcohol); + em.flush(); + return popularAlcohol; + } + + /** 상세 점수와 함께 PopularAlcohol 생성 */ + @Transactional + @NotNull + public PopularAlcohol persistPopularAlcohol( + @NotNull Long alcoholId, + @NotNull LocalDate date, + @NotNull BigDecimal reviewScore, + @NotNull BigDecimal ratingScore, + @NotNull BigDecimal pickScore, + @NotNull BigDecimal popularScore) { + PopularAlcohol popularAlcohol = + PopularAlcohol.builder() + .alcoholId(alcoholId) + .year(date.getYear()) + .month(date.getMonthValue()) + .day(date.getDayOfMonth()) + .reviewScore(reviewScore) + .ratingScore(ratingScore) + .pickScore(pickScore) + .popularScore(popularScore) + .build(); + em.persist(popularAlcohol); + em.flush(); + return popularAlcohol; + } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/history/fixture/AlcoholsViewHistoryTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/history/fixture/AlcoholsViewHistoryTestFactory.java new file mode 100644 index 000000000..9b496b8c7 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/history/fixture/AlcoholsViewHistoryTestFactory.java @@ -0,0 +1,52 @@ +package app.bottlenote.history.fixture; + +import app.bottlenote.history.domain.AlcoholsViewHistory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class AlcoholsViewHistoryTestFactory { + + @PersistenceContext private EntityManager em; + + @Transactional + @NotNull + public AlcoholsViewHistory persistAlcoholsViewHistory( + @NotNull Long userId, @NotNull Long alcoholId, @NotNull LocalDateTime viewAt) { + AlcoholsViewHistory history = AlcoholsViewHistory.of(userId, alcoholId, viewAt); + em.persist(history); + em.flush(); + return history; + } + + @Transactional + @NotNull + public AlcoholsViewHistory persistAlcoholsViewHistory( + @NotNull Long userId, @NotNull Long alcoholId) { + return persistAlcoholsViewHistory(userId, alcoholId, LocalDateTime.now()); + } + + @Transactional + @NotNull + public List persistAlcoholsViewHistories( + @NotNull Long userId, @NotNull List alcoholIds, @NotNull LocalDateTime viewAt) { + List histories = new ArrayList<>(); + for (Long alcoholId : alcoholIds) { + histories.add(persistAlcoholsViewHistory(userId, alcoholId, viewAt)); + } + return histories; + } + + @Transactional + @NotNull + public List persistAlcoholsViewHistories( + @NotNull Long userId, @NotNull List alcoholIds) { + return persistAlcoholsViewHistories(userId, alcoholIds, LocalDateTime.now()); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholQueryControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholQueryControllerTest.java deleted file mode 100644 index e27eb6fa8..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholQueryControllerTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package app.bottlenote.alcohols.controller; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.alcohols.dto.request.AlcoholSearchRequest; -import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; -import app.bottlenote.alcohols.fixture.AlcoholQueryFixture; -import app.bottlenote.alcohols.service.AlcoholQueryService; -import app.bottlenote.alcohols.service.AlcoholReferenceService; -import app.bottlenote.global.service.cursor.PageResponse; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -@WithMockUser() -@Tag("unit") -@ActiveProfiles("test") -@DisplayName("[unit] [controller] AlcoholQuery") -@WebMvcTest(AlcoholQueryController.class) -class AlcoholQueryControllerTest { - private static final Logger log = LogManager.getLogger(AlcoholQueryControllerTest.class); - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - @MockitoBean private AlcoholQueryService alcoholQueryService; - @MockitoBean private AlcoholReferenceService alcoholReferenceService; - - @DisplayName("술 목록을 조회할 수 있다.") - @ParameterizedTest(name = "{0}") - @MethodSource("app.bottlenote.alcohols.fixture.ArgumentsFixture#testCase1Provider") - void test_case_1(String description, AlcoholSearchRequest searchRequest) throws Exception { - log.debug("description test : {}", description); - // given - PageResponse response = AlcoholQueryFixture.getResponse(); - - // when - when(alcoholQueryService.searchAlcohols(any(), any())).thenReturn(response); - - // then - ResultActions resultActions = - mockMvc - .perform( - get("/api/v1/alcohols/search") - .param("keyword", searchRequest.keyword()) - .param( - "category", - searchRequest.category() == null ? null : searchRequest.category().name()) - .param( - "regionId", - searchRequest.regionId() == null - ? null - : String.valueOf(searchRequest.regionId())) - .param("sortType", searchRequest.sortType().name()) - .param("sortOrder", searchRequest.sortOrder().name()) - .param("cursor", String.valueOf(searchRequest.cursor())) - .param("pageSize", String.valueOf(searchRequest.pageSize())) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.totalCount").value(5)); - resultActions.andExpect(jsonPath("$.data.alcohols.size()").value(3)); - resultActions.andExpect(jsonPath("$.data.alcohols[0].alcoholId").value(5)); - resultActions.andExpect(jsonPath("$.data.alcohols[0].korName").value("아녹 24년")); - resultActions.andExpect(jsonPath("$.data.alcohols[0].engName").value("anCnoc 24-year-old")); - } - - @DisplayName("다양한 정렬 타입으로 술 목록을 조회할 수 있다.") - @ParameterizedTest(name = "{0}") - @MethodSource("app.bottlenote.alcohols.fixture.ArgumentsFixture#sortTypeParameters") - void test_sortType(String sortType, int expectedStatus) throws Exception { - // given - PageResponse response = AlcoholQueryFixture.getResponse(); - - // when - when(alcoholQueryService.searchAlcohols(any(), any())).thenReturn(response); - - mockMvc - .perform( - get("/api/v1/alcohols/search") - .param("keyword", "") - .param("category", "") - .param("regionId", "") - .param("sortType", sortType) - .param("sortOrder", "DESC") - .param("cursor", "") - .param("pageSize", "") - .with(csrf())) - .andExpect(status().is(expectedStatus)) - .andDo(print()); - } - - @DisplayName("다양한 정렬 방향으로 술 목록을 조회할 수 있다.") - @ParameterizedTest(name = "{0}") - @MethodSource("app.bottlenote.alcohols.fixture.ArgumentsFixture#sortOrderParameters") - void test_sortOrder(String sortOrder, int expectedStatus) throws Exception { - // given - PageResponse response = AlcoholQueryFixture.getResponse(); - - // when - when(alcoholQueryService.searchAlcohols(any(), any())).thenReturn(response); - - mockMvc - .perform( - get("/api/v1/alcohols/search") - .param("keyword", "") - .param("category", "") - .param("regionId", "") - .param("sortType", "REVIEW") - .param("sortOrder", sortOrder) - .param("cursor", "") - .param("pageSize", "") - .with(csrf())) - .andExpect(status().is(expectedStatus)) - .andDo(print()); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java new file mode 100644 index 000000000..b55be2efc --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java @@ -0,0 +1,188 @@ +package app.bottlenote.alcohols.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; + +import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.dto.response.PopularsResponse; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; +import app.bottlenote.history.fixture.AlcoholsViewHistoryTestFactory; +import app.bottlenote.rating.fixture.RatingTestFactory; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.fixture.UserTestFactory; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +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.test.web.servlet.assertj.MvcTestResult; + +@Tag("integration") +@DisplayName("[integration] Popular API") +class PopularIntegrationTest extends IntegrationTestSupport { + + @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private UserTestFactory userTestFactory; + @Autowired private RatingTestFactory ratingTestFactory; + @Autowired private AlcoholsViewHistoryTestFactory viewHistoryTestFactory; + + @Nested + @DisplayName("주간 인기 API") + class WeeklyPopularApi { + + @Test + @DisplayName("주간 인기 위스키를 조회할 수 있다") + void test_getPopularOfWeek() throws Exception { + // given + List alcohols = alcoholTestFactory.persistAlcohols(5); + for (int i = 0; i < alcohols.size(); i++) { + alcoholTestFactory.persistPopularAlcohol( + alcohols.get(i).getId(), BigDecimal.valueOf(0.5 - i * 0.1)); + } + + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/popular/week") + .param("top", "5") + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then + PopularsResponse response = extractData(result, PopularsResponse.class); + assertNotNull(response); + assertEquals(5, response.alcohols().size()); + } + } + + @Nested + @DisplayName("조회수 기반 인기 API") + class ViewBasedPopularApi { + + @Test + @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") + void test_getPopularViewWeekly() throws Exception { + // given + List alcohols = alcoholTestFactory.persistAlcohols(5); + List users = createUsers(5); + + // 조회수 데이터 생성 (alcohol별로 다른 조회수) + LocalDateTime now = LocalDateTime.now(); + for (int i = 0; i < alcohols.size(); i++) { + Alcohol alcohol = alcohols.get(i); + int viewCount = 5 - i; + for (int j = 0; j < viewCount; j++) { + viewHistoryTestFactory.persistAlcoholsViewHistory( + users.get(j).getId(), alcohol.getId(), now.minusDays(j)); + } + } + + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/popular/view/week") + .param("top", "5") + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then + PopularsResponse response = extractData(result, PopularsResponse.class); + assertNotNull(response); + assertEquals(5, response.alcohols().size()); + assertEquals(5, response.totalCount()); + } + + @Test + @DisplayName("조회 기록이 부족하면 평점 높은 주류로 채워서 반환한다") + void test_getPopularViewWeekly_fillWithRating() throws Exception { + // given + List alcohols = alcoholTestFactory.persistAlcohols(10); + List users = createUsers(5); + + // 조회수 데이터 생성 (5개 주류만) + LocalDateTime now = LocalDateTime.now(); + for (int i = 0; i < 5; i++) { + Alcohol alcohol = alcohols.get(i); + int viewCount = 5 - i; + for (int j = 0; j < viewCount; j++) { + viewHistoryTestFactory.persistAlcoholsViewHistory( + users.get(j).getId(), alcohol.getId(), now.minusDays(j)); + } + } + + // 평점 데이터 생성 (나머지 5개 주류) + for (int i = 5; i < 10; i++) { + ratingTestFactory.persistRating(users.get(0).getId(), alcohols.get(i).getId(), 5); + ratingTestFactory.persistRating(users.get(1).getId(), alcohols.get(i).getId(), 4); + } + + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/popular/view/week") + .param("top", "10") + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then + PopularsResponse response = extractData(result, PopularsResponse.class); + assertNotNull(response); + assertEquals(10, response.totalCount()); + assertTrue(response.alcohols().size() >= 5); + } + + @Test + @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") + void test_getPopularViewMonthly() throws Exception { + // given + List alcohols = alcoholTestFactory.persistAlcohols(5); + List users = createUsers(5); + + // 조회수 데이터 생성 (월간 범위) + LocalDateTime now = LocalDateTime.now(); + for (int i = 0; i < alcohols.size(); i++) { + Alcohol alcohol = alcohols.get(i); + int viewCount = 5 - i; + for (int j = 0; j < viewCount; j++) { + viewHistoryTestFactory.persistAlcoholsViewHistory( + users.get(j).getId(), alcohol.getId(), now.minusDays(j * 7)); + } + } + + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/popular/view/monthly") + .param("top", "5") + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then + PopularsResponse response = extractData(result, PopularsResponse.class); + assertNotNull(response); + assertEquals(5, response.alcohols().size()); + assertEquals(5, response.totalCount()); + } + } + + private List createUsers(int count) { + return java.util.stream.IntStream.range(0, count) + .mapToObj(i -> userTestFactory.persistUser()) + .toList(); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowControllerTest.java deleted file mode 100644 index 7b4abf802..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowControllerTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package app.bottlenote.follow.controller; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.follow.fixture.FollowQueryFixture; -import app.bottlenote.global.service.cursor.PageResponse; -import app.bottlenote.user.controller.FollowController; -import app.bottlenote.user.dto.request.FollowPageableRequest; -import app.bottlenote.user.dto.response.FollowingSearchResponse; -import app.bottlenote.user.service.FollowService; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.stream.Stream; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -@Disabled -@Tag("unit") -@DisplayName("[unit] [controller] FollowController") -@WithMockUser() -@WebMvcTest(FollowController.class) -class FollowControllerTest { - - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - - @MockitoBean private FollowService followService; - - private final FollowQueryFixture followQueryFixture = new FollowQueryFixture(); - - static Stream testCaseProvider() { - return Stream.of( - Arguments.of(1L, 0L, 50L), Arguments.of(2L, 10L, 50L), Arguments.of(3L, 20L, 50L)); - } - - @DisplayName("팔로우 리스트를 조회할 수 있다.") - @ParameterizedTest(name = "[{index}] userId: {0}, cursor: {1}, pageSize: {2}") - @MethodSource("testCaseProvider") - void testFindFollowingList(Long userId, Long cursor, Long pageSize) throws Exception { - // given - PageResponse response = followQueryFixture.getFollowingPageResponse(); - FollowPageableRequest pageableRequest = - FollowPageableRequest.builder().cursor(cursor).pageSize(pageSize).build(); - - // when - when(followService.getFollowingList(any(), any(), any())).thenReturn(response); - - // then - ResultActions resultActions = - mockMvc - .perform( - get("/api/v1/follow/{userId}/relation-list", userId) - .param("type", "FOLLOWING") - .param("cursor", pageableRequest.cursor().toString()) - .param("pageSize", pageableRequest.pageSize().toString()) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.totalCount").value(5)); - resultActions.andExpect(jsonPath("$.data.followList.size()").value(3)); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowUpdateControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowUpdateControllerTest.java deleted file mode 100644 index fd1901682..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/follow/controller/FollowUpdateControllerTest.java +++ /dev/null @@ -1,187 +0,0 @@ -package app.bottlenote.follow.controller; - -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.user.constant.FollowStatus; -import app.bottlenote.user.controller.FollowController; -import app.bottlenote.user.dto.request.FollowUpdateRequest; -import app.bottlenote.user.dto.response.FollowUpdateResponse; -import app.bottlenote.user.exception.FollowException; -import app.bottlenote.user.exception.FollowExceptionCode; -import app.bottlenote.user.service.FollowService; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -@Tag("unit") -@DisplayName("[unit] [controller] FollowUpdateController") -@WebMvcTest(FollowController.class) -@ActiveProfiles("test") -@WithMockUser -class FollowUpdateControllerTest { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper mapper; - @MockitoBean private FollowService followService; - - private MockedStatic mockedSecurityUtil; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(9L)); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @DisplayName("다른 유저를 팔로우 할 수 있다.") - @Test - void test_1() throws Exception { - - // given - FollowUpdateRequest request = new FollowUpdateRequest(1L, FollowStatus.FOLLOWING); - FollowUpdateResponse response = - FollowUpdateResponse.builder() - .status(FollowStatus.FOLLOWING) - .followUserId(1L) - .nickName("nickName") - .imageUrl("imageUrl") - .build(); - - // when - when(followService.updateFollowStatus(request, 9L)).thenReturn(response); - - // then - ResultActions resultActions = - mockMvc - .perform( - post("/api/v1/follow") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.followUserId").value(response.getFollowUserId())); - resultActions.andExpect(jsonPath("$.data.nickName").value(response.getNickName())); - resultActions.andExpect(jsonPath("$.data.imageUrl").value(response.getImageUrl())); - resultActions.andExpect(jsonPath("$.data.message").value(response.getMessage())); - } - - @DisplayName("유저를 언팔로우할 수 있다.") - @Test - void test_2() throws Exception { - // given - FollowUpdateRequest request = new FollowUpdateRequest(1L, FollowStatus.UNFOLLOW); - FollowUpdateResponse response = - FollowUpdateResponse.builder() - .status(FollowStatus.UNFOLLOW) - .followUserId(1L) - .nickName("nickName") - .imageUrl("imageUrl") - .build(); - - // when - when(followService.updateFollowStatus(request, 9L)).thenReturn(response); - - // then - ResultActions resultActions = - mockMvc - .perform( - post("/api/v1/follow") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.followUserId").value(response.getFollowUserId())); - resultActions.andExpect(jsonPath("$.data.nickName").value(response.getNickName())); - resultActions.andExpect(jsonPath("$.data.imageUrl").value(response.getImageUrl())); - resultActions.andExpect(jsonPath("$.data.message").value(response.getMessage())); - } - - @DisplayName("자기 자신을 팔로우할 수 없다.") - @Test - void test_3() throws Exception { - - Error error = Error.of(FollowExceptionCode.CANNOT_FOLLOW_SELF); - - // given - FollowUpdateRequest request = new FollowUpdateRequest(9L, FollowStatus.FOLLOWING); - - // when - when(followService.updateFollowStatus(request, 9L)) - .thenThrow(new FollowException(FollowExceptionCode.CANNOT_FOLLOW_SELF)); - - // then - ResultActions resultActions = - mockMvc - .perform( - post("/api/v1/follow") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))); - resultActions.andExpect(jsonPath("$.errors[0].status").value(error.status().name())); - resultActions.andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @DisplayName("팔로우할 유저가 존재하지 않으면 팔로우할 수 없다.") - @Test - void test_4() throws Exception { - Error error = Error.of(FollowExceptionCode.FOLLOW_NOT_FOUND); - // given - FollowUpdateRequest request = new FollowUpdateRequest(1L, FollowStatus.FOLLOWING); - - // when - when(followService.updateFollowStatus(request, 9L)) - .thenThrow(new FollowException(FollowExceptionCode.FOLLOW_NOT_FOUND)); - - // then - ResultActions resultActions = - mockMvc - .perform( - post("/api/v1/follow") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isNotFound()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))); - resultActions.andExpect(jsonPath("$.errors[0].status").value(error.status().name())); - resultActions.andExpect(jsonPath("$.errors[0].message").value(error.message())); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/history/controller/UserHistoryControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/history/controller/UserHistoryControllerTest.java deleted file mode 100644 index ef08fe120..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/history/controller/UserHistoryControllerTest.java +++ /dev/null @@ -1,3 +0,0 @@ -package app.bottlenote.history.controller; - -class UserHistoryControllerTest {} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/picks/controller/PicksCommandControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/picks/controller/PicksCommandControllerTest.java deleted file mode 100644 index f6f426573..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/picks/controller/PicksCommandControllerTest.java +++ /dev/null @@ -1,3 +0,0 @@ -package app.bottlenote.picks.controller; - -class PicksCommandControllerTest {} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/rating/controller/RatingControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/rating/controller/RatingControllerTest.java deleted file mode 100644 index 2b68c3c6a..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/rating/controller/RatingControllerTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package app.bottlenote.rating.controller; - -import static org.hamcrest.Matchers.hasSize; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.alcohols.domain.Alcohol; -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.exception.custom.code.ValidExceptionCode; -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.rating.domain.Rating; -import app.bottlenote.rating.domain.Rating.RatingId; -import app.bottlenote.rating.domain.RatingPoint; -import app.bottlenote.rating.dto.request.RatingRegisterRequest; -import app.bottlenote.rating.dto.response.RatingRegisterResponse; -import app.bottlenote.rating.service.RatingCommandService; -import app.bottlenote.rating.service.RatingQueryService; -import app.bottlenote.user.domain.User; -import app.bottlenote.user.exception.UserExceptionCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@WithMockUser -@Tag("unit") -@DisplayName("[unit] [controller] RatingController") -@ActiveProfiles("test") -@WebMvcTest(RatingController.class) -class RatingControllerTest { - private final Long userId = 1L; - private final Long alcoholId = 1L; - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - @MockitoBean private RatingQueryService queryService; - @MockitoBean private RatingCommandService commandService; - private MockedStatic mockedSecurityUtil; - private User user; - private Alcohol alcohol; - private Rating rating; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - - user = User.builder().id(userId).build(); - alcohol = Alcohol.builder().id(alcoholId).build(); - rating = - Rating.builder().id(RatingId.is(userId, alcoholId)).ratingPoint(RatingPoint.of(5)).build(); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @Test - @DisplayName("별점을 등록할 수 있다.") - void test_1() throws Exception { - // given - RatingRegisterRequest request = new RatingRegisterRequest(alcoholId, 5.0); - RatingRegisterResponse response = - RatingRegisterResponse.success(rating.getRatingPoint().getRating()); - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(commandService.register(anyLong(), anyLong(), any(RatingPoint.class))) - .thenReturn(response); - - // then - mockMvc - .perform( - post("/api/v1/rating/register") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value("true")) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.rating").value(rating.getRatingPoint().getRating().toString())) - .andExpect( - jsonPath("$.data.message").value(RatingRegisterResponse.Message.SUCCESS.getMessage())); - } - - @Test - @DisplayName("등록 시 유저 정보가 없을 경우 예외를 발생시킨다.") - void test_2() throws Exception { - - Error error = Error.of(UserExceptionCode.REQUIRED_USER_ID); - - // given - RatingRegisterRequest request = new RatingRegisterRequest(alcoholId, 5.0); - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - - // then - mockMvc - .perform( - post("/api/v1/rating/register") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @Test - @DisplayName("별점 등록 시 파라미터가 없는 경우 예외를 발생시킨다.") - void test_3() throws Exception { - // Expected errors - Error ratingError = Error.of(ValidExceptionCode.RATING_REQUIRED); - Error alcoholIdError = Error.of(ValidExceptionCode.ALCOHOL_ID_REQUIRED); - - // given - RatingRegisterRequest request = new RatingRegisterRequest(null, null); - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // then - mockMvc - .perform( - post("/api/v1/rating/register") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errors", hasSize(2))) - .andExpect( - jsonPath("$.errors[?(@.code == 'RATING_REQUIRED')].status") - .value(ratingError.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'RATING_REQUIRED')].message") - .value(ratingError.message())) - .andExpect( - jsonPath("$.errors[?(@.code == 'ALCOHOL_ID_REQUIRED')].status") - .value(alcoholIdError.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'ALCOHOL_ID_REQUIRED')].message") - .value(alcoholIdError.message())); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java deleted file mode 100644 index 0e4b8edb7..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewControllerTest.java +++ /dev/null @@ -1,767 +0,0 @@ -package app.bottlenote.review.controller; - -import static app.bottlenote.review.exception.ReviewExceptionCode.REVIEW_NOT_FOUND; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.description; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.exception.custom.code.ValidExceptionCode; -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.global.security.jwt.CustomJwtException; -import app.bottlenote.global.security.jwt.CustomJwtExceptionCode; -import app.bottlenote.global.security.jwt.JwtExceptionType; -import app.bottlenote.global.service.cursor.PageResponse; -import app.bottlenote.global.service.cursor.SortOrder; -import app.bottlenote.review.constant.ReviewDisplayStatus; -import app.bottlenote.review.constant.ReviewResultMessage; -import app.bottlenote.review.constant.ReviewSortType; -import app.bottlenote.review.dto.request.LocationInfoRequest; -import app.bottlenote.review.dto.request.ReviewCreateRequest; -import app.bottlenote.review.dto.request.ReviewImageInfoRequest; -import app.bottlenote.review.dto.request.ReviewModifyRequest; -import app.bottlenote.review.dto.request.ReviewPageableRequest; -import app.bottlenote.review.dto.request.ReviewStatusChangeRequest; -import app.bottlenote.review.dto.response.ReviewCreateResponse; -import app.bottlenote.review.dto.response.ReviewDetailResponse; -import app.bottlenote.review.dto.response.ReviewListResponse; -import app.bottlenote.review.dto.response.ReviewResultResponse; -import app.bottlenote.review.exception.ReviewException; -import app.bottlenote.review.fixture.ReviewObjectFixture; -import app.bottlenote.review.service.ReviewService; -import app.bottlenote.support.block.service.BlockService; -import app.bottlenote.user.exception.UserExceptionCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.MalformedJwtException; -import java.math.BigDecimal; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; -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.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -@Tag("unit") -@ActiveProfiles("test") -@DisplayName("[unit] ReviewController") -@WithMockUser -@WebMvcTest(ReviewController.class) -class ReviewControllerTest { - - private final ReviewCreateRequest reviewCreateRequest = - ReviewObjectFixture.getReviewCreateRequest(); - private final ReviewCreateResponse reviewCreateResponse = - ReviewObjectFixture.getReviewCreateResponse(); - private final ReviewModifyRequest reviewModifyRequest = - ReviewObjectFixture.getReviewModifyRequest(ReviewDisplayStatus.PUBLIC); - private final ReviewModifyRequest nullableReviewModifyRequest = - ReviewObjectFixture.getNullableReviewModifyRequest(ReviewDisplayStatus.PRIVATE); - private final ReviewModifyRequest wrongReviewModifyRequest = - ReviewObjectFixture.getWrongReviewModifyRequest(); - private final PageResponse reviewListResponse = - ReviewObjectFixture.getReviewListResponse(); - private final ReviewDetailResponse reviewDetailResponse = - ReviewObjectFixture.getReviewDetailResponse(); - private final ReviewStatusChangeRequest reviewStatusChangeRequest = - new ReviewStatusChangeRequest(ReviewDisplayStatus.PRIVATE); - private final ReviewResultResponse reviewStatusChangeResponse = - ReviewResultResponse.response(ReviewResultMessage.PRIVATE_SUCCESS, 1L); - private final Long reviewId = 1L; - private final Long userId = 1L; - - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - @MockitoBean private ReviewService reviewService; - @MockitoBean private BlockService blockService; - - private MockedStatic mockedSecurityUtil; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - // BlockService mock 설정 - 차단되지 않은 상태로 설정 - when(blockService.isBlocked(any(), any())).thenReturn(false); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @Nested - @DisplayName("리뷰 등록 컨트롤러 테스트") - class ReviewCreateControllerTest { - - @DisplayName("리뷰를 등록할 수 있다.") - @Test - void create_review_test() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.createReview(any(), anyLong())).thenReturn(reviewCreateResponse); - - mockMvc - .perform( - post("/api/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewCreateRequest)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.content").value("맛있어요")); - } - - @DisplayName("리뷰 등록에 실패한다.") - @Test - void create_review_fail_test() throws Exception { - - ReviewCreateRequest wrongRequest = - new ReviewCreateRequest( - 1L, - ReviewDisplayStatus.PUBLIC, - "맛있어요", - null, - new BigDecimal("30000.0"), - new LocationInfoRequest( - "xxPub", - "12345", - "서울시 강남구 청담동", - "xx빌딩", - "PUB", - "https://map.naver.com", - "111.111", - "222.222"), - List.of( - new ReviewImageInfoRequest(1L, "url1"), - new ReviewImageInfoRequest(2L, "url2"), - new ReviewImageInfoRequest(3L, "url3"), - new ReviewImageInfoRequest(4L, "url4"), - new ReviewImageInfoRequest(5L, "url5"), - new ReviewImageInfoRequest(6L, "url6")), - List.of("테이스팅태그", "테이스팅태그 2", "테이스팅태그 3"), - 0.5); - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(1L)); - - when(reviewService.createReview(any(), anyLong())) - .thenThrow(HttpMessageNotReadableException.class); - - mockMvc - .perform( - post("/api/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(wrongRequest)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()); - } - } - - @Nested - @DisplayName("리뷰 조회 컨트롤러 테스트") - class ReviewReadControllerTest { - - static Stream testCase1Provider() { - return Stream.of( - Arguments.of( - "모든 요청 파라미터가 존재할 때.", - ReviewPageableRequest.builder() - .sortType(ReviewSortType.POPULAR) - .sortOrder(SortOrder.DESC) - .cursor(1L) - .pageSize(2L) - .build()), - Arguments.of( - "정렬 정보가 없을 때.", - ReviewPageableRequest.builder() - .sortType(null) - .sortOrder(null) - .cursor(0L) - .pageSize(3L) - .build()), - Arguments.of( - "페이지네이션 정보가 없을 때.", - ReviewPageableRequest.builder() - .sortType(null) - .sortOrder(null) - .cursor(null) - .pageSize(null) - .build())); - } - - static Stream sortOrderParameters() { - return Stream.of( - // 성공 케이스 - Arguments.of("ASC", 200), - Arguments.of("DESC", 200), - // 실패 케이스 - Arguments.of("DESCCC", 400), - Arguments.of("ASCC", 400)); - } - - static Stream sortTypeParameters() { - return Stream.of( - // 성공 케이스 - - Arguments.of("POPULAR", 200), - Arguments.of("RATING", 200), - // 실패 케이스 - - Arguments.of("Popular", 400), - Arguments.of("Rating", 400)); - } - - @DisplayName("리뷰를 조회할 수 있다.") - @ParameterizedTest(name = "[{index}]{0}") - @MethodSource("testCase1Provider") - void test_case_1(String description, ReviewPageableRequest reviewPageableRequest) - throws Exception { - - // given - - // when - when(reviewService.getReviews(any(), any(), any())).thenReturn(reviewListResponse); - - ResultActions resultActions = - mockMvc - .perform( - get("/api/v1/reviews/1") - .param("sortType", String.valueOf(reviewPageableRequest.sortType())) - .param("sortOrder", reviewPageableRequest.sortOrder().name()) - .param("cursor", String.valueOf(reviewPageableRequest.cursor())) - .param("pageSize", String.valueOf(reviewPageableRequest.pageSize())) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.totalCount").value(2)); - resultActions.andExpect(jsonPath("$.data.reviewList[0].reviewId").value(1)); - resultActions.andExpect(jsonPath("$.data.reviewList[0].reviewContent").value("맛있어요")); - } - - @DisplayName("정렬 타입에 대한 검증") - @ParameterizedTest(name = "{1} : {0}") - @MethodSource("sortTypeParameters") - void test_sortType(String sortType, int expectedStatus) throws Exception { - // given - - // when - when(reviewService.getReviews(any(), any(), any())).thenReturn(reviewListResponse); - - mockMvc - .perform( - RestDocumentationRequestBuilders.get("/api/v1/reviews/1") - .param("keyword", "") - .param("category", "") - .param("regionId", "") - .param("sortType", sortType) - .param("sortOrder", "DESC") - .param("cursor", "") - .param("pageSize", "") - .with(csrf())) - .andExpect(status().is(expectedStatus)) - .andDo(print()); - } - - @DisplayName("정렬 방향에 대한 검증") - @ParameterizedTest(name = "{1} : {0}") - @MethodSource("sortOrderParameters") - void test_sortOrder(String sortOrder, int expectedStatus) throws Exception { - // given - - // when - when(reviewService.getReviews(any(), any(), any())).thenReturn(reviewListResponse); - - mockMvc - .perform( - RestDocumentationRequestBuilders.get("/api/v1/reviews/1") - .param("category", "") - .param("regionId", "") - .param("sortType", "POPULAR") - .param("sortOrder", sortOrder) - .param("cursor", "") - .param("pageSize", "") - .with(csrf())) - .andExpect(status().is(expectedStatus)) - .andDo(print()); - } - - @DisplayName("리뷰를 조회할 수 있다.") - @ParameterizedTest(name = "[{index}]{0}") - @MethodSource("testCase1Provider") - void my_review_read_success(String description, ReviewPageableRequest reviewPageableRequest) - throws Exception { - - // given - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(reviewService.getMyReviews(any(), any(), any())).thenReturn(reviewListResponse); - - ResultActions resultActions = - mockMvc - .perform( - get("/api/v1/reviews/me/1") - .param("sortType", String.valueOf(reviewPageableRequest.sortType())) - .param("sortOrder", reviewPageableRequest.sortOrder().name()) - .param("cursor", String.valueOf(reviewPageableRequest.cursor())) - .param("pageSize", String.valueOf(reviewPageableRequest.pageSize())) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.totalCount").value(2)); - resultActions.andExpect(jsonPath("$.data.reviewList[0].reviewId").value(1)); - resultActions.andExpect(jsonPath("$.data.reviewList[0].reviewContent").value("맛있어요")); - } - - @Test - @DisplayName("유저 정보가 없을 경우에는 예외를 반환한다..") - void test_fail_when_no_auth_info() throws Exception { - - // given - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - when(reviewService.getMyReviews(any(), any(), any())).thenReturn(reviewListResponse); - - mockMvc - .perform(get("/api/v1/reviews/me/1").contentType(MediaType.APPLICATION_JSON).with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - } - - @Test - @DisplayName("Authorization Header가 Null일 경우에는 예외를 반환한다.") - void test_fail_when_authorization_header_is_null() throws Exception { - - // given - Error error = Error.of(CustomJwtExceptionCode.EMPTY_JWT_TOKEN); - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(reviewService.getMyReviews(any(), any(), any())) - .thenThrow(new CustomJwtException(CustomJwtExceptionCode.EMPTY_JWT_TOKEN)); - - // then - mockMvc - .perform(get("/api/v1/reviews/me/1").contentType(MediaType.APPLICATION_JSON).with(csrf())) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect( - jsonPath("$.errors[0].status").value(error.status().getReasonPhrase().toUpperCase())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())) - .andDo(print()); - } - - @Test - @DisplayName("토큰이 잘못된 토큰일 경우 예외를 반환한다.") - void test_fail_when_token_is_wrong() throws Exception { - Error error = Error.of(JwtExceptionType.MALFORMED_TOKEN); - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(reviewService.getMyReviews(any(), any(), any())) - .thenThrow(new MalformedJwtException(JwtExceptionType.MALFORMED_TOKEN.getMessage())); - - // then - mockMvc - .perform(get("/api/v1/reviews/me/1").contentType(MediaType.APPLICATION_JSON).with(csrf())) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect( - jsonPath("$.errors[0].status").value(error.status().getReasonPhrase().toUpperCase())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())) - .andDo(print()); - } - - @Test - @DisplayName("토큰이 만료 된 토큰일 경우 예외를 반환한다.") - void test_fail_when_token_is_expired() throws Exception { - - Error error = Error.of(JwtExceptionType.EXPIRED_TOKEN); - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.getMyReviews(any(), any(), any())) - .thenThrow( - new ExpiredJwtException(null, null, JwtExceptionType.EXPIRED_TOKEN.getMessage())); - - // then - mockMvc - .perform(get("/api/v1/reviews/me/1").contentType(MediaType.APPLICATION_JSON).with(csrf())) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.errors[0].message").value(error.message())) - .andDo(print()); - } - - @DisplayName("리뷰를 상세 조회할 수 있다.") - @Test - void detail_read_review_success() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.getDetailReview(reviewId, userId)).thenReturn(reviewDetailResponse); - - mockMvc - .perform( - get("/api/v1/reviews/detail/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(jsonPath("$.success").value("true")) - .andExpect(jsonPath("$.code").value("200")) - .andExpect( - jsonPath("$.data.alcoholInfo.alcoholId") - .value(reviewDetailResponse.alcoholInfo().alcoholId())); - - verify(reviewService, description("getDetailReview 메서드가 정상적으로 호출됨")) - .getDetailReview(reviewId, userId); - } - - @DisplayName("리뷰가 존재하지 않으면 상세 조회에 실패한다.") - @Test - void detail_read_review_fail_when_review_not_exist() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.getDetailReview(anyLong(), anyLong())) - .thenThrow(new ReviewException(REVIEW_NOT_FOUND)); - - mockMvc - .perform( - get("/api/v1/reviews/detail/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isBadRequest()); - } - } - - @Nested - @DisplayName("리뷰 수정 컨트롤러 테스트") - class ReviewModifyControllerTest { - - private final ReviewResultResponse response = - ReviewResultResponse.response(ReviewResultMessage.MODIFY_SUCCESS, 1L); - - @DisplayName("리뷰를 수정할 수 있다.") - @Test - void modify_review_success() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.modifyReview(reviewModifyRequest, reviewId, userId)).thenReturn(response); - - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewModifyRequest)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - verify(reviewService, description("modifyReviews 메서드가 정상적으로 호출됨")) - .modifyReview(any(ReviewModifyRequest.class), anyLong(), anyLong()); - } - - @DisplayName("로그인하지 않은 유저는 리뷰를 수정할 수 없다.") - @Test - void modify_review_fail_unauthorized_user() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - - when(reviewService.modifyReview(reviewModifyRequest, reviewId, userId)).thenReturn(response); - - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewModifyRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - - // modifyReview 메서드가 호출되지 않음 - verify(reviewService, never()) - .modifyReview(any(ReviewModifyRequest.class), anyLong(), anyLong()); - } - - @DisplayName("존재하지 않은 리뷰를 수정할 수 없다.") - @Test - void modify_review_fail_not_exist_review() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.modifyReview(reviewModifyRequest, reviewId, userId)) - .thenThrow(new ReviewException(REVIEW_NOT_FOUND)); - - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewModifyRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - - verify(reviewService, description("modifyReviews 메서드가 정상적으로 호출됨")) - .modifyReview(any(ReviewModifyRequest.class), anyLong(), anyLong()); - } - - @DisplayName("status와 content를 제외한 필드가 null 인 경우 리뷰 수정이 가능하다.") - @Test - void modify_review_fail_when_request_body_has_null() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.modifyReview(nullableReviewModifyRequest, reviewId, userId)) - .thenReturn(response); - - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(nullableReviewModifyRequest)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - } - - @DisplayName("Not Null인 필드가 null인 경우 리뷰를 수정할 수 없다.") - @Test - void modify_review_fail_when_null_in_not_nullable_field() throws Exception { - // given - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.modifyReview(wrongReviewModifyRequest, reviewId, userId)) - .thenReturn(response); - - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(wrongReviewModifyRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - // then - } - } - - @Nested - @DisplayName("리뷰 삭제 컨트롤러 테스트") - class ReviewDeleteControllerTest { - - private final ReviewResultResponse response = - ReviewResultResponse.response(ReviewResultMessage.DELETE_SUCCESS, reviewId); - - @DisplayName("리뷰를 삭제할 수 있다") - @Test - void delete_review_success() throws Exception { - - String codeMessage = "DELETE_SUCCESS"; - String message = "리뷰 삭제가 성공적으로 완료되었습니다."; - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.deleteReview(reviewId, userId)).thenReturn(response); - - mockMvc - .perform( - delete("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(jsonPath("$.success").value("true")) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.codeMessage").value(codeMessage)) - .andExpect(jsonPath("$.data.message").value(message)) - .andExpect(jsonPath("$.data.reviewId").value(reviewId)); - - verify(reviewService, description("deleteReview 메서드가 정상적으로 호출됨")) - .deleteReview(anyLong(), anyLong()); - } - - @DisplayName("로그인하지 않은 유저는 리뷰를 삭제할 수 없다.") - @Test - void delete_review_fail_unauthorized_user() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - - when(reviewService.deleteReview(reviewId, userId)).thenReturn(response); - - mockMvc - .perform( - delete("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - } - - @DisplayName("존재하지 않은 리뷰를 삭제할 수 없다.") - @Test - void delete_review_fail_not_exist_review() throws Exception { - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - when(reviewService.deleteReview(reviewId, userId)) - .thenThrow(new ReviewException(REVIEW_NOT_FOUND)); - - mockMvc - .perform( - delete("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - - verify(reviewService, description("deleteReview 메서드가 정상적으로 호출됨")) - .deleteReview(anyLong(), anyLong()); - } - } - - @Nested - @DisplayName("리뷰 상태변경 컨트롤러 테스트") - class ReviewStatusChangeControllerTest { - - @DisplayName("리뷰 상태를 변경할 수 있다.") - @Test - void update_review_status_change() throws Exception { - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(reviewService.changeStatus(1L, reviewStatusChangeRequest, 1L)) - .thenReturn(reviewStatusChangeResponse); - - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}/display", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewStatusChangeRequest)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - // then - verify(reviewService, times(1)).changeStatus(anyLong(), any(), anyLong()); - } - - @DisplayName("로그인하지 않은 유저는 리뷰의 상태를 바꿀 수 없다.") - @Test - void fail_when_user_is_unauthorized() throws Exception { - - Error error = Error.of(UserExceptionCode.REQUIRED_USER_ID); - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - - when(reviewService.deleteReview(reviewId, userId)).thenReturn(reviewStatusChangeResponse); - - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}/display", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewStatusChangeRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @DisplayName("리뷰 작성자가 아니면 리뷰 상태를 바꿀 수 없다.") - @Test - void fail_when_user_is_not_review_owner() throws Exception { - - Error error = Error.of(REVIEW_NOT_FOUND); - // given - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(reviewService.changeStatus(1L, reviewStatusChangeRequest, 1L)) - .thenThrow(new ReviewException(REVIEW_NOT_FOUND)); - - // when - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}/display", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewStatusChangeRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @DisplayName("리뷰 상태 변경 요청이 null이면 리뷰 상태를 바꿀 수 없다.") - @Test - void fail_when_request_is_null() throws Exception { - - Error error = Error.of(ValidExceptionCode.REVIEW_DISPLAY_STATUS_NOT_EMPTY); - // given - ReviewStatusChangeRequest nullRequest = new ReviewStatusChangeRequest(null); - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - when(reviewService.changeStatus(1L, reviewStatusChangeRequest, 1L)) - .thenReturn(reviewStatusChangeResponse); - - // when - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}/display", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(nullRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewReplyControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewReplyControllerTest.java deleted file mode 100644 index 65e797b9b..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/controller/ReviewReplyControllerTest.java +++ /dev/null @@ -1,222 +0,0 @@ -package app.bottlenote.review.controller; - -import static app.bottlenote.review.fixture.ReviewReplyObjectFixture.getDeleteReviewReplyResponse; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.exception.custom.code.ValidExceptionCode; -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.review.constant.ReviewReplyResultMessage; -import app.bottlenote.review.exception.ReviewException; -import app.bottlenote.review.exception.ReviewExceptionCode; -import app.bottlenote.review.fixture.ReviewReplyObjectFixture; -import app.bottlenote.review.service.ReviewReplyService; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Optional; -import org.apache.commons.lang3.RandomStringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.junit.jupiter.api.AfterEach; -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.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@Tag("unit") -@DisplayName("[unit] [controller] ReviewReplyController") -@WebMvcTest(ReviewReplyController.class) -@ActiveProfiles("test") -@WithMockUser -class ReviewReplyControllerTest { - - private static final Logger log = LogManager.getLogger(ReviewReplyControllerTest.class); - @Autowired private ObjectMapper mapper; - @Autowired private MockMvc mockMvc; - @MockitoBean private ReviewReplyService reviewReplyService; - private MockedStatic mockedSecurityUtil; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @Nested - @DisplayName("리뷰에 새로운 댓글을 등록할 수 있다.") - class registerReviewReply { - @Test - @DisplayName("새로운 댓글을 등록 할 수 있다.") - void test_1() throws Exception { - final Long reviewId = 1L; - var request = ReviewReplyObjectFixture.getReviewReplyRegisterRequest(); - var response = ReviewReplyObjectFixture.getReviewReplyResponse(); - - mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(1L)); - when(reviewReplyService.registerReviewReply(1L, 1L, request)).thenReturn(response); - - mockMvc - .perform( - post("/api/v1/review/reply/register/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.codeMessage").value("SUCCESS_REGISTER_REPLY")) - .andExpect(jsonPath("$.data.message").value("성공적으로 댓글을 등록했습니다.")) - .andExpect(jsonPath("$.data.reviewId").value("1")); - } - - @Test - @DisplayName("댓글 내용이 없는 경우 예외가 반환된다.") - void test_2() throws Exception { - - Error error = Error.of(ValidExceptionCode.REQUIRED_REVIEW_REPLY_CONTENT); - - final Long reviewId = 1L; - var request = ReviewReplyObjectFixture.getReviewReplyRegisterRequest(null, null); - - mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(1L)); - - mockMvc - .perform( - post("/api/v1/review/reply/register/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @Test - @DisplayName("댓글 내용이 500자를 초과하는 경우 예외가 반환된다.") - void test_3() throws Exception { - - Error error = Error.of(ValidExceptionCode.CONTENT_IS_OUT_OF_RANGE); - final Long reviewId = 1L; - var request = - ReviewReplyObjectFixture.getReviewReplyRegisterRequest( - RandomStringUtils.randomAlphabetic(501), null); - - mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(1L)); - - mockMvc - .perform( - post("/api/v1/review/reply/register/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - } - - @Nested - @DisplayName("리뷰 댓글을 삭제할 수 있다.") - class delete { - - @Test - @DisplayName("댓글을 삭제 할 수 있다.") - void test_1() throws Exception { - final Long reviewId = 1L; - final Long replyId = 1L; - - var response = getDeleteReviewReplyResponse(reviewId); - - mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(1L)); - - when(reviewReplyService.deleteReviewReply(1L, 1L, 1L)).thenReturn(response); - - mockMvc - .perform( - delete("/api/v1/review/reply/{reviewId}/{replyId}", reviewId, replyId).with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect( - jsonPath("$.data.codeMessage") - .value(ReviewReplyResultMessage.SUCCESS_DELETE_REPLY.name())) - .andExpect( - jsonPath("$.data.message") - .value(ReviewReplyResultMessage.SUCCESS_DELETE_REPLY.getMessage())) - .andExpect(jsonPath("$.data.reviewId").value(replyId)) - .andReturn(); - } - - @Test - @DisplayName("본인의 댓글이 아닌 경우 REPLY_NOT_OWNER 예외가 발생한다.") - void test_2() throws Exception { - - Error error = Error.of(ReviewExceptionCode.REPLY_NOT_OWNER); - - final Long reviewId = 1L; - final Long replyId = 1L; - - mockedSecurityUtil - .when(SecurityContextUtil::getUserIdByContext) - .thenReturn(Optional.of(999L)); - when(reviewReplyService.deleteReviewReply(1L, 1L, 999L)) - .thenThrow(new ReviewException(ReviewExceptionCode.REPLY_NOT_OWNER)); - - mockMvc - .perform( - delete("/api/v1/review/reply/{reviewId}/{replyId}", reviewId, replyId).with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())) - .andReturn(); - } - - @Test - @DisplayName("존재하지 않는 댓글인 경우 NOT_FOUND_REVIEW_REPLY 예외가 발생한다.") - void test_3() throws Exception { - Error error = Error.of(ReviewExceptionCode.NOT_FOUND_REVIEW_REPLY); - final Long reviewId = 1L; - final Long replyId = 1L; - - mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(1L)); - when(reviewReplyService.deleteReviewReply(1L, 1L, 1L)) - .thenThrow(new ReviewException(ReviewExceptionCode.NOT_FOUND_REVIEW_REPLY)); - - mockMvc - .perform( - delete("/api/v1/review/reply/{reviewId}/{replyId}", reviewId, replyId).with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())) - .andReturn(); - } - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/help/controller/HelpCommandControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/help/controller/HelpCommandControllerTest.java deleted file mode 100644 index e95193266..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/help/controller/HelpCommandControllerTest.java +++ /dev/null @@ -1,386 +0,0 @@ -package app.bottlenote.support.help.controller; - -import static app.bottlenote.support.help.constant.HelpResultMessage.DELETE_SUCCESS; -import static app.bottlenote.support.help.constant.HelpResultMessage.MODIFY_SUCCESS; -import static app.bottlenote.support.help.constant.HelpResultMessage.REGISTER_SUCCESS; -import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_AUTHORIZED; -import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_FOUND; -import static app.bottlenote.user.exception.UserExceptionCode.REQUIRED_USER_ID; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.global.service.cursor.PageResponse; -import app.bottlenote.support.help.constant.HelpType; -import app.bottlenote.support.help.dto.request.HelpPageableRequest; -import app.bottlenote.support.help.dto.request.HelpUpsertRequest; -import app.bottlenote.support.help.dto.response.HelpListResponse; -import app.bottlenote.support.help.dto.response.HelpResultResponse; -import app.bottlenote.support.help.exception.HelpException; -import app.bottlenote.support.help.fixture.HelpObjectFixture; -import app.bottlenote.support.help.service.HelpService; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -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.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -@Tag("unit") -@ActiveProfiles("test") -@DisplayName("[unit] [controller] HelpCommandController") -@WebMvcTest(HelpCommandController.class) -@WithMockUser -class HelpCommandControllerTest { - - private final HelpUpsertRequest helpUpsertRequest = HelpObjectFixture.getHelpUpsertRequest(); - private final HelpPageableRequest emptyPageableRequest = - HelpObjectFixture.getEmptyHelpPageableRequest(); - private final PageResponse helpPageResponse = - HelpObjectFixture.getHelpListPageResponse(); - private final HelpResultResponse successRegisterResponse = - HelpObjectFixture.getSuccessHelpResponse(REGISTER_SUCCESS); - private final HelpResultResponse successModifyResponse = - HelpObjectFixture.getSuccessHelpResponse(MODIFY_SUCCESS); - private final HelpResultResponse successDeleteResponse = - HelpObjectFixture.getSuccessHelpResponse(DELETE_SUCCESS); - private final Long userId = 1L; - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - @MockitoBean private HelpService helpService; - private MockedStatic mockedSecurityUtil; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @DisplayName("로그인하지 않은 유저는 문의글을 삭제할 수 없다.") - @Test - void test_fail_when_unauthorized_user() throws Exception { - // given - Error error = Error.of(REQUIRED_USER_ID); - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - - when(helpService.deleteHelp(1L, 1L)).thenReturn(successDeleteResponse); - - // then - mockMvc - .perform( - delete("/api/v1/help/{helpId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @Nested - @DisplayName("문의글 등록 컨트롤러 테스트") - class HelpRegisterControllerTest { - - @DisplayName("문의글을 등록할 수 있다.") - @Test - void register_help_test() throws Exception { - - // given - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.registerHelp(helpUpsertRequest, 1L)).thenReturn(successRegisterResponse); - - // then - mockMvc - .perform( - post("/api/v1/help") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(helpUpsertRequest)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.message").value(REGISTER_SUCCESS.getDescription())); - } - - @DisplayName("로그인하지 않은 유저는 문의글을 등록할 수 없다.") - @Test - void test_fail_when_unauthorized_user() throws Exception { - // given - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - - when(helpService.registerHelp(helpUpsertRequest, 1L)).thenReturn(successRegisterResponse); - - // then - mockMvc - .perform( - post("/api/v1/help") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(helpUpsertRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - } - } - - @Nested - @DisplayName("문의글 조회 컨트롤러 테스트") - class HelpReadControllerTest { - - @DisplayName("문의글 목록을 조회할 수 있다.") - @Test - void get_help_list_test() throws Exception { - // given - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.getHelpList(any(HelpPageableRequest.class), anyLong())) - .thenReturn(helpPageResponse); - - // then - ResultActions resultActions = - mockMvc - .perform( - get("/api/v1/help") - .param("cursor", String.valueOf(emptyPageableRequest.cursor())) - .param("pageSize", String.valueOf(emptyPageableRequest.pageSize())) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.totalCount").value(2)); - } - - @DisplayName("문의글을 상세 조회할 수 있다.") - @Test - void get_detail_help_test() throws Exception { - // given - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.getDetailHelp(anyLong(), anyLong())) - .thenReturn(HelpObjectFixture.getDetailHelpInfo("content", HelpType.REVIEW)); - - // then - ResultActions resultActions = - mockMvc - .perform( - get("/api/v1/help/{helpId}", 1L) - .param("cursor", String.valueOf(emptyPageableRequest.cursor())) - .param("pageSize", String.valueOf(emptyPageableRequest.pageSize())) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()); - - resultActions.andExpect(jsonPath("$.success").value("true")); - resultActions.andExpect(jsonPath("$.code").value("200")); - resultActions.andExpect(jsonPath("$.data.helpType").value("REVIEW")); - } - - @DisplayName("로그인 한 유저만 문의글을 조회할 수 있다.") - @Test - void get_help_only_authorized_user() throws Exception { - - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - when(helpService.getDetailHelp(anyLong(), anyLong())) - .thenReturn(HelpObjectFixture.getDetailHelpInfo("content", HelpType.REVIEW)); - - // then - mockMvc - .perform( - get("/api/v1/help/{helpId}", 1L) - .param("cursor", String.valueOf(emptyPageableRequest.cursor())) - .param("pageSize", String.valueOf(emptyPageableRequest.pageSize())) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()); - } - } - - @Nested - @DisplayName("문의글 수정 컨트롤러 테스트") - class HelpUpdateControllerTest { - - @DisplayName("문의글을 수정할 수 있다.") - @Test - void update_help_test() throws Exception { - // given - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.modifyHelp(helpUpsertRequest, 1L, 1L)).thenReturn(successModifyResponse); - - // then - mockMvc - .perform( - patch("/api/v1/help/{helpId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(helpUpsertRequest)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.message").value(MODIFY_SUCCESS.getDescription())); - } - - @DisplayName("자신이 등록한 리뷰만 수정할 수 있다.") - @Test - void fail_update_help_test() throws Exception { - - Error error = Error.of(HELP_NOT_FOUND); - // given - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.modifyHelp(helpUpsertRequest, 1L, 1L)) - .thenThrow(new HelpException(HELP_NOT_FOUND)); - - // then - mockMvc - .perform( - patch("/api/v1/help/{helpId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(helpUpsertRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @DisplayName("로그인하지 않은 유저는 문의글을 수정할 수 없다.") - @Test - void test_fail_when_unauthorized_user() throws Exception { - // given - Error error = Error.of(REQUIRED_USER_ID); - // when - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.empty()); - - when(helpService.registerHelp(helpUpsertRequest, 1L)).thenReturn(successModifyResponse); - - // then - mockMvc - .perform( - patch("/api/v1/help/{helpId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(helpUpsertRequest)) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - } - - @Nested - @DisplayName("문의글 삭제 컨트롤러 테스트") - class HelpDeleteControllerTest { - - @DisplayName("문의글을 삭제할 수 있다.") - @Test - void delete_help_test() throws Exception { - // given - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.deleteHelp(1L, 1L)).thenReturn(successDeleteResponse); - - // then - mockMvc - .perform( - delete("/api/v1/help/{helpId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.message").value(DELETE_SUCCESS.getDescription())); - } - - @DisplayName("존재하지 않는 문의글을 삭제할 수 없다.") - @Test - void test_fail_when_help_is_not_exist() throws Exception { - // given - Error error = Error.of(HELP_NOT_FOUND); - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.deleteHelp(1L, 1L)).thenThrow(new HelpException(HELP_NOT_FOUND)); - - // then - mockMvc - .perform( - delete("/api/v1/help/{helpId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.code").value("400")) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @DisplayName("자신이 등록한 리뷰만 삭제할 수 있다.") - @Test - void test_fail_when_user_is_not_owner() throws Exception { - // given - Error error = Error.of(HELP_NOT_AUTHORIZED); - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(userId)); - - // when - when(helpService.deleteHelp(1L, 1L)).thenThrow(new HelpException(HELP_NOT_AUTHORIZED)); - - // then - mockMvc - .perform( - delete("/api/v1/help/{helpId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isUnauthorized()) - .andDo(print()) - .andExpect(jsonPath("$.code").value("401")) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/controller/ReportCommandControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/controller/ReportCommandControllerTest.java deleted file mode 100644 index 0598e47f3..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/controller/ReportCommandControllerTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package app.bottlenote.support.report.controller; - -import static app.bottlenote.support.report.dto.response.UserReportResponse.UserReportResponseEnum.SAME_USER; -import static app.bottlenote.support.report.dto.response.UserReportResponse.UserReportResponseEnum.SUCCESS; -import static app.bottlenote.support.report.dto.response.UserReportResponse.of; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.hasItem; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.exception.custom.code.ValidExceptionCode; -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.support.report.constant.UserReportType; -import app.bottlenote.support.report.dto.request.UserReportRequest; -import app.bottlenote.support.report.dto.response.UserReportResponse; -import app.bottlenote.support.report.service.ReviewReportService; -import app.bottlenote.support.report.service.UserReportService; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@Tag("unit") -@DisplayName("[unit] [controller] ReportCommandController") -@WebMvcTest(ReportCommandController.class) -@ActiveProfiles("test") -@WithMockUser // 인증된 사용자로 설정 -class ReportCommandControllerTest { - - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - @MockitoBean private UserReportService userReportService; - @MockitoBean private ReviewReportService reviewReportService; - - private MockedStatic mockedSecurityUtil; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @DisplayName("유저를 신고 할 수 있다.") - @Test - void reportUserTest() throws Exception { - // given - - final Long currentUserId = 1L; - final UserReportRequest request = - new UserReportRequest(2L, UserReportType.OTHER, "신고 내용 쏼라 쏼라 쏼라 "); - final UserReportResponse response = of(SUCCESS, 1L, 2L, "신고한 유저 이름"); - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(currentUserId)); - - // when - when(userReportService.userReport(currentUserId, request)).thenReturn(response); - - // then - mockMvc - .perform( - post("/api/v1/reports/user") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .content(mapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value("true")) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.message").value(SUCCESS.getMessage())) - .andExpect(jsonPath("$.data.reportId").value(response.getReportId())) - .andExpect(jsonPath("$.data.reportUserId").value(response.getReportUserId())) - .andExpect(jsonPath("$.data.reportUserName").value(response.getReportUserName())) - .andDo(print()); - } - - @DisplayName("자기 자신을 신고 할 수 없다.") - @Test - void cantNotReportMySelf() throws Exception { - // given - final Long currentUserId = 1L; - UserReportRequest request = new UserReportRequest(1L, UserReportType.OTHER, "신고 내용 쏼라 쏼라 쏼라 "); - UserReportResponse response = UserReportResponse.of(SAME_USER, null, currentUserId, null); - - when(SecurityContextUtil.getUserIdByContext()).thenReturn(Optional.of(currentUserId)); - - // then - mockMvc - .perform( - post("/api/v1/reports/user") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .content(mapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value("false")) - .andExpect(jsonPath("$.code").value("400")) - .andExpect(jsonPath("$.data").isEmpty()) - .andExpect(jsonPath("$.errors.message").value(SAME_USER.getMessage())) // error의 메시지 - .andDo(print()); - } - - @DisplayName("유저 신고 요청에 파라미터 값이 없으면 실패한다.") - @Test - void reportUserValidationTest() throws Exception { - Error reportTargetUserIdError = Error.of(ValidExceptionCode.REPORT_TARGET_USER_ID_REQUIRED); - Error reportTypeError = Error.of(ValidExceptionCode.REPORT_TYPE_NOT_VALID); - Error notBlankError = Error.of(ValidExceptionCode.CONTENT_NOT_BLANK); - - // given - UserReportRequest request = new UserReportRequest(null, null, null); - - // then - mockMvc - .perform( - post("/api/v1/reports/user") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .content(mapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect( - jsonPath("$.errors[?(@.code == '" + reportTargetUserIdError.code() + "')].status") - .value(reportTargetUserIdError.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == '" + reportTargetUserIdError.code() + "')].message") - .value(reportTargetUserIdError.message())) - .andExpect( - jsonPath("$.errors[?(@.code == '" + reportTypeError.code() + "')].status") - .value(reportTypeError.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == '" + reportTypeError.code() + "')].message") - .value(reportTypeError.message())) - .andExpect( - jsonPath("$.errors[?(@.code == '" + notBlankError.code() + "')].status") - .value(notBlankError.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == '" + notBlankError.code() + "')].message") - .value(notBlankError.message())); - } - - @DisplayName("유저 신고 요청에 파라미터 타입이 안 맞으면 실패한다.") - @Test - void reportUserValidationTypeTest() throws Exception { - - // given - Map request = new HashMap<>(); - request.put("reportUserId", "숫자가 아닌 어떤 값"); - request.put("type", 123); - request.put("content", 123); - - // when & then - mockMvc - .perform( - post("/api/v1/reports/user") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .content(mapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect( - jsonPath("$.errors[?(@.code == 'JSON_PASSING_FAILED')].message") - .value(hasItem(containsString("필드의 값이 잘못되었습니다.")))) - .andExpect( - jsonPath("$.errors[?(@.code == 'JSON_PASSING_FAILED')].message") - .value(hasItem(containsString("해당 필드의 값의 타입을 확인해주세요.")))); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java deleted file mode 100644 index a24c633da..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/OauthControllerTest.java +++ /dev/null @@ -1,226 +0,0 @@ -package app.bottlenote.user.controller; - -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.exception.custom.code.ValidExceptionCode; -import app.bottlenote.user.config.OauthConfigProperties; -import app.bottlenote.user.constant.GenderType; -import app.bottlenote.user.constant.SocialType; -import app.bottlenote.user.dto.request.OauthRequest; -import app.bottlenote.user.dto.response.TokenItem; -import app.bottlenote.user.exception.UserException; -import app.bottlenote.user.exception.UserExceptionCode; -import app.bottlenote.user.service.NonceService; -import app.bottlenote.user.service.OauthService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@Tag("unit") -@DisplayName("[unit] [controller] OauthController") -@WebMvcTest(OauthController.class) -@ActiveProfiles("test") -@WithMockUser -class OauthControllerTest { - // todo : oauth 관련 integration test 구현 필요 (2024.12.15) - @Autowired protected ObjectMapper mapper; - @Autowired protected MockMvc mockMvc; - @MockitoBean protected OauthService oauthService; - @MockitoBean protected NonceService nonceService; - @MockitoBean private OauthConfigProperties oauthConfigProperties; - - private TokenItem tokenItem; - - @BeforeEach - void setUp() { - tokenItem = - TokenItem.builder().accessToken("access-token").refreshToken("refresh-token").build(); - oauthConfigProperties.printConfigs(); - } - - @Test - @DisplayName("유저는 로그인 할 수 있다.") - void user_login_test() throws Exception { - - // given - OauthRequest oauthRequest = - new OauthRequest("cdm2883@naver.com", null, SocialType.KAKAO, GenderType.MALE, 27); - - TokenItem tokenItem = - TokenItem.builder().accessToken("accessToken").refreshToken("refreshToken").build(); - - // when - when(oauthService.login(oauthRequest)).thenReturn(tokenItem); - - // then - mockMvc - .perform( - post("/api/v1/oauth/login") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .content(mapper.writeValueAsString(oauthRequest))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.accessToken").value("accessToken")) - .andExpect(cookie().value("refresh-token", "refreshToken")) - .andExpect(cookie().httpOnly("refresh-token", true)) - .andExpect(cookie().secure("refresh-token", true)) - .andDo(print()); - } - - @Test - @DisplayName("유저는 SocialType이 null 값이면 로그인 할 수 없다.") - void user_login_fail_when_socialType_is_null() throws Exception { - - Error error = Error.of(ValidExceptionCode.SOCIAL_TYPE_REQUIRED); - - // given - OauthRequest oauthRequest = - new OauthRequest("cdm2883@naver.com", null, null, GenderType.MALE, 27); - - // when - when(oauthService.login(oauthRequest)).thenReturn(tokenItem); - - // then - mockMvc - .perform( - post("/api/v1/oauth/login") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .content(mapper.writeValueAsString(oauthRequest))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @Test - @DisplayName("유저나이가 유효하지 않은 값이면 로그인 할 수 없다.") - void user_login_fail_when_gender_is_null() throws Exception { - Error error = Error.of(ValidExceptionCode.AGE_MINIMUM); - - // given - OauthRequest oauthRequest = - new OauthRequest("cdm2883@naver.com", null, SocialType.KAKAO, GenderType.MALE, -10); - - // when - when(oauthService.login(oauthRequest)).thenReturn(tokenItem); - - // then - mockMvc - .perform( - post("/api/v1/oauth/login") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .content(mapper.writeValueAsString(oauthRequest))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @Test - @DisplayName("유저는 토큰을 재발급 받을 수 있다.") - void user_reissue_test() throws Exception { - - // given - String reissueRefreshToken = "refresh-token"; - - TokenItem newTokenItem = - TokenItem.builder() - .accessToken("new-access-token") - .refreshToken("new-refresh-token") - .build(); - - // when - when(oauthService.refresh(reissueRefreshToken)).thenReturn(newTokenItem); - - // then - mockMvc - .perform( - post("/api/v1/oauth/reissue") - .contentType(MediaType.APPLICATION_JSON) - .header("refresh-token", reissueRefreshToken) - .with(csrf()) - .content(mapper.writeValueAsString(reissueRefreshToken))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) - .andExpect(cookie().value("refresh-token", "new-refresh-token")) - .andExpect(cookie().httpOnly("refresh-token", true)) - .andExpect(cookie().secure("refresh-token", true)) - .andDo(print()); - } - - @Test - @DisplayName("refresh 토큰이 유효하지 않으면 토큰을 재발급 받을 수 없다.") - void user_reissue_fail_when_refreshToken_is_not_invalid() throws Exception { - - Error error = Error.of(UserExceptionCode.INVALID_REFRESH_TOKEN); - System.out.println(error); - System.out.println(error.code()); - System.out.println(error.status().name()); - System.out.println(error.status().value()); - System.out.println(error.status()); - System.out.println(error.message()); - - String reissueRefreshToken = "refresh-tokenxzz"; - - TokenItem newTokenItem = - TokenItem.builder() - .accessToken("new-access-token") - .refreshToken("new-refresh-token") - .build(); - // when - when(oauthService.refresh(reissueRefreshToken)) - .thenThrow(new UserException(UserExceptionCode.INVALID_REFRESH_TOKEN)); - - // then - mockMvc - .perform( - post("/api/v1/oauth/reissue") - .contentType(MediaType.APPLICATION_JSON) - .header("refresh-token", reissueRefreshToken) - .with(csrf())) - .andExpect(status().isUnauthorized()) - .andDo(print()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } - - @Test - @DisplayName("헤더의 리프레쉬 토큰이 null이면 토큰을 재발급 받을 수 없다.") - void user_reissue_fail_when_refreshToken_is_null() throws Exception { - String reissueRefreshToken = null; - - // when - when(oauthService.refresh(reissueRefreshToken)) - .thenThrow(new IllegalArgumentException("Refresh token is missing")); - - // then - mockMvc - .perform(post("/api/v1/oauth/reissue").contentType(MediaType.APPLICATION_JSON).with(csrf())) - .andExpect(status().isUnauthorized()) - .andExpect( - result -> assertTrue(result.getResolvedException() instanceof IllegalArgumentException)) - .andExpect(jsonPath("$.errors").exists()); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserBasicControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserBasicControllerTest.java deleted file mode 100644 index ceb20e6b7..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserBasicControllerTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package app.bottlenote.user.controller; - -import static app.bottlenote.global.security.SecurityContextUtil.getUserIdByContext; -import static app.bottlenote.user.constant.WithdrawUserResultMessage.USER_WITHDRAW_SUCCESS; -import static app.bottlenote.user.dto.response.WithdrawUserResultResponse.response; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.user.exception.UserExceptionCode; -import app.bottlenote.user.service.DefaultUserFacade; -import app.bottlenote.user.service.UserBasicService; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@Tag("unit") -@DisplayName("[unit] [controller] UserBasicController") -@WebMvcTest(UserBasicController.class) -@ActiveProfiles("test") -@WithMockUser -class UserBasicControllerTest { - - @Autowired private MockMvc mockMvc; - - @MockitoBean private UserBasicService userCommandService; - - @MockitoBean private DefaultUserFacade userFacade; - - private MockedStatic mockedSecurityUtil; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @DisplayName("회원탈퇴에 성공한다.") - @Test - void testWithdrawUserSuccess() throws Exception { - - // when - when(getUserIdByContext()).thenReturn(Optional.of(1L)); - - when(userCommandService.withdrawUser(anyLong())) - .thenReturn(response(USER_WITHDRAW_SUCCESS, 1L)); - - mockMvc - .perform(delete("/api/v1/users").contentType(MediaType.APPLICATION_JSON).with(csrf())) - .andExpect(status().isOk()) - .andDo(print()) - .andExpect(jsonPath("$.success").value("true")) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.data.message").value(USER_WITHDRAW_SUCCESS.getMessage())); - // then - } - - @DisplayName("존재하지 않는 회원은 회원탈퇴에 실패한다.") - @Test - void testWithdrawUserFailedWhenUserNotExist() throws Exception { - - Error error = Error.of(UserExceptionCode.REQUIRED_USER_ID); - - // when - when(getUserIdByContext()).thenReturn(Optional.empty()); - - when(userCommandService.withdrawUser(anyLong())) - .thenReturn(response(USER_WITHDRAW_SUCCESS, 1L)); - - // then - mockMvc - .perform(delete("/api/v1/users").contentType(MediaType.APPLICATION_JSON).with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserProfileImagesChangeControllerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserProfileImagesChangeControllerTest.java deleted file mode 100644 index bb19d4de8..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/controller/UserProfileImagesChangeControllerTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package app.bottlenote.user.controller; - -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.global.security.SecurityContextUtil; -import app.bottlenote.user.dto.response.ProfileImageChangeResponse; -import app.bottlenote.user.service.DefaultUserFacade; -import app.bottlenote.user.service.UserBasicService; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@Tag("unit") -@DisplayName("[unit] [controller] UserProfileImagesChangeController") -@WebMvcTest(UserBasicController.class) -@ActiveProfiles("test") -@WithMockUser -class UserProfileImagesChangeControllerTest { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper mapper; - @MockitoBean private UserBasicService profileImageChangeService; - @MockitoBean private DefaultUserFacade userFacade; - - private MockedStatic mockedSecurityUtil; - - @BeforeEach - void setup() { - mockedSecurityUtil = mockStatic(SecurityContextUtil.class); - mockedSecurityUtil.when(SecurityContextUtil::getUserIdByContext).thenReturn(Optional.of(1L)); - } - - @AfterEach - void tearDown() { - mockedSecurityUtil.close(); - } - - @DisplayName("프로필 이미지를 성공적으로 변경할 수 있다.") - @Test - void test_1() throws Exception { - - Long userId = 1L; - String viewUrl = "http://example.com/profile-image.jpg"; - - ProfileImageChangeResponse response = new ProfileImageChangeResponse(userId, viewUrl); - - when(profileImageChangeService.profileImageChange(anyLong(), anyString())).thenReturn(response); - - Map requestBody = new HashMap<>(); - requestBody.put("viewUrl", viewUrl); - - mockMvc - .perform( - patch("/api/v1/users/profile-image") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(requestBody)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) // 수정된 부분 - .andExpect(jsonPath("$.code").value(200)) // 수정된 부분 - .andExpect(jsonPath("$.data.userId").value(response.userId())) - .andExpect(jsonPath("$.data.profileImageUrl").value(response.profileImageUrl())) - .andDo(print()); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholReferenceControllerRestDocsTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java similarity index 98% rename from bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholReferenceControllerRestDocsTest.java rename to bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java index 0a89d3fa6..a311b4459 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/AlcoholReferenceControllerRestDocsTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestAlcoholReferenceControllerTest.java @@ -1,4 +1,4 @@ -package app.bottlenote.alcohols.controller; +package app.docs.alcohols; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -17,6 +17,7 @@ import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import app.bottlenote.alcohols.controller.AlcoholReferenceController; import app.bottlenote.alcohols.dto.request.CurationKeywordSearchRequest; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; @@ -32,7 +33,7 @@ @Tag("rest-docs") @DisplayName("큐레이션 키워드 API 문서화 테스트") -class AlcoholReferenceControllerRestDocsTest extends AbstractRestDocs { +class RestAlcoholReferenceControllerTest extends AbstractRestDocs { private final AlcoholReferenceService alcoholReferenceService = mock(AlcoholReferenceService.class); diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestRegionControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java similarity index 100% rename from bottlenote-product-api/src/test/java/app/docs/alcohols/RestRegionControllerTest.java rename to bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestTastingTagControllerTest.java similarity index 95% rename from bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java rename to bottlenote-product-api/src/test/java/app/docs/alcohols/RestTastingTagControllerTest.java index 47ee891a5..02fcf52a9 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/controller/TastingTagControllerRestDocsTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestTastingTagControllerTest.java @@ -1,4 +1,4 @@ -package app.bottlenote.alcohols.controller; +package app.docs.alcohols; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -16,6 +16,7 @@ import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import app.bottlenote.alcohols.controller.TastingTagController; import app.bottlenote.alcohols.service.TastingTagService; import app.external.docs.AbstractRestDocs; import java.util.List; @@ -26,7 +27,7 @@ @Tag("rest-docs") @DisplayName("TastingTag API 문서화 테스트") -class TastingTagControllerRestDocsTest extends AbstractRestDocs { +class RestTastingTagControllerTest extends AbstractRestDocs { private final TastingTagService tastingTagService = mock(TastingTagService.class); diff --git a/git.environment-variables b/git.environment-variables index a1c88ae19..eb36c2bb9 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit a1c88ae199387c3485b81419110525e02ad6078b +Subproject commit eb36c2bb9ece676ef6f2641b46579764521532a0 From f43600b89a3f4a30e091c1f33c8860ff6318f6f9 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 14 Jan 2026 10:27:01 +0900 Subject: [PATCH 38/95] =?UTF-8?q?chore:=20=EC=9D=B8=EA=B8=B0=20=EC=A3=BC?= =?UTF-8?q?=EB=A5=98=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PopularControllerIntegrationTest.java | 53 ---- .../integration/PopularIntegrationTest.java | 24 +- .../PopularViewIntegrationTest.java | 124 ---------- .../RestPopularControllerIntegrationTest.java | 125 ---------- .../alcohols/RestPopularControllerTest.java | 230 ++++++++++++++++++ .../RestPopularViewControllerTest.java | 126 ---------- .../init-alcohols_view_history.sql | 37 --- .../resources/init-script/init-rating.sql | 43 ---- 8 files changed, 242 insertions(+), 520 deletions(-) delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularControllerIntegrationTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerIntegrationTest.java create mode 100644 bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java delete mode 100644 bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-rating.sql diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularControllerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularControllerIntegrationTest.java deleted file mode 100644 index 5b1706003..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularControllerIntegrationTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package app.bottlenote.alcohols.integration; - -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.IntegrationTestSupport; -import app.bottlenote.global.data.response.GlobalResponse; -import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.web.servlet.MvcResult; - -@Tag("integration") -@DisplayName("[integration] [controller] Popular") -class PopularControllerIntegrationTest extends IntegrationTestSupport { - - @DisplayName("주간 인기 위스키를 조회할 수 있습니다.") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-popular_alcohol.sql" - }) - @Test - void test_1() throws Exception { - // given - // when && then - MvcResult result = - mockMvc - .perform( - get("/api/v1/popular/week") - .contentType(MediaType.APPLICATION_JSON) - .param("top", "5") - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.alcohols").isArray()) - .andExpect(jsonPath("$.data.alcohols.length()").value(5)) - .andReturn(); - - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); - log.info("response : {}", response); - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java index b55be2efc..49ad1c6d5 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularIntegrationTest.java @@ -8,7 +8,7 @@ import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; -import app.bottlenote.alcohols.dto.response.PopularsResponse; +import app.bottlenote.alcohols.dto.response.PopularsOfWeekResponse; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.history.fixture.AlcoholsViewHistoryTestFactory; import app.bottlenote.rating.fixture.RatingTestFactory; @@ -58,9 +58,9 @@ void test_getPopularOfWeek() throws Exception { .exchange(); // then - PopularsResponse response = extractData(result, PopularsResponse.class); + PopularsOfWeekResponse response = extractData(result, PopularsOfWeekResponse.class); assertNotNull(response); - assertEquals(5, response.alcohols().size()); + assertEquals(5, response.getAlcohols().size()); } } @@ -97,10 +97,10 @@ void test_getPopularViewWeekly() throws Exception { .exchange(); // then - PopularsResponse response = extractData(result, PopularsResponse.class); + PopularsOfWeekResponse response = extractData(result, PopularsOfWeekResponse.class); assertNotNull(response); - assertEquals(5, response.alcohols().size()); - assertEquals(5, response.totalCount()); + assertEquals(5, response.getAlcohols().size()); + assertEquals(5, response.getTotalCount()); } @Test @@ -138,10 +138,10 @@ void test_getPopularViewWeekly_fillWithRating() throws Exception { .exchange(); // then - PopularsResponse response = extractData(result, PopularsResponse.class); + PopularsOfWeekResponse response = extractData(result, PopularsOfWeekResponse.class); assertNotNull(response); - assertEquals(10, response.totalCount()); - assertTrue(response.alcohols().size() >= 5); + assertEquals(10, response.getTotalCount()); + assertTrue(response.getAlcohols().size() >= 5); } @Test @@ -173,10 +173,10 @@ void test_getPopularViewMonthly() throws Exception { .exchange(); // then - PopularsResponse response = extractData(result, PopularsResponse.class); + PopularsOfWeekResponse response = extractData(result, PopularsOfWeekResponse.class); assertNotNull(response); - assertEquals(5, response.alcohols().size()); - assertEquals(5, response.totalCount()); + assertEquals(5, response.getAlcohols().size()); + assertEquals(5, response.getTotalCount()); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java deleted file mode 100644 index 2fa1f7525..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/PopularViewIntegrationTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package app.bottlenote.alcohols.integration; - -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.IntegrationTestSupport; -import app.bottlenote.global.data.response.GlobalResponse; -import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.web.servlet.MvcResult; - -@Tag("integration") -@DisplayName("[integration] [controller] Popular View") -class PopularViewIntegrationTest extends IntegrationTestSupport { - - @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-alcohols_view_history.sql", - "/init-script/init-rating.sql" - }) - @Test - void test_getPopularViewWeekly() throws Exception { - // given - int top = 5; - - // when && then - MvcResult result = - mockMvc - .perform( - get("/api/v1/popular/view/week") - .contentType(MediaType.APPLICATION_JSON) - .param("top", String.valueOf(top)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.alcohols").isArray()) - .andExpect(jsonPath("$.data.alcohols.length()").value(top)) - .andReturn(); - - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); - log.info("response : {}", response); - } - - @DisplayName("조회 기록이 부족하면 평점 높은 주류로 채워서 반환한다") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-alcohols_view_history.sql", - "/init-script/init-rating.sql" - }) - @Test - void test_getPopularViewWeekly_fillWithRating() throws Exception { - // given - 조회 기록 5개, 요청 10개 -> 5개는 평점 기반으로 채워짐 - int top = 10; - - // when && then - MvcResult result = - mockMvc - .perform( - get("/api/v1/popular/view/week") - .contentType(MediaType.APPLICATION_JSON) - .param("top", String.valueOf(top)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.alcohols").isArray()) - .andExpect(jsonPath("$.data.totalCount").value(top)) - .andReturn(); - - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); - log.info("response : {}", response); - } - - @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-alcohols_view_history.sql", - "/init-script/init-rating.sql" - }) - @Test - void test_getPopularViewMonthly() throws Exception { - // given - int top = 5; - - // when && then - MvcResult result = - mockMvc - .perform( - get("/api/v1/popular/view/monthly") - .contentType(MediaType.APPLICATION_JSON) - .param("top", String.valueOf(top)) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.alcohols").isArray()) - .andExpect(jsonPath("$.data.alcohols.length()").value(top)) - .andReturn(); - - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); - log.info("response : {}", response); - } -} diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerIntegrationTest.java deleted file mode 100644 index 8e70c03bb..000000000 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerIntegrationTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package app.docs.alcohols; - -import static app.bottlenote.alcohols.fixture.PopularsObjectFixture.getFixturePopulars; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.alcohols.controller.AlcoholPopularQueryController; -import app.bottlenote.alcohols.dto.response.PopularItem; -import app.bottlenote.alcohols.service.AlcoholPopularService; -import app.bottlenote.global.security.SecurityContextUtil; -import app.docs.AbstractRestDocs; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -@DisplayName("popular 컨트롤러 RestDocs용 테스트") -class RestPopularControllerIntegrationTest extends AbstractRestDocs { - - private final AlcoholPopularService alcoholPopularService = mock(AlcoholPopularService.class); - private MockedStatic mockedSecurityUtil; - - @Override - protected Object initController() { - return new AlcoholPopularQueryController(alcoholPopularService); - } - - @Test - @DisplayName("주간 인기 술 리스트를 조회할 수 있다.") - void docs_1() throws Exception { - // given - List populars = - List.of( - getFixturePopulars(1L, "글렌피딕", "glen fi"), - getFixturePopulars(2L, "맥키토시", "macintosh"), - getFixturePopulars(3L, "글렌리벳", "glen rivet"), - getFixturePopulars(4L, "글렌피딕", "glen fi"), - getFixturePopulars(5L, "맥키토시", "macintosh")); - - // when & then - when(alcoholPopularService.getPopularOfWeek(anyInt(), any())).thenReturn(populars); - - mockMvc - .perform(MockMvcRequestBuilders.get("/api/v1/popular/week").param("top", "5")) - .andExpect(status().isOk()) - .andDo( - document( - "alcohols/populars/week", - queryParameters(parameterWithName("top").description("조회할 주간 인기 술 목록 사이즈(갯수)")), - responseFields( - fieldWithPath("success").description("응답 성공 여부"), - fieldWithPath("code").description("응답 코드(http status code)"), - fieldWithPath("data.totalCount").description("주간 인기 술 리스트의 크기"), - fieldWithPath("data.alcohols").description("주간 인기 술 리스트"), - fieldWithPath("data.alcohols[].alcoholId").description("술 ID"), - fieldWithPath("data.alcohols[].korName").description("술 이름"), - fieldWithPath("data.alcohols[].engName").description("술 영문명"), - fieldWithPath("data.alcohols[].rating").description("술의 평균 평점"), - fieldWithPath("data.alcohols[].ratingCount").description("술의 평점 참여자 수"), - fieldWithPath("data.alcohols[].korCategory").description("술 카테고리"), - fieldWithPath("data.alcohols[].engCategory").description("술 카테고리 영문명"), - fieldWithPath("data.alcohols[].imageUrl").description("술 이미지 URL"), - fieldWithPath("data.alcohols[].isPicked").description("내가 찜했는지 여부"), - fieldWithPath("data.alcohols[].popularScore").description("인기도 점수"), - fieldWithPath("errors").ignored(), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - - verify(alcoholPopularService).getPopularOfWeek(5, -1L); - } - - @Test - @DisplayName("봄에 인기 있는 술 리스트를 조회할 수 있다.") - void docs_2() throws Exception { - // given - List springPopulars = - List.of( - getFixturePopulars(1L, "글렌피딕", "glen fi"), - getFixturePopulars(2L, "맥키토시", "macintosh"), - getFixturePopulars(3L, "글렌리벳", "glen rivet"), - getFixturePopulars(4L, "글렌피딕", "glen fi"), - getFixturePopulars(5L, "맥키토시", "macintosh")); - - // when & then - when(alcoholPopularService.getSpringItems(anyLong())).thenReturn(springPopulars); - - mockMvc - .perform(MockMvcRequestBuilders.get("/api/v1/popular/spring")) - .andExpect(status().isOk()) - .andDo( - document( - "alcohols/populars/spring", - responseFields( - fieldWithPath("success").description("응답 성공 여부"), - fieldWithPath("code").description("응답 코드(http status code)"), - fieldWithPath("data[].alcoholId").description("술 ID"), - fieldWithPath("data[].korName").description("술 이름"), - fieldWithPath("data[].engName").description("술 영문명"), - fieldWithPath("data[].rating").description("술의 평균 평점"), - fieldWithPath("data[].ratingCount").description("술의 평점 참여자 수"), - fieldWithPath("data[].korCategory").description("술 카테고리"), - fieldWithPath("data[].engCategory").description("술 카테고리 영문명"), - fieldWithPath("data[].imageUrl").description("술 이미지 URL"), - fieldWithPath("data[].isPicked").description("내가 찜했는지 여부"), - fieldWithPath("data[].popularScore").description("인기도 점수"), - fieldWithPath("errors").ignored(), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - } -} diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java new file mode 100644 index 000000000..4152492f4 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularControllerTest.java @@ -0,0 +1,230 @@ +package app.docs.alcohols; + +import static app.bottlenote.alcohols.fixture.PopularsObjectFixture.getFixturePopulars; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import app.bottlenote.alcohols.controller.AlcoholPopularQueryController; +import app.bottlenote.alcohols.dto.response.PopularItem; +import app.bottlenote.alcohols.service.AlcoholPopularService; +import app.docs.AbstractRestDocs; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@DisplayName("Popular API RestDocs") +class RestPopularControllerTest extends AbstractRestDocs { + + private final AlcoholPopularService alcoholPopularService = mock(AlcoholPopularService.class); + + @Override + protected Object initController() { + return new AlcoholPopularQueryController(alcoholPopularService); + } + + @Nested + @DisplayName("주간 인기 API") + class WeeklyPopularDocs { + + @Test + @DisplayName("주간 인기 술 리스트를 조회할 수 있다") + void docs_getPopularOfWeek() throws Exception { + // given + List populars = + List.of( + getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), + getFixturePopulars(2L, "맥캘란", "Macallan"), + getFixturePopulars(3L, "글렌리벳", "Glenlivet"), + getFixturePopulars(4L, "발베니", "Balvenie"), + getFixturePopulars(5L, "라프로익", "Laphroaig")); + + when(alcoholPopularService.getPopularOfWeek(anyInt(), any())).thenReturn(populars); + + // when & then + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/popular/week").param("top", "5")) + .andExpect(status().isOk()) + .andDo( + document( + "alcohols/populars/week", + queryParameters(parameterWithName("top").description("조회할 주간 인기 술 목록 사이즈(갯수)")), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data.totalCount").description("주간 인기 술 리스트의 크기"), + fieldWithPath("data.alcohols").description("주간 인기 술 리스트"), + fieldWithPath("data.alcohols[].alcoholId").description("술 ID"), + fieldWithPath("data.alcohols[].korName").description("술 이름"), + fieldWithPath("data.alcohols[].engName").description("술 영문명"), + fieldWithPath("data.alcohols[].rating").description("술의 평균 평점"), + fieldWithPath("data.alcohols[].ratingCount").description("술의 평점 참여자 수"), + fieldWithPath("data.alcohols[].korCategory").description("술 카테고리"), + fieldWithPath("data.alcohols[].engCategory").description("술 카테고리 영문명"), + fieldWithPath("data.alcohols[].imageUrl").description("술 이미지 URL"), + fieldWithPath("data.alcohols[].isPicked").description("내가 찜했는지 여부"), + fieldWithPath("data.alcohols[].popularScore").description("인기도 점수"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + + verify(alcoholPopularService).getPopularOfWeek(5, -1L); + } + } + + @Nested + @DisplayName("계절 인기 API") + class SeasonalPopularDocs { + + @Test + @DisplayName("봄에 인기 있는 술 리스트를 조회할 수 있다") + void docs_getSpringItems() throws Exception { + // given + List springPopulars = + List.of( + getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), + getFixturePopulars(2L, "맥캘란", "Macallan"), + getFixturePopulars(3L, "글렌리벳", "Glenlivet"), + getFixturePopulars(4L, "발베니", "Balvenie"), + getFixturePopulars(5L, "라프로익", "Laphroaig")); + + when(alcoholPopularService.getSpringItems(anyLong())).thenReturn(springPopulars); + + // when & then + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/popular/spring")) + .andExpect(status().isOk()) + .andDo( + document( + "alcohols/populars/spring", + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data[].alcoholId").description("술 ID"), + fieldWithPath("data[].korName").description("술 이름"), + fieldWithPath("data[].engName").description("술 영문명"), + fieldWithPath("data[].rating").description("술의 평균 평점"), + fieldWithPath("data[].ratingCount").description("술의 평점 참여자 수"), + fieldWithPath("data[].korCategory").description("술 카테고리"), + fieldWithPath("data[].engCategory").description("술 카테고리 영문명"), + fieldWithPath("data[].imageUrl").description("술 이미지 URL"), + fieldWithPath("data[].isPicked").description("내가 찜했는지 여부"), + fieldWithPath("data[].popularScore").description("인기도 점수"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + } + } + + @Nested + @DisplayName("조회수 기반 인기 API") + class ViewBasedPopularDocs { + + @Test + @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") + void docs_getPopularViewWeekly() throws Exception { + // given + List populars = + List.of( + getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), + getFixturePopulars(2L, "맥캘란", "Macallan"), + getFixturePopulars(3L, "글렌리벳", "Glenlivet"), + getFixturePopulars(4L, "발베니", "Balvenie"), + getFixturePopulars(5L, "라프로익", "Laphroaig")); + + when(alcoholPopularService.getPopularByViewsWeekly(anyInt(), any())).thenReturn(populars); + + // when & then + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/popular/view/week").param("top", "5")) + .andExpect(status().isOk()) + .andDo( + document( + "alcohols/populars/view/week", + queryParameters(parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data.totalCount").description("조회된 위스키 개수"), + fieldWithPath("data.alcohols").description("주간 조회수 기반 인기 위스키 리스트"), + fieldWithPath("data.alcohols[].alcoholId").description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").description("위스키 한글명"), + fieldWithPath("data.alcohols[].engName").description("위스키 영문명"), + fieldWithPath("data.alcohols[].rating").description("평균 평점"), + fieldWithPath("data.alcohols[].ratingCount").description("평점 참여자 수"), + fieldWithPath("data.alcohols[].korCategory").description("카테고리 한글명"), + fieldWithPath("data.alcohols[].engCategory").description("카테고리 영문명"), + fieldWithPath("data.alcohols[].imageUrl").description("이미지 URL"), + fieldWithPath("data.alcohols[].isPicked").description("찜 여부"), + fieldWithPath("data.alcohols[].popularScore").description("인기도 점수 (조회수 기반)"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + + verify(alcoholPopularService).getPopularByViewsWeekly(5, -1L); + } + + @Test + @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") + void docs_getPopularViewMonthly() throws Exception { + // given + List populars = + List.of( + getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), + getFixturePopulars(2L, "맥캘란", "Macallan"), + getFixturePopulars(3L, "글렌리벳", "Glenlivet"), + getFixturePopulars(4L, "발베니", "Balvenie"), + getFixturePopulars(5L, "라프로익", "Laphroaig")); + + when(alcoholPopularService.getPopularByViewsMonthly(anyInt(), any())).thenReturn(populars); + + // when & then + mockMvc + .perform(MockMvcRequestBuilders.get("/api/v1/popular/view/monthly").param("top", "5")) + .andExpect(status().isOk()) + .andDo( + document( + "alcohols/populars/view/monthly", + queryParameters(parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), + responseFields( + fieldWithPath("success").description("응답 성공 여부"), + fieldWithPath("code").description("응답 코드(http status code)"), + fieldWithPath("data.totalCount").description("조회된 위스키 개수"), + fieldWithPath("data.alcohols").description("월간 조회수 기반 인기 위스키 리스트"), + fieldWithPath("data.alcohols[].alcoholId").description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").description("위스키 한글명"), + fieldWithPath("data.alcohols[].engName").description("위스키 영문명"), + fieldWithPath("data.alcohols[].rating").description("평균 평점"), + fieldWithPath("data.alcohols[].ratingCount").description("평점 참여자 수"), + fieldWithPath("data.alcohols[].korCategory").description("카테고리 한글명"), + fieldWithPath("data.alcohols[].engCategory").description("카테고리 영문명"), + fieldWithPath("data.alcohols[].imageUrl").description("이미지 URL"), + fieldWithPath("data.alcohols[].isPicked").description("찜 여부"), + fieldWithPath("data.alcohols[].popularScore").description("인기도 점수 (조회수 기반)"), + fieldWithPath("errors").ignored(), + fieldWithPath("meta.serverEncoding").ignored(), + fieldWithPath("meta.serverVersion").ignored(), + fieldWithPath("meta.serverPathVersion").ignored(), + fieldWithPath("meta.serverResponseTime").ignored()))); + + verify(alcoholPopularService).getPopularByViewsMonthly(5, -1L); + } + } +} diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java deleted file mode 100644 index abec56aa8..000000000 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestPopularViewControllerTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package app.docs.alcohols; - -import static app.bottlenote.alcohols.fixture.PopularsObjectFixture.getFixturePopulars; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import app.bottlenote.alcohols.controller.AlcoholPopularQueryController; -import app.bottlenote.alcohols.dto.response.PopularItem; -import app.bottlenote.alcohols.service.AlcoholPopularService; -import app.docs.AbstractRestDocs; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -@DisplayName("조회수 기반 인기 위스키 RestDocs 테스트") -class RestPopularViewControllerTest extends AbstractRestDocs { - - private final AlcoholPopularService alcoholPopularService = mock(AlcoholPopularService.class); - - @Override - protected Object initController() { - return new AlcoholPopularQueryController(alcoholPopularService); - } - - @Test - @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") - void docs_getPopularViewWeekly() throws Exception { - // given - List populars = - List.of( - getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), - getFixturePopulars(2L, "맥캘란", "Macallan"), - getFixturePopulars(3L, "글렌리벳", "Glenlivet"), - getFixturePopulars(4L, "발베니", "Balvenie"), - getFixturePopulars(5L, "라프로익", "Laphroaig")); - - when(alcoholPopularService.getPopularByViewsWeekly(anyInt(), any())).thenReturn(populars); - - // when & then - mockMvc - .perform(MockMvcRequestBuilders.get("/api/v1/popular/view/week").param("top", "5")) - .andExpect(status().isOk()) - .andDo( - document( - "alcohols/populars/view/week", - queryParameters(parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), - responseFields( - fieldWithPath("success").description("응답 성공 여부"), - fieldWithPath("code").description("응답 코드(http status code)"), - fieldWithPath("data.totalCount").description("조회된 위스키 개수"), - fieldWithPath("data.alcohols").description("주간 조회수 기반 인기 위스키 리스트"), - fieldWithPath("data.alcohols[].alcoholId").description("위스키 ID"), - fieldWithPath("data.alcohols[].korName").description("위스키 한글명"), - fieldWithPath("data.alcohols[].engName").description("위스키 영문명"), - fieldWithPath("data.alcohols[].rating").description("평균 평점"), - fieldWithPath("data.alcohols[].ratingCount").description("평점 참여자 수"), - fieldWithPath("data.alcohols[].korCategory").description("카테고리 한글명"), - fieldWithPath("data.alcohols[].engCategory").description("카테고리 영문명"), - fieldWithPath("data.alcohols[].imageUrl").description("이미지 URL"), - fieldWithPath("data.alcohols[].isPicked").description("찜 여부"), - fieldWithPath("data.alcohols[].popularScore").description("인기도 점수 (조회수 기반)"), - fieldWithPath("errors").ignored(), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - - verify(alcoholPopularService).getPopularByViewsWeekly(5, -1L); - } - - @Test - @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") - void docs_getPopularViewMonthly() throws Exception { - // given - List populars = - List.of( - getFixturePopulars(1L, "글렌피딕", "Glenfiddich"), - getFixturePopulars(2L, "맥캘란", "Macallan"), - getFixturePopulars(3L, "글렌리벳", "Glenlivet"), - getFixturePopulars(4L, "발베니", "Balvenie"), - getFixturePopulars(5L, "라프로익", "Laphroaig")); - - when(alcoholPopularService.getPopularByViewsMonthly(anyInt(), any())).thenReturn(populars); - - // when & then - mockMvc - .perform(MockMvcRequestBuilders.get("/api/v1/popular/view/monthly").param("top", "5")) - .andExpect(status().isOk()) - .andDo( - document( - "alcohols/populars/view/monthly", - queryParameters(parameterWithName("top").description("조회할 위스키 개수 (기본값: 20)")), - responseFields( - fieldWithPath("success").description("응답 성공 여부"), - fieldWithPath("code").description("응답 코드(http status code)"), - fieldWithPath("data.totalCount").description("조회된 위스키 개수"), - fieldWithPath("data.alcohols").description("월간 조회수 기반 인기 위스키 리스트"), - fieldWithPath("data.alcohols[].alcoholId").description("위스키 ID"), - fieldWithPath("data.alcohols[].korName").description("위스키 한글명"), - fieldWithPath("data.alcohols[].engName").description("위스키 영문명"), - fieldWithPath("data.alcohols[].rating").description("평균 평점"), - fieldWithPath("data.alcohols[].ratingCount").description("평점 참여자 수"), - fieldWithPath("data.alcohols[].korCategory").description("카테고리 한글명"), - fieldWithPath("data.alcohols[].engCategory").description("카테고리 영문명"), - fieldWithPath("data.alcohols[].imageUrl").description("이미지 URL"), - fieldWithPath("data.alcohols[].isPicked").description("찜 여부"), - fieldWithPath("data.alcohols[].popularScore").description("인기도 점수 (조회수 기반)"), - fieldWithPath("errors").ignored(), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - - verify(alcoholPopularService).getPopularByViewsMonthly(5, -1L); - } -} diff --git a/bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql b/bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql deleted file mode 100644 index c9b7e4506..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql +++ /dev/null @@ -1,37 +0,0 @@ --- 조회수 기반 인기 주류 테스트 데이터 --- 이번 주 기준으로 조회 기록 생성 - --- alcohol_id 1: 5명 조회 (가장 인기) -INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) -VALUES (1, 1, DATE_SUB(NOW(), INTERVAL 1 DAY)), - (2, 1, DATE_SUB(NOW(), INTERVAL 1 DAY)), - (3, 1, DATE_SUB(NOW(), INTERVAL 2 DAY)), - (4, 1, DATE_SUB(NOW(), INTERVAL 2 DAY)), - (5, 1, DATE_SUB(NOW(), INTERVAL 3 DAY)) -ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); - --- alcohol_id 2: 4명 조회 -INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) -VALUES (1, 2, DATE_SUB(NOW(), INTERVAL 1 DAY)), - (2, 2, DATE_SUB(NOW(), INTERVAL 2 DAY)), - (3, 2, DATE_SUB(NOW(), INTERVAL 3 DAY)), - (4, 2, DATE_SUB(NOW(), INTERVAL 4 DAY)) -ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); - --- alcohol_id 3: 3명 조회 -INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) -VALUES (1, 3, DATE_SUB(NOW(), INTERVAL 1 DAY)), - (2, 3, DATE_SUB(NOW(), INTERVAL 2 DAY)), - (3, 3, DATE_SUB(NOW(), INTERVAL 3 DAY)) -ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); - --- alcohol_id 4: 2명 조회 -INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) -VALUES (1, 4, DATE_SUB(NOW(), INTERVAL 1 DAY)), - (2, 4, DATE_SUB(NOW(), INTERVAL 2 DAY)) -ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); - --- alcohol_id 5: 1명 조회 -INSERT INTO alcohols_view_histories (user_id, alcohol_id, view_at) -VALUES (1, 5, DATE_SUB(NOW(), INTERVAL 1 DAY)) -ON DUPLICATE KEY UPDATE view_at = VALUES(view_at); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-rating.sql b/bottlenote-product-api/src/test/resources/init-script/init-rating.sql deleted file mode 100644 index 6d10d6997..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-rating.sql +++ /dev/null @@ -1,43 +0,0 @@ --- 평점 테스트 데이터 (부족분 채우기용) - --- alcohol 6~10: 평점 높은 순 (조회수 데이터 없음 -> 평점 기반으로 채워질 대상) -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 6, 5.0, NOW(), NOW()), - (2, 6, 4.5, NOW(), NOW()), - (3, 6, 5.0, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); - -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 7, 4.5, NOW(), NOW()), - (2, 7, 4.5, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); - -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 8, 4.0, NOW(), NOW()), - (2, 8, 4.0, NOW(), NOW()), - (3, 8, 4.5, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); - -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 9, 3.5, NOW(), NOW()), - (2, 9, 4.0, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); - -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 10, 3.0, NOW(), NOW()), - (2, 10, 3.5, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); - --- alcohol 1~5: 조회수 있는 주류들의 평점 -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 1, 4.0, NOW(), NOW()), - (2, 1, 4.5, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); - -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 2, 3.5, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); - -INSERT INTO ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -VALUES (1, 3, 4.0, NOW(), NOW()) -ON DUPLICATE KEY UPDATE rating = VALUES(rating); From eeee046cf2aeb1da5099ddb0b23f8ef26e5b4217 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 14 Jan 2026 10:30:43 +0900 Subject: [PATCH 39/95] =?UTF-8?q?docs(alcohols):=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=EC=8A=A4=ED=82=A4=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주간 및 월간 조회수 기반 인기 위스키 조회 API 명세 추가 - 요청 및 응답 파라미터 스니펫 include --- .../docs/asciidoc/api/alcohols/popular.adoc | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/popular.adoc b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/popular.adoc index 47091b842..f982bb821 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/alcohols/popular.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/alcohols/popular.adoc @@ -43,3 +43,49 @@ include::{snippets}/alcohols/populars/spring/curl-request.adoc[] [discrete] include::{snippets}/alcohols/populars/spring/response-fields.adoc[] include::{snippets}/alcohols/populars/spring/response-body.adoc[] + +=== 주간 조회수 기반 인기 위스키 조회 === + +주간 조회수를 기준으로 인기 위스키를 조회합니다. + +조회수가 부족한 경우 평점이 높은 위스키로 채워서 반환합니다. + +[source] +---- +GET /api/v1/popular/view/week +---- + +[discrete] +==== 요청 파라미터 ==== + +include::{snippets}/alcohols/populars/view/week/query-parameters.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/alcohols/populars/view/week/response-fields.adoc[] +include::{snippets}/alcohols/populars/view/week/response-body.adoc[] + +=== 월간 조회수 기반 인기 위스키 조회 === + +월간 조회수를 기준으로 인기 위스키를 조회합니다. + +조회수가 부족한 경우 평점이 높은 위스키로 채워서 반환합니다. + +[source] +---- +GET /api/v1/popular/view/monthly +---- + +[discrete] +==== 요청 파라미터 ==== + +include::{snippets}/alcohols/populars/view/monthly/query-parameters.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/alcohols/populars/view/monthly/response-fields.adoc[] +include::{snippets}/alcohols/populars/view/monthly/response-body.adoc[] From 00fd5dc581cafb5bd9337b227486c75fcb1076b2 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 14 Jan 2026 10:40:30 +0900 Subject: [PATCH 40/95] =?UTF-8?q?docs(blocks):=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20API=20=EB=AC=B8=EC=84=9C=EC=97=90=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=84=B9=EC=85=98=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 --- .../docs/asciidoc/api/support/block/user-block.adoc | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bottlenote-product-api/src/docs/asciidoc/api/support/block/user-block.adoc b/bottlenote-product-api/src/docs/asciidoc/api/support/block/user-block.adoc index 0303d80a3..b76afa749 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/support/block/user-block.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/support/block/user-block.adoc @@ -7,10 +7,12 @@ POST /api/v1/blocks ---- +[discrete] ==== 요청 파라미터 include::{snippets}/block-create/request-fields.adoc[] +[discrete] ==== 응답 파라미터 include::{snippets}/block-create/response-fields.adoc[] @@ -26,10 +28,12 @@ include::{snippets}/block-create/response-fields.adoc[] DELETE /api/v1/blocks/{blockedUserId} ---- +[discrete] ==== 경로 파라미터 include::{snippets}/block-delete/path-parameters.adoc[] +[discrete] ==== 응답 파라미터 include::{snippets}/block-delete/response-fields.adoc[] @@ -45,6 +49,7 @@ include::{snippets}/block-delete/response-fields.adoc[] GET /api/v1/blocks ---- +[discrete] ==== 응답 파라미터 include::{snippets}/block-list/response-fields.adoc[] @@ -60,6 +65,7 @@ include::{snippets}/block-list/response-fields.adoc[] GET /api/v1/blocks/ids ---- +[discrete] ==== 응답 파라미터 include::{snippets}/block-ids/response-fields.adoc[] @@ -75,10 +81,12 @@ include::{snippets}/block-ids/response-fields.adoc[] GET /api/v1/blocks/check/{targetUserId} ---- +[discrete] ==== 경로 파라미터 include::{snippets}/block-check/path-parameters.adoc[] +[discrete] ==== 응답 파라미터 include::{snippets}/block-check/response-fields.adoc[] @@ -92,10 +100,12 @@ include::{snippets}/block-check/response-fields.adoc[] GET /api/v1/blocks/mutual-check/{targetUserId} ---- +[discrete] ==== 경로 파라미터 include::{snippets}/block-mutual-check/path-parameters.adoc[] +[discrete] ==== 응답 파라미터 include::{snippets}/block-mutual-check/response-fields.adoc[] @@ -111,6 +121,7 @@ include::{snippets}/block-mutual-check/response-fields.adoc[] GET /api/v1/blocks/stats/blocked-by-count ---- +[discrete] ==== 응답 파라미터 include::{snippets}/block-blocked-by-count/response-fields.adoc[] @@ -124,6 +135,7 @@ include::{snippets}/block-blocked-by-count/response-fields.adoc[] GET /api/v1/blocks/stats/blocking-count ---- +[discrete] ==== 응답 파라미터 include::{snippets}/block-blocking-count/response-fields.adoc[] From 11ae0e957298830c4ec0fda8102d9a1ee41368d6 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 14 Jan 2026 10:45:15 +0900 Subject: [PATCH 41/95] =?UTF-8?q?fix(batch):=20=EC=9D=BC=EC=9D=BC=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EA=B8=B0=EB=B0=98=20=EC=8A=A4=ED=82=B5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 git 브랜치 기반 스킵 로직을 환경(environment) 기반으로 변경 - 문제: dev 환경도 main 브랜치에서 배포되어 gitBranch가 "main"이 됨 - 해결: SPRING_PROFILES_ACTIVE 기반 environment 값으로 판단 - dev 환경에서만 디스코드 리포트 발송 스킵 Co-Authored-By: Claude Opus 4.5 --- .../schedule/DailyDataReportQuartzJob.java | 10 ++--- .../DailyDataReportQuartzJobTest.java | 39 ++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java index 6ba820ff1..84c6ba03e 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java @@ -19,13 +19,13 @@ *

    이 클래스는 BatchQuartzJob을 상속받아 일일 데이터 리포트 배치 작업에 특화된 Quartz Job을 구현합니다. Spring의 @Component 어노테이션을 * 통해 스프링 빈으로 등록되며, QuartzConfig에서 이 클래스를 사용하여 JobDetail과 Trigger를 설정합니다. * - *

    dev 브랜치에서는 실행되지 않습니다. + *

    dev 환경에서는 실행되지 않습니다. */ @Slf4j @Component public class DailyDataReportQuartzJob extends BatchQuartzJob { - private static final String DEV_BRANCH = "dev"; + private static final String DEV_ENVIRONMENT = "dev"; private final AppInfoConfig appInfoConfig; /** @@ -44,9 +44,9 @@ public DailyDataReportQuartzJob( @Override protected void executeInternal(@NonNull JobExecutionContext context) throws JobExecutionException { - String gitBranch = Objects.requireNonNullElse(appInfoConfig.getGitBranch(), "unknown"); - if (DEV_BRANCH.equals(gitBranch)) { - log.info("[BATCH SKIP] dailyDataReportJob skipped on dev branch at: {}", now()); + String environment = Objects.requireNonNullElse(appInfoConfig.getEnvironment(), "unknown"); + if (DEV_ENVIRONMENT.equals(environment)) { + log.info("[BATCH SKIP] dailyDataReportJob skipped on dev environment at: {}", now()); return; } super.executeInternal(context); diff --git a/bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java b/bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java index d9890b708..6a94cc08e 100644 --- a/bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java +++ b/bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java @@ -39,9 +39,12 @@ class DailyDataReportQuartzJobTest { private DailyDataReportQuartzJob dailyDataReportQuartzJob; + private AppInfoConfig appInfoConfig; + @BeforeEach void setUp() { - AppInfoConfig appInfoConfig = new AppInfoConfig(); + appInfoConfig = new AppInfoConfig(); + appInfoConfig.setEnvironment("prod"); dailyDataReportQuartzJob = new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); } @@ -92,4 +95,38 @@ void setUp() { // then assertEquals(DAILY_DATA_REPORT_JOB_NAME, expectedSchedulerName); } + + @Test + @DisplayName("dev 환경에서는 Job이 실행되지 않는다") + void testExecuteInternal_dev환경스킵() throws Exception { + // given + appInfoConfig.setEnvironment("dev"); + dailyDataReportQuartzJob = + new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); + + // when + dailyDataReportQuartzJob.executeInternal(context); + + // then + verify(jobRegistry, times(0)).getJob(any()); + verify(jobLauncher, times(0)).run(any(Job.class), any(JobParameters.class)); + } + + @Test + @DisplayName("prod 환경에서는 Job이 정상 실행된다") + void testExecuteInternal_prod환경실행() throws Exception { + // given + appInfoConfig.setEnvironment("prod"); + dailyDataReportQuartzJob = + new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); + when(jobRegistry.getJob(eq(DAILY_DATA_REPORT_JOB_NAME))).thenReturn(job); + when(jobLauncher.run(any(Job.class), any(JobParameters.class))).thenReturn(null); + + // when + dailyDataReportQuartzJob.executeInternal(context); + + // then + verify(jobRegistry, times(1)).getJob(DAILY_DATA_REPORT_JOB_NAME); + verify(jobLauncher, times(1)).run(any(Job.class), any(JobParameters.class)); + } } From 4cf208b962348f3ffe0df0838f835bdd912844c0 Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 14 Jan 2026 21:36:42 +0900 Subject: [PATCH 42/95] =?UTF-8?q?fix(resource):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20ACTIVATED=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 수정 시 클라이언트가 전체 이미지 목록(기존+신규)을 전송하면 모든 이미지에 대해 ACTIVATED 이벤트가 재발행되어 중복 로그가 저장되는 문제 수정 - ResourceLogRepository에 existsByResourceKeyAndReferenceIdAndEventType 메서드 추가 - ResourceCommandService.activateImageResource에서 중복 체크 후 스킵 로직 추가 - 단위 테스트 및 통합 테스트 추가 Co-Authored-By: Claude Opus 4.5 --- .../file/domain/ResourceLogRepository.java | 3 + .../repository/JpaResourceLogRepository.java | 3 + .../file/service/ResourceCommandService.java | 8 + .../common/file/ImageUploadUnitTest.java | 13 +- .../ImageUploadIntegrationTest.java | 152 ++++++++++++++++++ .../upload/ResourceCommandServiceTest.java | 43 +++++ .../InMemoryResourceLogRepository.java | 13 +- 7 files changed, 233 insertions(+), 2 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java index 824cadbe4..d187b37b4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java @@ -24,5 +24,8 @@ List findByEventTypeAndCreateAtBefore( Optional findLatestByResourceKey(String resourceKey); + boolean existsByResourceKeyAndReferenceIdAndEventType( + String resourceKey, Long referenceId, ResourceEventType eventType); + void delete(ResourceLog resourceLog); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java index a16b9eec4..f0114edf9 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java @@ -28,4 +28,7 @@ List findByEventTypeAndCreateAtBefore( @Query( "SELECT r FROM resource_log r WHERE r.resourceKey = :resourceKey ORDER BY r.createAt DESC LIMIT 1") Optional findLatestByResourceKey(@Param("resourceKey") String resourceKey); + + boolean existsByResourceKeyAndReferenceIdAndEventType( + String resourceKey, Long referenceId, ResourceEventType eventType); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java index d2aa36d1e..af784d472 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java @@ -52,6 +52,14 @@ public CompletableFuture saveImageResourceCreated( @Transactional(propagation = Propagation.REQUIRES_NEW) public CompletableFuture> activateImageResource( String resourceKey, Long referenceId, String referenceType) { + // 이미 동일한 resourceKey, referenceId로 ACTIVATED 로그가 있으면 스킵 + if (resourceLogRepository.existsByResourceKeyAndReferenceIdAndEventType( + resourceKey, referenceId, ResourceEventType.ACTIVATED)) { + log.info( + "이미지 리소스 활성화 로그 스킵 (중복) - resourceKey: {}, referenceId: {}", resourceKey, referenceId); + return CompletableFuture.completedFuture(Optional.empty()); + } + ResourceLog entity = ResourceLog.builder() .userId(getUserIdFromLatestLog(resourceKey)) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index b32b5a130..6df8ba6e4 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -272,7 +272,18 @@ public List findByReferenceIdAndReferenceType( public Optional findLatestByResourceKey(String resourceKey) { return database.values().stream() .filter(log -> log.getResourceKey().equals(resourceKey)) - .max(Comparator.comparing(ResourceLog::getCreateAt)); + .max(Comparator.comparing(ResourceLog::getId)); + } + + @Override + public boolean existsByResourceKeyAndReferenceIdAndEventType( + String resourceKey, Long referenceId, ResourceEventType eventType) { + return database.values().stream() + .anyMatch( + log -> + log.getResourceKey().equals(resourceKey) + && referenceId.equals(log.getReferenceId()) + && log.getEventType() == eventType); } @Override diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index c21cd8919..fea6d02de 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -20,7 +20,9 @@ import app.bottlenote.review.dto.request.LocationInfoRequest; import app.bottlenote.review.dto.request.ReviewCreateRequest; import app.bottlenote.review.dto.request.ReviewImageInfoRequest; +import app.bottlenote.review.dto.request.ReviewModifyRequest; import app.bottlenote.review.dto.response.ReviewCreateResponse; +import app.bottlenote.review.dto.response.ReviewResultResponse; import java.math.BigDecimal; import java.util.List; import java.util.concurrent.TimeUnit; @@ -430,5 +432,155 @@ void test_full_flow_created_to_activated() throws Exception { log.info( "전체 흐름 테스트 완료 - CREATED: {}, ACTIVATED: {}", createdLog.getId(), activatedLog.getId()); } + + @Test + @DisplayName("리뷰 수정 시 기존 이미지는 중복 ACTIVATED 로그가 저장되지 않는다") + void test_modify_review_does_not_duplicate_activated_log() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + // 1. PreSigned URL 생성 (기존 이미지용) + MvcTestResult presignResult = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "1") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse uploadResponse = extractData(presignResult, ImageUploadResponse.class); + String existingImageUrl = uploadResponse.imageUploadInfo().get(0).viewUrl(); + + // CREATED 로그 저장 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(1, logs.size()); + }); + + // 2. 리뷰 생성 (이미지 1개 포함) + ReviewCreateRequest createRequest = + new ReviewCreateRequest( + alcohol.getId(), + ReviewDisplayStatus.PUBLIC, + "최초 리뷰 내용", + SizeType.GLASS, + BigDecimal.valueOf(30000), + createTestLocationInfo(), + List.of(new ReviewImageInfoRequest(1L, existingImageUrl)), + List.of(), + 4.0); + + MvcTestResult createResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse createResponse = extractData(createResult, ReviewCreateResponse.class); + Long reviewId = createResponse.getId(); + + // ACTIVATED 로그 저장 대기 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long activatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.ACTIVATED) + .count(); + assertEquals(1, activatedCount); + }); + + // 3. 새 이미지 PreSigned URL 생성 + MvcTestResult newPresignResult = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "1") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse newUploadResponse = + extractData(newPresignResult, ImageUploadResponse.class); + String newImageUrl = newUploadResponse.imageUploadInfo().get(0).viewUrl(); + + // 새 이미지 CREATED 로그 저장 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long createdCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.CREATED) + .count(); + assertEquals(2, createdCount); + }); + + // 4. 리뷰 수정 (기존 이미지 + 새 이미지) + ReviewModifyRequest modifyRequest = + new ReviewModifyRequest( + "수정된 리뷰 내용", + null, + null, + List.of( + new ReviewImageInfoRequest(1L, existingImageUrl), + new ReviewImageInfoRequest(2L, newImageUrl)), + null, + null, + createTestLocationInfo()); + + // when + MvcTestResult modifyResult = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(modifyRequest)) + .with(csrf()) + .exchange(); + + ReviewResultResponse modifyResponse = extractData(modifyResult, ReviewResultResponse.class); + assertNotNull(modifyResponse); + + // then - ACTIVATED 로그 검증 (기존 이미지는 중복 저장 안 됨) + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long activatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.ACTIVATED) + .count(); + assertEquals(2, activatedCount); + }); + + List allLogs = resourceLogRepository.findByUserId(userId); + List activatedLogs = + allLogs.stream().filter(l -> l.getEventType() == ResourceEventType.ACTIVATED).toList(); + + assertEquals(2, activatedLogs.size()); + assertTrue(activatedLogs.stream().allMatch(l -> reviewId.equals(l.getReferenceId()))); + + log.info("리뷰 수정 후 총 로그 수: {}, ACTIVATED 로그 수: {}", allLogs.size(), activatedLogs.size()); + } } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java index 7aa55aab7..c17a635fd 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java @@ -137,6 +137,49 @@ void test_2() { log.info("저장된 로그 수 = {}", logs.size()); } + + @Test + @DisplayName("이미 활성화된 리소스에 대해 동일한 referenceId로 다시 활성화할 때 중복 로그가 저장되지 않는다") + void test_3() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + Long referenceId = 100L; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.activateImageResource(resourceKey, referenceId, "REVIEW").join(); + + // when + Optional result = + resourceCommandService.activateImageResource(resourceKey, referenceId, "REVIEW").join(); + + // then + assertTrue(result.isEmpty()); + List logs = resourceCommandService.findByResourceKey(resourceKey); + long activatedCount = + logs.stream().filter(l -> l.eventType() == ResourceEventType.ACTIVATED).count(); + assertEquals(1, activatedCount); + + log.info("중복 활성화 시도 후 ACTIVATED 로그 수 = {}", activatedCount); + } + + @Test + @DisplayName("같은 리소스 키라도 다른 referenceId로 활성화할 때는 각각 저장된다") + void test_4() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + + // when + resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); + resourceCommandService.activateImageResource(resourceKey, 200L, "REVIEW").join(); + + // then + List logs = resourceCommandService.findByResourceKey(resourceKey); + long activatedCount = + logs.stream().filter(l -> l.eventType() == ResourceEventType.ACTIVATED).count(); + assertEquals(2, activatedCount); + + log.info("서로 다른 referenceId로 활성화 후 ACTIVATED 로그 수 = {}", activatedCount); + } } @Nested diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java index 64b19d180..8331394fd 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java @@ -69,7 +69,18 @@ public List findByReferenceIdAndReferenceType( public Optional findLatestByResourceKey(String resourceKey) { return database.values().stream() .filter(log -> log.getResourceKey().equals(resourceKey)) - .max(Comparator.comparing(ResourceLog::getCreateAt)); + .max(Comparator.comparing(ResourceLog::getId)); + } + + @Override + public boolean existsByResourceKeyAndReferenceIdAndEventType( + String resourceKey, Long referenceId, ResourceEventType eventType) { + return database.values().stream() + .anyMatch( + log -> + log.getResourceKey().equals(resourceKey) + && referenceId.equals(log.getReferenceId()) + && log.getEventType() == eventType); } @Override From 29991026b39a53519afe2a32d427d1b0371ec78f Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 14 Jan 2026 22:21:03 +0900 Subject: [PATCH 43/95] =?UTF-8?q?fix(test):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=ED=95=84=EC=88=98=20status=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReviewModifyRequest의 status가 @NotNull 필수값이므로 테스트에 추가 Co-Authored-By: Claude Opus 4.5 --- .../common/file/integration/ImageUploadIntegrationTest.java | 2 +- tmpclaude-d340-cwd | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 tmpclaude-d340-cwd diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index fea6d02de..837cb0c44 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -537,7 +537,7 @@ void test_modify_review_does_not_duplicate_activated_log() throws Exception { ReviewModifyRequest modifyRequest = new ReviewModifyRequest( "수정된 리뷰 내용", - null, + ReviewDisplayStatus.PUBLIC, null, List.of( new ReviewImageInfoRequest(1L, existingImageUrl), diff --git a/tmpclaude-d340-cwd b/tmpclaude-d340-cwd new file mode 100644 index 000000000..a8c84d34f --- /dev/null +++ b/tmpclaude-d340-cwd @@ -0,0 +1 @@ +/c/Users/rlagu/.claude-worktrees/bottle-note-api-server/exciting-germain From 80e7cfeb2fcd3762a659298cd4a24c1012260218 Mon Sep 17 00:00:00 2001 From: rlagu Date: Thu, 15 Jan 2026 01:08:00 +0900 Subject: [PATCH 44/95] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/file/domain/ResourceLog.java | 73 ++++++++++---- .../file/domain/ResourceLogRepository.java | 7 +- .../dto/response/ResourceLogResponse.java | 3 +- .../repository/JpaResourceLogRepository.java | 9 +- .../file/service/ResourceCommandService.java | 84 +++++++--------- .../common/file/ImageUploadUnitTest.java | 33 ++----- ...mageResourceActivatedEventPublishTest.java | 65 +++++-------- .../ImageUploadIntegrationTest.java | 92 +++++++----------- .../upload/ResourceCommandServiceTest.java | 96 ++++++++++--------- .../InMemoryResourceLogRepository.java | 33 ++----- .../DailyDataReportIntegrationTest.java | 2 +- git.environment-variables | 2 +- 12 files changed, 223 insertions(+), 276 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java index 6863e3a6f..3f7accf91 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLog.java @@ -1,5 +1,6 @@ package app.bottlenote.common.file.domain; +import app.bottlenote.common.domain.BaseEntity; import app.bottlenote.common.file.constant.ResourceEventType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -9,20 +10,17 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; -import org.springframework.data.annotation.CreatedBy; -import org.springframework.data.annotation.CreatedDate; @Getter @Entity(name = "resource_log") @Table(name = "resource_logs") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ResourceLog { +public class ResourceLog extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -33,7 +31,7 @@ public class ResourceLog { private Long userId; @Comment("리소스 키 (S3 객체 키 등)") - @Column(name = "resource_key", nullable = false, length = 1024) + @Column(name = "resource_key", nullable = false, length = 1024, unique = true) private String resourceKey; @Comment("리소스 타입: IMAGE") @@ -65,16 +63,6 @@ public class ResourceLog { @Column(name = "bucket_name", length = 128) private String bucketName; - @CreatedDate - @Comment("이벤트 발생일") - @Column(name = "create_at", nullable = false, updatable = false) - private LocalDateTime createAt; - - @CreatedBy - @Comment("이벤트 발생자") - @Column(name = "create_by", length = 255) - private String createBy; - @Builder public ResourceLog( Long id, @@ -86,9 +74,7 @@ public ResourceLog( String referenceType, String viewUrl, String rootPath, - String bucketName, - LocalDateTime createAt, - String createBy) { + String bucketName) { this.id = id; this.userId = userId; this.resourceKey = resourceKey; @@ -99,7 +85,54 @@ public ResourceLog( this.viewUrl = viewUrl; this.rootPath = rootPath; this.bucketName = bucketName; - this.createAt = createAt != null ? createAt : LocalDateTime.now(); - this.createBy = createBy; + } + + /** 리소스를 활성화 상태로 변경합니다. */ + public ResourceLog activate(Long referenceId, String referenceType) { + validateStateTransition(ResourceEventType.ACTIVATED); + this.eventType = ResourceEventType.ACTIVATED; + this.referenceId = referenceId; + this.referenceType = referenceType; + return this; + } + + /** 리소스를 무효화 상태로 변경합니다. */ + public ResourceLog invalidate() { + validateStateTransition(ResourceEventType.INVALIDATED); + this.eventType = ResourceEventType.INVALIDATED; + return this; + } + + /** 리소스를 삭제 상태로 변경합니다. */ + public ResourceLog markDeleted() { + validateStateTransition(ResourceEventType.DELETED); + this.eventType = ResourceEventType.DELETED; + return this; + } + + private void validateStateTransition(ResourceEventType newState) { + if (!canTransitionTo(newState)) { + throw new IllegalStateException( + String.format("%s 상태에서 %s 상태로 전이할 수 없습니다.", this.eventType, newState)); + } + } + + /** 현재 상태에서 새 상태로 전이가 가능한지 확인합니다. */ + public boolean canTransitionTo(ResourceEventType newState) { + return switch (this.eventType) { + case CREATED -> + newState == ResourceEventType.ACTIVATED || newState == ResourceEventType.INVALIDATED; + case ACTIVATED -> newState == ResourceEventType.INVALIDATED; + case INVALIDATED -> newState == ResourceEventType.DELETED; + case DELETED -> false; + }; + } + + public boolean isActivated() { + return this.eventType == ResourceEventType.ACTIVATED; + } + + public boolean isInvalidated() { + return this.eventType == ResourceEventType.INVALIDATED; } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java index d187b37b4..d702e95d2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/domain/ResourceLogRepository.java @@ -13,7 +13,7 @@ public interface ResourceLogRepository { Optional findById(Long id); - List findByResourceKey(String resourceKey); + Optional findByResourceKey(String resourceKey); List findByUserId(Long userId); @@ -22,10 +22,5 @@ List findByEventTypeAndCreateAtBefore( List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); - Optional findLatestByResourceKey(String resourceKey); - - boolean existsByResourceKeyAndReferenceIdAndEventType( - String resourceKey, Long referenceId, ResourceEventType eventType); - void delete(ResourceLog resourceLog); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java index e4bf219df..fed807405 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/response/ResourceLogResponse.java @@ -16,4 +16,5 @@ public record ResourceLogResponse( String viewUrl, String rootPath, String bucketName, - LocalDateTime createAt) {} + LocalDateTime createAt, + LocalDateTime lastModifyAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java index f0114edf9..7cc1c456f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/repository/JpaResourceLogRepository.java @@ -15,7 +15,7 @@ public interface JpaResourceLogRepository extends ResourceLogRepository, JpaRepository { - List findByResourceKey(String resourceKey); + Optional findByResourceKey(String resourceKey); List findByUserId(Long userId); @@ -24,11 +24,4 @@ List findByEventTypeAndCreateAtBefore( @Param("eventType") ResourceEventType eventType, @Param("dateTime") LocalDateTime dateTime); List findByReferenceIdAndReferenceType(Long referenceId, String referenceType); - - @Query( - "SELECT r FROM resource_log r WHERE r.resourceKey = :resourceKey ORDER BY r.createAt DESC LIMIT 1") - Optional findLatestByResourceKey(@Param("resourceKey") String resourceKey); - - boolean existsByResourceKeyAndReferenceIdAndEventType( - String resourceKey, Long referenceId, ResourceEventType eventType); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java index af784d472..aaddf9cd1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java @@ -52,26 +52,23 @@ public CompletableFuture saveImageResourceCreated( @Transactional(propagation = Propagation.REQUIRES_NEW) public CompletableFuture> activateImageResource( String resourceKey, Long referenceId, String referenceType) { - // 이미 동일한 resourceKey, referenceId로 ACTIVATED 로그가 있으면 스킵 - if (resourceLogRepository.existsByResourceKeyAndReferenceIdAndEventType( - resourceKey, referenceId, ResourceEventType.ACTIVATED)) { - log.info( - "이미지 리소스 활성화 로그 스킵 (중복) - resourceKey: {}, referenceId: {}", resourceKey, referenceId); + Optional resourceLogOpt = resourceLogRepository.findByResourceKey(resourceKey); + + if (resourceLogOpt.isEmpty()) { + log.warn("리소스 로그를 찾을 수 없음 - resourceKey: {}", resourceKey); return CompletableFuture.completedFuture(Optional.empty()); } - ResourceLog entity = - ResourceLog.builder() - .userId(getUserIdFromLatestLog(resourceKey)) - .resourceKey(resourceKey) - .resourceType(RESOURCE_TYPE_IMAGE) - .eventType(ResourceEventType.ACTIVATED) - .referenceId(referenceId) - .referenceType(referenceType) - .viewUrl(getViewUrlFromLatestLog(resourceKey)) - .build(); - ResourceLog saved = resourceLogRepository.save(entity); - log.info("이미지 리소스 활성화 로그 저장 - resourceKey: {}, referenceId: {}", resourceKey, referenceId); + ResourceLog resourceLog = resourceLogOpt.get(); + + if (resourceLog.isActivated()) { + log.info("이미 활성화된 리소스 스킵 - resourceKey: {}", resourceKey); + return CompletableFuture.completedFuture(Optional.empty()); + } + + resourceLog.activate(referenceId, referenceType); + ResourceLog saved = resourceLogRepository.save(resourceLog); + log.info("이미지 리소스 활성화 - resourceKey: {}, referenceId: {}", resourceKey, referenceId); return CompletableFuture.completedFuture(Optional.of(toResponse(saved))); } @@ -79,22 +76,29 @@ public CompletableFuture> activateImageResource( @Transactional(propagation = Propagation.REQUIRES_NEW) public CompletableFuture> invalidateImageResource( String resourceKey) { - ResourceLog entity = - ResourceLog.builder() - .userId(getUserIdFromLatestLog(resourceKey)) - .resourceKey(resourceKey) - .resourceType(RESOURCE_TYPE_IMAGE) - .eventType(ResourceEventType.INVALIDATED) - .viewUrl(getViewUrlFromLatestLog(resourceKey)) - .build(); - ResourceLog saved = resourceLogRepository.save(entity); - log.info("이미지 리소스 무효화 로그 저장 - resourceKey: {}", resourceKey); + Optional resourceLogOpt = resourceLogRepository.findByResourceKey(resourceKey); + + if (resourceLogOpt.isEmpty()) { + log.warn("리소스 로그를 찾을 수 없음 - resourceKey: {}", resourceKey); + return CompletableFuture.completedFuture(Optional.empty()); + } + + ResourceLog resourceLog = resourceLogOpt.get(); + + if (resourceLog.isInvalidated()) { + log.info("이미 무효화된 리소스 스킵 - resourceKey: {}", resourceKey); + return CompletableFuture.completedFuture(Optional.empty()); + } + + resourceLog.invalidate(); + ResourceLog saved = resourceLogRepository.save(resourceLog); + log.info("이미지 리소스 무효화 - resourceKey: {}", resourceKey); return CompletableFuture.completedFuture(Optional.of(toResponse(saved))); } @Transactional(readOnly = true) - public Optional findLatestByResourceKey(String resourceKey) { - return resourceLogRepository.findLatestByResourceKey(resourceKey).map(this::toResponse); + public Optional findByResourceKey(String resourceKey) { + return resourceLogRepository.findByResourceKey(resourceKey).map(this::toResponse); } @Transactional(readOnly = true) @@ -105,27 +109,6 @@ public List findByEventTypeAndCreateAtBefore( .toList(); } - @Transactional(readOnly = true) - public List findByResourceKey(String resourceKey) { - return resourceLogRepository.findByResourceKey(resourceKey).stream() - .map(this::toResponse) - .toList(); - } - - private Long getUserIdFromLatestLog(String resourceKey) { - return resourceLogRepository - .findLatestByResourceKey(resourceKey) - .map(ResourceLog::getUserId) - .orElse(null); - } - - private String getViewUrlFromLatestLog(String resourceKey) { - return resourceLogRepository - .findLatestByResourceKey(resourceKey) - .map(ResourceLog::getViewUrl) - .orElse(null); - } - private ResourceLogItem toItem(ResourceLog log) { return ResourceLogItem.builder() .id(log.getId()) @@ -155,6 +138,7 @@ private ResourceLogResponse toResponse(ResourceLog log) { .rootPath(log.getRootPath()) .bucketName(log.getBucketName()) .createAt(log.getCreateAt()) + .lastModifyAt(log.getLastModifyAt()) .build(); } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index 6df8ba6e4..c573a6265 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -20,7 +20,6 @@ import java.net.HttpURLConnection; import java.net.URL; import java.time.LocalDateTime; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -221,6 +220,7 @@ void test_2() { static class InMemoryResourceLogRepository implements ResourceLogRepository { private final Map database = new HashMap<>(); + private final Map resourceKeyIndex = new HashMap<>(); @Override public ResourceLog save(ResourceLog resourceLog) { @@ -230,6 +230,7 @@ public ResourceLog save(ResourceLog resourceLog) { ReflectionTestUtils.setField(resourceLog, "id", id); } database.put(id, resourceLog); + resourceKeyIndex.put(resourceLog.getResourceKey(), id); return resourceLog; } @@ -239,10 +240,12 @@ public Optional findById(Long id) { } @Override - public List findByResourceKey(String resourceKey) { - return database.values().stream() - .filter(log -> log.getResourceKey().equals(resourceKey)) - .toList(); + public Optional findByResourceKey(String resourceKey) { + Long id = resourceKeyIndex.get(resourceKey); + if (id == null) { + return Optional.empty(); + } + return Optional.ofNullable(database.get(id)); } @Override @@ -268,31 +271,15 @@ public List findByReferenceIdAndReferenceType( .toList(); } - @Override - public Optional findLatestByResourceKey(String resourceKey) { - return database.values().stream() - .filter(log -> log.getResourceKey().equals(resourceKey)) - .max(Comparator.comparing(ResourceLog::getId)); - } - - @Override - public boolean existsByResourceKeyAndReferenceIdAndEventType( - String resourceKey, Long referenceId, ResourceEventType eventType) { - return database.values().stream() - .anyMatch( - log -> - log.getResourceKey().equals(resourceKey) - && referenceId.equals(log.getReferenceId()) - && log.getEventType() == eventType); - } - @Override public void delete(ResourceLog resourceLog) { + resourceKeyIndex.remove(resourceLog.getResourceKey()); database.remove(resourceLog.getId()); } public void clear() { database.clear(); + resourceKeyIndex.clear(); } public List findAll() { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java index 81c702276..b82c31b95 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java @@ -1,8 +1,6 @@ package app.bottlenote.common.file.event; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import app.bottlenote.alcohols.fixture.FakeAlcoholFacade; @@ -39,6 +37,7 @@ import app.bottlenote.user.facade.payload.UserProfileItem; import app.bottlenote.user.fixture.FakeUserFacade; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -370,17 +369,11 @@ void test_event_listener_creates_activated_log() { // when resourceEventListener.handleImageResourceActivated(event); - // then - List logs = resourceLogRepository.findByResourceKey(resourceKey); - assertFalse(logs.isEmpty()); - - ResourceLog activatedLog = - logs.stream() - .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) - .findFirst() - .orElse(null); + // then (Single Record 방식: 상태가 ACTIVATED로 변경) + Optional logOpt = resourceLogRepository.findByResourceKey(resourceKey); + assertTrue(logOpt.isPresent()); - assertNotNull(activatedLog); + ResourceLog activatedLog = logOpt.get(); assertEquals(ResourceEventType.ACTIVATED, activatedLog.getEventType()); assertEquals(referenceId, activatedLog.getReferenceId()); assertEquals(referenceType, activatedLog.getReferenceType()); @@ -422,23 +415,18 @@ void test_multiple_resources_create_multiple_activated_logs() { // when resourceEventListener.handleImageResourceActivated(event); - // then - List logs1 = resourceLogRepository.findByResourceKey(resourceKey1); - List logs2 = resourceLogRepository.findByResourceKey(resourceKey2); - - ResourceLog activated1 = - logs1.stream() - .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) - .findFirst() - .orElse(null); - ResourceLog activated2 = - logs2.stream() - .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) - .findFirst() - .orElse(null); - - assertNotNull(activated1); - assertNotNull(activated2); + // then (Single Record 방식: 각 레코드의 상태가 ACTIVATED로 변경) + Optional logOpt1 = resourceLogRepository.findByResourceKey(resourceKey1); + Optional logOpt2 = resourceLogRepository.findByResourceKey(resourceKey2); + + assertTrue(logOpt1.isPresent()); + assertTrue(logOpt2.isPresent()); + + ResourceLog activated1 = logOpt1.get(); + ResourceLog activated2 = logOpt2.get(); + + assertEquals(ResourceEventType.ACTIVATED, activated1.getEventType()); + assertEquals(ResourceEventType.ACTIVATED, activated2.getEventType()); assertEquals(referenceId, activated1.getReferenceId()); assertEquals(referenceId, activated2.getReferenceId()); assertEquals(referenceType, activated1.getReferenceType()); @@ -446,7 +434,7 @@ void test_multiple_resources_create_multiple_activated_logs() { } @Test - @DisplayName("CREATED 로그가 있는 리소스를 활성화할 때 CREATED와 ACTIVATED 로그가 모두 존재한다") + @DisplayName("CREATED 상태의 리소스를 활성화하면 해당 레코드의 상태가 ACTIVATED로 변경된다 (Single Record)") void test_resource_log_sequence_created_to_activated() { // given String resourceKey = "business/document.pdf"; @@ -470,17 +458,14 @@ void test_resource_log_sequence_created_to_activated() { // when resourceEventListener.handleImageResourceActivated(event); - // then - List logs = resourceLogRepository.findByResourceKey(resourceKey); - assertEquals(2, logs.size()); - - boolean hasCreated = - logs.stream().anyMatch(log -> log.getEventType() == ResourceEventType.CREATED); - boolean hasActivated = - logs.stream().anyMatch(log -> log.getEventType() == ResourceEventType.ACTIVATED); + // then (Single Record 방식: 1개의 레코드, 상태만 변경) + Optional logOpt = resourceLogRepository.findByResourceKey(resourceKey); + assertTrue(logOpt.isPresent()); - assertTrue(hasCreated); - assertTrue(hasActivated); + ResourceLog log = logOpt.get(); + assertEquals(ResourceEventType.ACTIVATED, log.getEventType()); + assertEquals(referenceId, log.getReferenceId()); + assertEquals(referenceType, log.getReferenceType()); } } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index 837cb0c44..f5a761432 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -197,7 +197,7 @@ void test_1() throws Exception { class ResourceActivationTest { @Test - @DisplayName("리뷰 생성 시 이미지가 포함되면 ResourceLog에 ACTIVATED 이벤트가 저장된다") + @DisplayName("리뷰 생성 시 이미지가 포함되면 ResourceLog 상태가 ACTIVATED로 변경된다") void test_review_with_images_creates_activated_log() throws Exception { // given String token = getToken(); @@ -260,7 +260,7 @@ void test_review_with_images_creates_activated_log() throws Exception { ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); assertNotNull(reviewResponse.getId()); - // then - ACTIVATED 로그 저장 대기 + // then - ACTIVATED 상태로 변경 대기 (Single Record 방식) Awaitility.await() .atMost(5, TimeUnit.SECONDS) .untilAsserted( @@ -273,15 +273,11 @@ void test_review_with_images_creates_activated_log() throws Exception { assertEquals(2, activatedCount); }); - // ACTIVATED 로그 검증 + // ACTIVATED 로그 검증 (총 레코드 수는 2개로 유지) List allLogs = resourceLogRepository.findByUserId(userId); - List activatedLogs = - allLogs.stream() - .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) - .toList(); + assertEquals(2, allLogs.size()); - assertEquals(2, activatedLogs.size()); - activatedLogs.forEach( + allLogs.forEach( activatedLog -> { assertEquals(ResourceEventType.ACTIVATED, activatedLog.getEventType()); assertEquals(reviewResponse.getId(), activatedLog.getReferenceId()); @@ -289,7 +285,7 @@ void test_review_with_images_creates_activated_log() throws Exception { assertTrue(activatedLog.getResourceKey().startsWith("review/")); }); - log.info("ACTIVATED 로그 수: {}", activatedLogs.size()); + log.info("총 로그 수: {}, 모두 ACTIVATED 상태", allLogs.size()); } @Test @@ -337,7 +333,7 @@ void test_review_without_images_does_not_create_activated_log() throws Exception } @Test - @DisplayName("PreSigned URL 생성부터 리뷰 생성까지 전체 흐름에서 CREATED와 ACTIVATED 로그가 순차적으로 저장된다") + @DisplayName("PreSigned URL 생성부터 리뷰 생성까지 전체 흐름에서 단일 레코드의 상태가 CREATED에서 ACTIVATED로 변경된다") void test_full_flow_created_to_activated() throws Exception { // given String token = getToken(); @@ -369,7 +365,7 @@ void test_full_flow_created_to_activated() throws Exception { assertEquals(ResourceEventType.CREATED, logs.get(0).getEventType()); }); - // 2. 리뷰 생성 -> ACTIVATED 로그 + // 2. 리뷰 생성 -> ACTIVATED 상태로 변경 ReviewCreateRequest reviewRequest = new ReviewCreateRequest( alcohol.getId(), @@ -394,47 +390,35 @@ void test_full_flow_created_to_activated() throws Exception { ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); - // ACTIVATED 로그 대기 + // ACTIVATED 상태로 변경 대기 (Single Record 방식: 레코드 수는 1개 유지) Awaitility.await() .atMost(5, TimeUnit.SECONDS) .untilAsserted( () -> { List logs = resourceLogRepository.findByUserId(userId); - assertEquals(2, logs.size()); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.ACTIVATED, logs.get(0).getEventType()); }); - // then - 전체 로그 검증 + // then - 단일 레코드 검증 List allLogs = resourceLogRepository.findByUserId(userId); - assertEquals(2, allLogs.size()); + assertEquals(1, allLogs.size()); - ResourceLog createdLog = - allLogs.stream() - .filter(log -> log.getEventType() == ResourceEventType.CREATED) - .findFirst() - .orElseThrow(); - ResourceLog activatedLog = - allLogs.stream() - .filter(log -> log.getEventType() == ResourceEventType.ACTIVATED) - .findFirst() - .orElseThrow(); - - // CREATED 로그 검증 - assertEquals(ResourceEventType.CREATED, createdLog.getEventType()); - assertEquals(userId, createdLog.getUserId()); - assertTrue(createdLog.getResourceKey().startsWith("review/")); - - // ACTIVATED 로그 검증 - assertEquals(ResourceEventType.ACTIVATED, activatedLog.getEventType()); - assertEquals(reviewResponse.getId(), activatedLog.getReferenceId()); - assertEquals("REVIEW", activatedLog.getReferenceType()); - assertEquals(createdLog.getResourceKey(), activatedLog.getResourceKey()); + ResourceLog resourceLog = allLogs.get(0); + + // ACTIVATED 상태 검증 + assertEquals(ResourceEventType.ACTIVATED, resourceLog.getEventType()); + assertEquals(reviewResponse.getId(), resourceLog.getReferenceId()); + assertEquals("REVIEW", resourceLog.getReferenceType()); + assertEquals(userId, resourceLog.getUserId()); + assertTrue(resourceLog.getResourceKey().startsWith("review/")); log.info( - "전체 흐름 테스트 완료 - CREATED: {}, ACTIVATED: {}", createdLog.getId(), activatedLog.getId()); + "전체 흐름 테스트 완료 - 레코드 ID: {}, 상태: {}", resourceLog.getId(), resourceLog.getEventType()); } @Test - @DisplayName("리뷰 수정 시 기존 이미지는 중복 ACTIVATED 로그가 저장되지 않는다") + @DisplayName("리뷰 수정 시 기존 이미지는 이미 ACTIVATED 상태이므로 상태가 유지되고, 새 이미지만 ACTIVATED로 변경된다") void test_modify_review_does_not_duplicate_activated_log() throws Exception { // given String token = getToken(); @@ -491,17 +475,14 @@ void test_modify_review_does_not_duplicate_activated_log() throws Exception { ReviewCreateResponse createResponse = extractData(createResult, ReviewCreateResponse.class); Long reviewId = createResponse.getId(); - // ACTIVATED 로그 저장 대기 + // ACTIVATED 상태로 변경 대기 (Single Record 방식) Awaitility.await() .atMost(5, TimeUnit.SECONDS) .untilAsserted( () -> { List logs = resourceLogRepository.findByUserId(userId); - long activatedCount = - logs.stream() - .filter(l -> l.getEventType() == ResourceEventType.ACTIVATED) - .count(); - assertEquals(1, activatedCount); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.ACTIVATED, logs.get(0).getEventType()); }); // 3. 새 이미지 PreSigned URL 생성 @@ -520,17 +501,13 @@ void test_modify_review_does_not_duplicate_activated_log() throws Exception { extractData(newPresignResult, ImageUploadResponse.class); String newImageUrl = newUploadResponse.imageUploadInfo().get(0).viewUrl(); - // 새 이미지 CREATED 로그 저장 대기 + // 새 이미지 CREATED 로그 저장 대기 (총 2개: 기존 ACTIVATED 1개 + 새 CREATED 1개) Awaitility.await() .atMost(3, TimeUnit.SECONDS) .untilAsserted( () -> { List logs = resourceLogRepository.findByUserId(userId); - long createdCount = - logs.stream() - .filter(l -> l.getEventType() == ResourceEventType.CREATED) - .count(); - assertEquals(2, createdCount); + assertEquals(2, logs.size()); }); // 4. 리뷰 수정 (기존 이미지 + 새 이미지) @@ -560,12 +537,13 @@ void test_modify_review_does_not_duplicate_activated_log() throws Exception { ReviewResultResponse modifyResponse = extractData(modifyResult, ReviewResultResponse.class); assertNotNull(modifyResponse); - // then - ACTIVATED 로그 검증 (기존 이미지는 중복 저장 안 됨) + // then - 모든 로그가 ACTIVATED 상태로 변경됨 (총 2개 레코드 유지) Awaitility.await() .atMost(5, TimeUnit.SECONDS) .untilAsserted( () -> { List logs = resourceLogRepository.findByUserId(userId); + assertEquals(2, logs.size()); long activatedCount = logs.stream() .filter(l -> l.getEventType() == ResourceEventType.ACTIVATED) @@ -574,13 +552,11 @@ void test_modify_review_does_not_duplicate_activated_log() throws Exception { }); List allLogs = resourceLogRepository.findByUserId(userId); - List activatedLogs = - allLogs.stream().filter(l -> l.getEventType() == ResourceEventType.ACTIVATED).toList(); - - assertEquals(2, activatedLogs.size()); - assertTrue(activatedLogs.stream().allMatch(l -> reviewId.equals(l.getReferenceId()))); + assertEquals(2, allLogs.size()); + assertTrue(allLogs.stream().allMatch(l -> l.getEventType() == ResourceEventType.ACTIVATED)); + assertTrue(allLogs.stream().allMatch(l -> reviewId.equals(l.getReferenceId()))); - log.info("리뷰 수정 후 총 로그 수: {}, ACTIVATED 로그 수: {}", allLogs.size(), activatedLogs.size()); + log.info("리뷰 수정 후 총 로그 수: {}, 모두 ACTIVATED 상태", allLogs.size()); } } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java index c17a635fd..099508ead 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java @@ -97,11 +97,11 @@ void test_2() { } @Nested - @DisplayName("이미지 리소스 활성화 로그 저장 테스트") + @DisplayName("이미지 리소스 활성화 테스트") class ActivateImageResourceTest { @Test - @DisplayName("이미지 리소스를 활성화할 때 ACTIVATED 이벤트로 저장된다") + @DisplayName("이미지 리소스를 활성화하면 기존 레코드의 상태가 ACTIVATED로 변경된다") void test_1() { // given String resourceKey = "review/20251231/1-uuid.jpg"; @@ -122,7 +122,7 @@ void test_1() { } @Test - @DisplayName("같은 리소스 키에 대해 CREATED, ACTIVATED 두 개의 로그가 저장된다") + @DisplayName("활성화 후에도 레코드 수는 1개로 유지된다 (Single Record)") void test_2() { // given String resourceKey = "review/20251231/1-uuid.jpg"; @@ -132,14 +132,16 @@ void test_2() { resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); // then - List logs = resourceCommandService.findByResourceKey(resourceKey); - assertEquals(2, logs.size()); + assertEquals(1, resourceLogRepository.findAll().size()); + Optional logOpt = resourceCommandService.findByResourceKey(resourceKey); + assertTrue(logOpt.isPresent()); + assertEquals(ResourceEventType.ACTIVATED, logOpt.get().eventType()); - log.info("저장된 로그 수 = {}", logs.size()); + log.info("저장된 로그 수 = {}", resourceLogRepository.findAll().size()); } @Test - @DisplayName("이미 활성화된 리소스에 대해 동일한 referenceId로 다시 활성화할 때 중복 로그가 저장되지 않는다") + @DisplayName("이미 활성화된 리소스에 대해 다시 활성화해도 상태가 유지된다") void test_3() { // given String resourceKey = "review/20251231/1-uuid.jpg"; @@ -153,41 +155,18 @@ void test_3() { // then assertTrue(result.isEmpty()); - List logs = resourceCommandService.findByResourceKey(resourceKey); - long activatedCount = - logs.stream().filter(l -> l.eventType() == ResourceEventType.ACTIVATED).count(); - assertEquals(1, activatedCount); + assertEquals(1, resourceLogRepository.findAll().size()); - log.info("중복 활성화 시도 후 ACTIVATED 로그 수 = {}", activatedCount); - } - - @Test - @DisplayName("같은 리소스 키라도 다른 referenceId로 활성화할 때는 각각 저장된다") - void test_4() { - // given - String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); - - // when - resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); - resourceCommandService.activateImageResource(resourceKey, 200L, "REVIEW").join(); - - // then - List logs = resourceCommandService.findByResourceKey(resourceKey); - long activatedCount = - logs.stream().filter(l -> l.eventType() == ResourceEventType.ACTIVATED).count(); - assertEquals(2, activatedCount); - - log.info("서로 다른 referenceId로 활성화 후 ACTIVATED 로그 수 = {}", activatedCount); + log.info("중복 활성화 시도 결과 = {}", result); } } @Nested - @DisplayName("이미지 리소스 무효화 로그 저장 테스트") + @DisplayName("이미지 리소스 무효화 테스트") class InvalidateImageResourceTest { @Test - @DisplayName("이미지 리소스를 무효화할 때 INVALIDATED 이벤트로 저장된다") + @DisplayName("이미지 리소스를 무효화하면 기존 레코드의 상태가 INVALIDATED로 변경된다") void test_1() { // given String resourceKey = "review/20251231/1-uuid.jpg"; @@ -201,9 +180,29 @@ void test_1() { // then assertTrue(result.isPresent()); assertEquals(ResourceEventType.INVALIDATED, result.get().eventType()); + assertEquals(1, resourceLogRepository.findAll().size()); log.info("무효화 결과 = {}", result.get()); } + + @Test + @DisplayName("이미 무효화된 리소스에 대해 다시 무효화해도 상태가 유지된다") + void test_2() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.invalidateImageResource(resourceKey).join(); + + // when + Optional result = + resourceCommandService.invalidateImageResource(resourceKey).join(); + + // then + assertTrue(result.isEmpty()); + assertEquals(1, resourceLogRepository.findAll().size()); + + log.info("중복 무효화 시도 결과 = {}", result); + } } @Nested @@ -211,7 +210,7 @@ void test_1() { class FindResourceLogTest { @Test - @DisplayName("resourceKey로 최신 로그를 조회할 때 가장 최근 로그를 반환한다") + @DisplayName("resourceKey로 로그를 조회할 때 해당 레코드를 반환한다") void test_1() { // given String resourceKey = "review/20251231/1-uuid.jpg"; @@ -219,14 +218,13 @@ void test_1() { resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); // when - Optional result = - resourceCommandService.findLatestByResourceKey(resourceKey); + Optional result = resourceCommandService.findByResourceKey(resourceKey); // then assertTrue(result.isPresent()); assertEquals(ResourceEventType.ACTIVATED, result.get().eventType()); - log.info("최신 로그 = {}", result.get()); + log.info("조회된 로그 = {}", result.get()); } @Test @@ -236,8 +234,7 @@ void test_2() { String resourceKey = "non-existent-key.jpg"; // when - Optional result = - resourceCommandService.findLatestByResourceKey(resourceKey); + Optional result = resourceCommandService.findByResourceKey(resourceKey); // then assertTrue(result.isEmpty()); @@ -285,17 +282,26 @@ void test_1() { } @Test - @DisplayName("ACTIVATED 상태의 로그는 CREATED 조회 시 제외된다") + @DisplayName("ACTIVATED로 상태가 변경된 로그는 CREATED 조회 시 제외된다") void test_2() { // given - String resourceKey = "review/20251231/1-uuid1.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + String resourceKey1 = "review/20251231/1-uuid1.jpg"; + String resourceKey2 = "review/20251231/2-uuid2.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey1)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(2L, resourceKey2)).join(); + resourceLogRepository .findById(1L) .ifPresent( log -> ReflectionTestUtils.setField(log, "createAt", LocalDateTime.now().minusDays(7))); - resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); + resourceLogRepository + .findById(2L) + .ifPresent( + log -> + ReflectionTestUtils.setField(log, "createAt", LocalDateTime.now().minusDays(7))); + + resourceCommandService.activateImageResource(resourceKey1, 100L, "REVIEW").join(); // when List result = @@ -304,7 +310,7 @@ void test_2() { // then assertEquals(1, result.size()); - assertEquals(ResourceEventType.CREATED, result.get(0).eventType()); + assertEquals(resourceKey2, result.get(0).resourceKey()); log.info("조회된 로그 수 = {}", result.size()); } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java index 8331394fd..b1f94e7ce 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/InMemoryResourceLogRepository.java @@ -4,7 +4,6 @@ import app.bottlenote.common.file.domain.ResourceLog; import app.bottlenote.common.file.domain.ResourceLogRepository; import java.time.LocalDateTime; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -17,6 +16,7 @@ public class InMemoryResourceLogRepository implements ResourceLogRepository { private static final Logger log = LogManager.getLogger(InMemoryResourceLogRepository.class); private final Map database = new HashMap<>(); + private final Map resourceKeyIndex = new HashMap<>(); @Override public ResourceLog save(ResourceLog resourceLog) { @@ -26,6 +26,7 @@ public ResourceLog save(ResourceLog resourceLog) { ReflectionTestUtils.setField(resourceLog, "id", id); } database.put(id, resourceLog); + resourceKeyIndex.put(resourceLog.getResourceKey(), id); log.info("[InMemory] resourceLog repository save = {}", resourceLog); return resourceLog; } @@ -36,10 +37,12 @@ public Optional findById(Long id) { } @Override - public List findByResourceKey(String resourceKey) { - return database.values().stream() - .filter(log -> log.getResourceKey().equals(resourceKey)) - .toList(); + public Optional findByResourceKey(String resourceKey) { + Long id = resourceKeyIndex.get(resourceKey); + if (id == null) { + return Optional.empty(); + } + return Optional.ofNullable(database.get(id)); } @Override @@ -65,31 +68,15 @@ public List findByReferenceIdAndReferenceType( .toList(); } - @Override - public Optional findLatestByResourceKey(String resourceKey) { - return database.values().stream() - .filter(log -> log.getResourceKey().equals(resourceKey)) - .max(Comparator.comparing(ResourceLog::getId)); - } - - @Override - public boolean existsByResourceKeyAndReferenceIdAndEventType( - String resourceKey, Long referenceId, ResourceEventType eventType) { - return database.values().stream() - .anyMatch( - log -> - log.getResourceKey().equals(resourceKey) - && referenceId.equals(log.getReferenceId()) - && log.getEventType() == eventType); - } - @Override public void delete(ResourceLog resourceLog) { + resourceKeyIndex.remove(resourceLog.getResourceKey()); database.remove(resourceLog.getId()); } public void clear() { database.clear(); + resourceKeyIndex.clear(); } public List findAll() { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java index 5b861786c..5598f29d6 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/DailyDataReportIntegrationTest.java @@ -17,7 +17,7 @@ import org.springframework.jdbc.core.JdbcTemplate; @Tag("integration") -@DisplayName("[integration] [service] DailyDataReportService - TestContainers 실제 데이터 통합 테스트") +@DisplayName("[integration] [service] DailyDataReportService") class DailyDataReportIntegrationTest extends IntegrationTestSupport { @Autowired private DailyDataReportService dailyDataReportService; @Autowired private JdbcTemplate jdbcTemplate; diff --git a/git.environment-variables b/git.environment-variables index eb36c2bb9..daa284b37 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit eb36c2bb9ece676ef6f2641b46579764521532a0 +Subproject commit daa284b37fe6420b2409dd635dd09e6bd698765f From 01c5ebd9ebf444febb46ecd48261c5388f5765d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 10:11:57 +0000 Subject: [PATCH 45/95] =?UTF-8?q?feat(tag):=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=8B=9C=20=EB=B6=80=EB=B6=84=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TastingTagService에서 isWholeWord 필터 제거 - "초콜릿향", "바닐라빈" 등에서도 태그 추출 가능 - 단위/통합 테스트 케이스 수정 --- .../alcohols/service/TastingTagService.java | 21 +------------------ .../service/TastingTagServiceTest.java | 19 +++++++++-------- .../TastingTagIntegrationTest.java | 6 +++--- 3 files changed, 14 insertions(+), 32 deletions(-) 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 178a7c445..978ad3549 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 @@ -39,7 +39,7 @@ public void initializeTrie() { } /** - * 문장에서 태그 이름 목록을 추출한다. + * 문장에서 태그 이름 목록을 추출한다. (부분 매칭 허용) * * @param text 분석할 문장 * @return 매칭된 태그 이름 목록 @@ -51,27 +51,8 @@ public List extractTagNames(String text) { } return trie.parseText(text).stream() - .filter(emit -> isWholeWord(text, emit)) .map(Emit::getKeyword) .distinct() .toList(); } - - private boolean isWholeWord(String text, Emit emit) { - int start = emit.getStart(); - int end = emit.getEnd() + 1; - - if (start > 0 && isKorean(text.charAt(start - 1))) { - return false; - } - if (end < text.length() && isKorean(text.charAt(end))) { - return false; - } - - return true; - } - - private boolean isKorean(char c) { - return (c >= 0xAC00 && c <= 0xD7A3) || (c >= 0x1100 && c <= 0x11FF); - } } 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 059d84c70..34a3fe352 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 @@ -72,19 +72,20 @@ class ExtractTagNames { assertThat(result).containsExactlyInAnyOrderElementsOf(expectedTags); } - static Stream 부분_매칭_제외_케이스() { + static Stream 부분_매칭_허용_케이스() { return Stream.of( - Arguments.of("바닐라빈 향이 좋아요", List.of()), - Arguments.of("꿀물처럼 달콤해요", List.of()), - Arguments.of("스모키한 느낌", List.of()), - Arguments.of("카라멜라이즈된 설탕 맛", List.of()), - Arguments.of("초콜릿케이크 같은 맛", List.of())); + Arguments.of("바닐라빈 향이 좋아요", List.of("바닐라")), + Arguments.of("꿀물처럼 달콤해요", List.of("꿀")), + Arguments.of("스모키한 느낌", List.of("스모키")), + Arguments.of("카라멜라이즈된 설탕 맛", List.of("카라멜")), + Arguments.of("초콜릿케이크 같은 맛", List.of("초콜릿")), + Arguments.of("초콜릿향이 남니다", List.of("초콜릿"))); } @ParameterizedTest(name = "\"{0}\" → {1}") - @MethodSource("부분_매칭_제외_케이스") - @DisplayName("부분 매칭은 제외한다") - void 부분_매칭_제외(String text, List expectedTags) { + @MethodSource("부분_매칭_허용_케이스") + @DisplayName("부분 매칭을 허용한다") + void 부분_매칭_허용(String text, List expectedTags) { // when List result = tastingTagService.extractTagNames(text); diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java index ee56882bb..3d076ecaa 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/TastingTagIntegrationTest.java @@ -74,8 +74,8 @@ void extractMultipleTags() throws Exception { } @Test - @DisplayName("부분 매칭은 제외된다") - void excludePartialMatch() throws Exception { + @DisplayName("부분 매칭을 허용한다") + void allowPartialMatch() throws Exception { // given String text = "바닐라빈 향이 좋고 꿀물처럼 달콤해요"; @@ -92,7 +92,7 @@ void excludePartialMatch() throws Exception { // then List tags = extractDataAsList(result); - assertThat(tags).isEmpty(); + assertThat(tags).containsExactlyInAnyOrder("바닐라", "꿀"); } @Test From eaafe58f90f6a54f99fd33a283a890baac53fb8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 15 Jan 2026 10:13:53 +0000 Subject: [PATCH 46/95] chore: apply code formatting [skip ci] --- .../app/bottlenote/alcohols/service/TastingTagService.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 978ad3549..f5331dac8 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 @@ -50,9 +50,6 @@ public List extractTagNames(String text) { return List.of(); } - return trie.parseText(text).stream() - .map(Emit::getKeyword) - .distinct() - .toList(); + return trie.parseText(text).stream().map(Emit::getKeyword).distinct().toList(); } } From 57e4642f017472258949a4dbe104cccd27920f7f Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 15 Jan 2026 22:13:47 +0900 Subject: [PATCH 47/95] =?UTF-8?q?feat:=20=EC=9C=84=EC=8A=A4=ED=82=A4=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=EB=B0=8F=20=ED=85=8C=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=ED=83=9C=EA=B7=B8=20=EC=9A=94=EC=B2=AD=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 --- .../alcohols/controller/AlcoholExploreController.java | 1 + git.environment-variables | 2 +- .../\353\221\230\353\237\254\353\263\264\352\270\260.http" | 7 ++++++- .../\355\203\234\352\267\270\354\266\224\354\266\234.http" | 6 +++++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholExploreController.java b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholExploreController.java index 155f3bb0b..03626065d 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholExploreController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/alcohols/controller/AlcoholExploreController.java @@ -30,6 +30,7 @@ public ResponseEntity getStandardExplore( @RequestParam(required = false) List keywords, @RequestParam(required = false, defaultValue = "20") Integer size, @RequestParam(required = false, defaultValue = "0") Long cursor) { + if (keywords == null) keywords = List.of(); Long userId = SecurityContextUtil.getUserIdByContext().orElse(-1L); Pair> pair = alcoholQueryService.getStandardExplore(userId, keywords, cursor, size); diff --git a/git.environment-variables b/git.environment-variables index daa284b37..e9740a429 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit daa284b37fe6420b2409dd635dd09e6bd698765f +Subproject commit e9740a4297c2c90d3ca0a7c23ad1682a918e4b78 diff --git "a/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" index 68e5c57ad..7b62611cb 100644 --- "a/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" +++ "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\354\234\204\354\212\244\355\202\244\354\240\225\353\263\264/\353\221\230\353\237\254\353\263\264\352\270\260.http" @@ -15,5 +15,10 @@ Content-Type: application/json %} ### 위스키 둘러보기 -GET {{host}}/api/v1/alcohols/explore/standard?page=0&size=6&keywords=탈라모어&keywords=사과 +GET {{host}}/api/v1/alcohols/explore/standard?cursor=0&size=6&keywords=탈라모어&keywords=사과 Authorization: Bearer {{accessToken}} + +### 위스키 둘러보기 2 +GET {{host}}/api/v1/alcohols/explore/standard?cursor=0&size=10 +Authorization: Bearer {{accessToken}} + diff --git "a/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" index 09b570e45..4911c58f9 100644 --- "a/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" +++ "b/http/product/02_\354\234\204\354\212\244\355\202\244\355\203\220\354\203\211/\355\205\214\354\235\264\354\212\244\355\214\205\355\203\234\352\267\270/\355\203\234\352\267\270\354\266\224\354\266\234.http" @@ -23,4 +23,8 @@ Authorization: Bearer {{accessToken}} ### 테이스팅 태그 추출 - 매칭 없음 GET {{host}}/api/v1/tasting-tags/extract?text=그냥 평범한 위스키입니다 -Authorization: Bearer {{accessToken}} \ No newline at end of file +Authorization: Bearer {{accessToken}} + +### 테이스팅 태그 추출 - 포함 매칭 +GET {{host}}/api/v1/tasting-tags/extract?text=스모키향이 좋아요 +Authorization: Bearer {{accessToken}} From 7a060c09b358f16f4abf92cba50bfdadeb59ceab Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 15 Jan 2026 21:40:30 +0900 Subject: [PATCH 48/95] =?UTF-8?q?feat(resource):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94/=EC=82=AD=EC=A0=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미지 리소스 생명주기 관리를 위한 INVALIDATED/DELETED 이벤트 인프라를 추가합니다. 서비스에서의 실제 사용은 향후 구현 예정. - ImageResourceInvalidatedEvent 이벤트 클래스 추가 - ImageResourceDeletedEvent 이벤트 클래스 추가 - ResourceEventListener에 신규 이벤트 핸들러 추가 - ResourceCommandService에 invalidate/delete 메서드 추가 - 단위 테스트에 삭제 상태 전이 테스트 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../event/listener/ResourceEventListener.java | 32 ++ .../payload/ImageResourceDeletedEvent.java | 24 ++ .../ImageResourceInvalidatedEvent.java | 24 ++ .../file/service/ResourceCommandService.java | 24 ++ .../upload/ResourceCommandServiceTest.java | 61 ++++ plan/image-resource-management.md | 308 ++++++++++++++++++ 6 files changed, 473 insertions(+) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceDeletedEvent.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceInvalidatedEvent.java create mode 100644 plan/image-resource-management.md diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java index 4f4a1ea42..14d830444 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java @@ -4,6 +4,8 @@ import app.bottlenote.common.annotation.DomainEventListener; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceDeletedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; import app.bottlenote.common.file.service.ResourceCommandService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,4 +36,34 @@ public void handleImageResourceActivated(ImageResourceActivatedEvent event) { resourceKey, event.referenceId(), event.referenceType()); } } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener + public void handleImageResourceInvalidated(ImageResourceInvalidatedEvent event) { + log.info( + "이미지 리소스 무효화 이벤트 수신 - referenceId: {}, referenceType: {}, resourceKeys: {}", + event.referenceId(), + event.referenceType(), + event.resourceKeys().size()); + + for (String resourceKey : event.resourceKeys()) { + resourceCommandService.invalidateImageResource(resourceKey); + } + } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener + public void handleImageResourceDeleted(ImageResourceDeletedEvent event) { + log.info( + "이미지 리소스 삭제 이벤트 수신 - referenceId: {}, referenceType: {}, resourceKeys: {}", + event.referenceId(), + event.referenceType(), + event.resourceKeys().size()); + + for (String resourceKey : event.resourceKeys()) { + resourceCommandService.deleteImageResource(resourceKey); + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceDeletedEvent.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceDeletedEvent.java new file mode 100644 index 000000000..1e766fb95 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceDeletedEvent.java @@ -0,0 +1,24 @@ +package app.bottlenote.common.file.event.payload; + +import java.util.List; +import java.util.Objects; + +public record ImageResourceDeletedEvent( + List resourceKeys, Long referenceId, String referenceType) { + + public ImageResourceDeletedEvent { + Objects.requireNonNull(resourceKeys, "resourceKeys must not be null"); + Objects.requireNonNull(referenceId, "referenceId must not be null"); + Objects.requireNonNull(referenceType, "referenceType must not be null"); + } + + public static ImageResourceDeletedEvent of( + List resourceKeys, Long referenceId, String referenceType) { + return new ImageResourceDeletedEvent(resourceKeys, referenceId, referenceType); + } + + public static ImageResourceDeletedEvent of( + String resourceKey, Long referenceId, String referenceType) { + return new ImageResourceDeletedEvent(List.of(resourceKey), referenceId, referenceType); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceInvalidatedEvent.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceInvalidatedEvent.java new file mode 100644 index 000000000..4c5f7def5 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceInvalidatedEvent.java @@ -0,0 +1,24 @@ +package app.bottlenote.common.file.event.payload; + +import java.util.List; +import java.util.Objects; + +public record ImageResourceInvalidatedEvent( + List resourceKeys, Long referenceId, String referenceType) { + + public ImageResourceInvalidatedEvent { + Objects.requireNonNull(resourceKeys, "resourceKeys must not be null"); + Objects.requireNonNull(referenceId, "referenceId must not be null"); + Objects.requireNonNull(referenceType, "referenceType must not be null"); + } + + public static ImageResourceInvalidatedEvent of( + List resourceKeys, Long referenceId, String referenceType) { + return new ImageResourceInvalidatedEvent(resourceKeys, referenceId, referenceType); + } + + public static ImageResourceInvalidatedEvent of( + String resourceKey, Long referenceId, String referenceType) { + return new ImageResourceInvalidatedEvent(List.of(resourceKey), referenceId, referenceType); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java index aaddf9cd1..096333072 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java @@ -96,6 +96,30 @@ public CompletableFuture> invalidateImageResource( return CompletableFuture.completedFuture(Optional.of(toResponse(saved))); } + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CompletableFuture> deleteImageResource(String resourceKey) { + Optional resourceLogOpt = resourceLogRepository.findByResourceKey(resourceKey); + + if (resourceLogOpt.isEmpty()) { + log.warn("리소스 로그를 찾을 수 없음 - resourceKey: {}", resourceKey); + return CompletableFuture.completedFuture(Optional.empty()); + } + + ResourceLog resourceLog = resourceLogOpt.get(); + + if (!resourceLog.canTransitionTo(ResourceEventType.DELETED)) { + log.info( + "삭제 상태로 전이 불가 - resourceKey: {}, 현재 상태: {}", resourceKey, resourceLog.getEventType()); + return CompletableFuture.completedFuture(Optional.empty()); + } + + resourceLog.markDeleted(); + ResourceLog saved = resourceLogRepository.save(resourceLog); + log.info("이미지 리소스 삭제 - resourceKey: {}", resourceKey); + return CompletableFuture.completedFuture(Optional.of(toResponse(saved))); + } + @Transactional(readOnly = true) public Optional findByResourceKey(String resourceKey) { return resourceLogRepository.findByResourceKey(resourceKey).map(this::toResponse); diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java index 099508ead..8bfc28f20 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java @@ -205,6 +205,67 @@ void test_2() { } } + @Nested + @DisplayName("이미지 리소스 삭제 테스트") + class DeleteImageResourceTest { + + @Test + @DisplayName("무효화된 이미지 리소스를 삭제하면 DELETED 상태로 변경된다") + void test_1() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.invalidateImageResource(resourceKey).join(); + + // when + CompletableFuture> future = + resourceCommandService.deleteImageResource(resourceKey); + Optional result = future.join(); + + // then + assertTrue(result.isPresent()); + assertEquals(ResourceEventType.DELETED, result.get().eventType()); + assertEquals(1, resourceLogRepository.findAll().size()); + + log.info("삭제 결과 = {}", result.get()); + } + + @Test + @DisplayName("CREATED 상태에서는 바로 삭제할 수 없다") + void test_2() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + + // when + Optional result = + resourceCommandService.deleteImageResource(resourceKey).join(); + + // then + assertTrue(result.isEmpty()); + + log.info("CREATED 상태에서 삭제 시도 결과 = {}", result); + } + + @Test + @DisplayName("ACTIVATED 상태에서는 바로 삭제할 수 없다") + void test_3() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); + + // when + Optional result = + resourceCommandService.deleteImageResource(resourceKey).join(); + + // then + assertTrue(result.isEmpty()); + + log.info("ACTIVATED 상태에서 삭제 시도 결과 = {}", result); + } + } + @Nested @DisplayName("리소스 로그 조회 테스트") class FindResourceLogTest { diff --git a/plan/image-resource-management.md b/plan/image-resource-management.md new file mode 100644 index 000000000..1be9bdb1e --- /dev/null +++ b/plan/image-resource-management.md @@ -0,0 +1,308 @@ +# 이미지 리소스 관리 분석 + +## 1. 현재 아키텍처 개요 + +``` +[클라이언트] → [ImageUploadController] → [ImageUploadService] → [AWS S3] + ↓ + [ResourceCommandService] + ↓ + [ResourceLog 저장] + +[도메인 서비스] → [이벤트 발행] → [ResourceEventListener] → [ResourceLog 상태 변경] +``` + +## 2. 핵심 흐름 + +### 2.1 PreSigned URL 발급 단계 + +1. 클라이언트가 `/api/v1/s3/presign-url`로 요청 (rootPath, uploadSize) +2. `ImageUploadService`가 S3 PreSigned URL 생성 (만료 5분) +3. CloudFront 기반 viewUrl과 S3 uploadUrl 쌍 반환 +4. 로그인 사용자인 경우 `ResourceLog` **CREATED** 상태 저장 + +### 2.2 이미지 사용 활성화 단계 + +1. 도메인에서 이미지가 실제 사용될 때 `ImageResourceActivatedEvent` 발행 +2. `ResourceEventListener`가 이벤트 수신 +3. `ResourceLog` **ACTIVATED** 상태 로그 저장 + +## 3. 이벤트 타입 (ResourceEventType) + +| 상태 | 설명 | 구현 상태 | +|------|------|----------| +| CREATED | PreSigned URL 발급 시 | 구현 완료 | +| ACTIVATED | 엔티티에 연결되어 사용됨 | 구현 완료 | +| INVALIDATED | 무효화됨 | 미구현 | +| DELETED | 삭제됨 | 미구현 | + +## 4. 이미지 사용 도메인 (4곳) + +| 도메인 | referenceType | 엔티티 | 특징 | +|--------|--------------|--------|------| +| Review | REVIEW | `ReviewImage` | `ImageInfo` 임베드, 최대 5장 | +| User | PROFILE | User 필드 | 단일 이미지, `imageUrl` 필드 | +| Help | HELP | `HelpImage` | 문의글 첨부 이미지 | +| Business | BUSINESS | `BusinessImage` | 사업자 지원 문서 | + +## 5. 공통 구조 + +### 5.1 ImageInfo (Embeddable) + +```java +@Embeddable +public class ImageInfo { + private Long order; // 이미지 순서 + private String imageUrl; // 전체 URL (CloudFront) + private String imageKey; // S3 객체 키 + private String imagePath;// 저장 경로 + private String imageName;// 파일명 +} +``` + +### 5.2 ResourceLog 엔티티 + +```java +@Entity +public class ResourceLog { + private Long id; + private Long userId; // 요청 사용자 + private String resourceKey; // S3 객체 키 + private String resourceType; // IMAGE + private ResourceEventType eventType; // CREATED/ACTIVATED/INVALIDATED/DELETED + private Long referenceId; // 연결된 엔티티 ID + private String referenceType; // REVIEW/PROFILE/HELP/BUSINESS + private String viewUrl; // CloudFront URL + private String rootPath; // 저장 경로 + private String bucketName; // S3 버킷명 + private LocalDateTime createAt; +} +``` + +## 6. 주요 파일 위치 + +### 6.1 Core + +| 파일 | 경로 | +|------|------| +| ImageUploadService | `bottlenote-mono/.../common/file/service/ImageUploadService.java` | +| ResourceCommandService | `bottlenote-mono/.../common/file/service/ResourceCommandService.java` | +| ResourceLog | `bottlenote-mono/.../common/file/domain/ResourceLog.java` | +| ResourceEventType | `bottlenote-mono/.../common/file/constant/ResourceEventType.java` | +| ImageResourceActivatedEvent | `bottlenote-mono/.../common/file/event/payload/ImageResourceActivatedEvent.java` | +| ResourceEventListener | `bottlenote-mono/.../common/file/event/listener/ResourceEventListener.java` | + +### 6.2 도메인별 이미지 엔티티 + +| 도메인 | 파일 | +|--------|------| +| Review | `review/domain/ReviewImage.java`, `ReviewImages.java` | +| Help | `support/help/domain/HelpImage.java`, `HelpImageList.java` | +| Business | `support/business/domain/BusinessImage.java`, `BusinessImageList.java` | +| User | `user/domain/User.java` (imageUrl 필드) | + +### 6.3 이벤트 발행 서비스 + +| 서비스 | 이벤트 발행 위치 | +|--------|-----------------| +| ReviewService | `publishImageActivatedEvent()` | +| UserBasicService | `profileImageChange()` | +| HelpService | 이미지 저장 시 | +| BusinessSupportService | 이미지 저장 시 | + +## 7. Git 히스토리 (진화 과정) + +| 날짜 | 커밋 | 내용 | +|------|------|------| +| 2024-05 | `98c17191` | PreSigned URL 기본 기능 구현 | +| - | `b96a558b` | ImageUtil 클래스 추가 | +| - | `3a5abd50` | ImageInfo로 중복 제거 리팩토링 | +| 2026-01-05 | `e00eecc5` | ImageUploadLog 도입 (상태 기반) | +| 2026-01-06 | `5dbd511c` | ResourceLog로 이벤트 기반 리팩토링 | +| 2026-01-09 | `c3e173dc` | ImageResourceActivatedEvent 도입 | + +## 8. 2차 작업: 삭제 시 상태 업데이트 + +### 8.1 현재 미구현 상태 + +- `INVALIDATED`: 이미지가 교체되어 기존 이미지가 무효화될 때 +- `DELETED`: 이미지가 완전히 삭제될 때 + +### 8.2 삭제/무효화 발생 시나리오 + +| 도메인 | 시나리오 | 예상 이벤트 | +|--------|----------|------------| +| Review | 리뷰 수정 시 이미지 교체 | 기존 이미지 INVALIDATED | +| Review | 리뷰 삭제 | 연관 이미지 DELETED | +| User | 프로필 이미지 변경 | 기존 이미지 INVALIDATED | +| Help | 문의글 수정/삭제 | 이미지 INVALIDATED/DELETED | +| Business | 지원글 수정/삭제 | 이미지 INVALIDATED/DELETED | + +### 8.3 구현 필요 항목 + +1. `ImageResourceInvalidatedEvent` 이벤트 클래스 +2. `ImageResourceDeletedEvent` 이벤트 클래스 +3. `ResourceEventListener`에 핸들러 추가 +4. 각 도메인 서비스에서 삭제/수정 시 이벤트 발행 + +--- + +## 9. 2차 작업 상세 분석: 삭제/무효화 이벤트 구현 + +### 9.1 현재 이미지 변경 시 동작 분석 + +#### Review 도메인 + +**수정 시 (`ReviewService.modifyReview`)** +``` +1. Review.imageInitialization() 호출 +2. ReviewImages.update() → clear() 후 새 이미지 추가 (orphanRemoval=true) +3. publishImageActivatedEvent() → 새 이미지만 ACTIVATED +``` +- 문제: 기존 이미지에 대한 INVALIDATED 이벤트 누락 + +**삭제 시 (`ReviewService.deleteReview`)** +``` +1. review.updateReviewActiveStatus(DELETED) → 소프트 삭제 +2. 이미지는 그대로 유지 (물리적 삭제 안함) +``` +- 문제: 이미지에 대한 DELETED 이벤트 누락 + +#### User 도메인 + +**프로필 이미지 변경 시 (`UserBasicService.profileImageChange`)** +``` +1. user.changeProfileImage(newUrl) → 단순 덮어쓰기 +2. ImageResourceActivatedEvent 발행 (새 이미지) +``` +- 문제: 기존 이미지에 대한 INVALIDATED 이벤트 누락 + +#### Help 도메인 + +**수정 시 (`HelpService.modifyHelp`)** +``` +1. help.updateHelp() → helpImageList.clear() 후 새 이미지 추가 +2. publishImageActivatedEvent() → 새 이미지만 ACTIVATED +``` +- 문제: 기존 이미지에 대한 INVALIDATED 이벤트 누락 + +**삭제 시 (`HelpService.deleteHelp`)** +``` +1. help.deleteHelp() → status = DELETED (소프트 삭제) +``` +- 문제: 이미지에 대한 DELETED 이벤트 누락 + +#### Business 도메인 + +**수정 시 (`BusinessSupportService.modify`)** +``` +1. bs.update() → 이미지 clear 후 새로 추가 +2. publishImageActivatedEvent() → 새 이미지만 ACTIVATED +``` +- 문제: 기존 이미지에 대한 INVALIDATED 이벤트 누락 + +**삭제 시 (`BusinessSupportService.delete`)** +``` +1. bs.delete() → 소프트 삭제 +``` +- 문제: 이미지에 대한 DELETED 이벤트 누락 + +### 9.2 구현 방안 + +#### 9.2.1 새 이벤트 클래스 + +```java +// 이미지 무효화 이벤트 (교체 시) +public record ImageResourceInvalidatedEvent( + List resourceKeys, + Long referenceId, + String referenceType +) {} + +// 이미지 삭제 이벤트 (엔티티 삭제 시) +public record ImageResourceDeletedEvent( + List resourceKeys, + Long referenceId, + String referenceType +) {} +``` + +#### 9.2.2 도메인별 수정 필요 사항 + +| 도메인 | 서비스 메서드 | 수정 내용 | +|--------|-------------|----------| +| Review | `modifyReview()` | 수정 전 기존 이미지 조회 → INVALIDATED 이벤트 발행 | +| Review | `deleteReview()` | 삭제 시 연관 이미지 → DELETED 이벤트 발행 | +| User | `profileImageChange()` | 변경 전 기존 이미지 조회 → INVALIDATED 이벤트 발행 | +| Help | `modifyHelp()` | 수정 전 기존 이미지 조회 → INVALIDATED 이벤트 발행 | +| Help | `deleteHelp()` | 삭제 시 연관 이미지 → DELETED 이벤트 발행 | +| Business | `modify()` | 수정 전 기존 이미지 조회 → INVALIDATED 이벤트 발행 | +| Business | `delete()` | 삭제 시 연관 이미지 → DELETED 이벤트 발행 | + +#### 9.2.3 기존 이미지 추출 방법 + +| 도메인 | 기존 이미지 접근 방법 | +|--------|---------------------| +| Review | `review.getReviewImages().getImages()` → `ImageInfo.getImageUrl()` | +| User | `user.getImageUrl()` (단일) | +| Help | `help.getHelpImageList().getHelpImages()` → `ImageInfo.getImageUrl()` | +| Business | `bs.getBusinessImageList().getBusinessImages()` → `ImageInfo.getImageUrl()` | + +#### 9.2.4 ResourceCommandService 확장 + +```java +// 기존 +activateImageResource(resourceKey, referenceId, referenceType) + +// 추가 필요 +invalidateImageResource(resourceKey, referenceId, referenceType) +deleteImageResource(resourceKey, referenceId, referenceType) +``` + +### 9.3 구현 우선순위 + +1. **Phase 1**: 이벤트 클래스 추가 (`ImageResourceInvalidatedEvent`, `ImageResourceDeletedEvent`) - **완료** +2. **Phase 2**: `ResourceCommandService`에 invalidate/delete 메서드 추가 - **완료** +3. **Phase 3**: `ResourceEventListener`에 핸들러 추가 - **완료** +4. **Phase 4**: 각 도메인 서비스 수정 (Review 완료 / User, Help, Business 미구현) +5. **Phase 5**: 단위 테스트 및 통합 테스트 추가 - **Review 도메인 완료** + +### 9.4 주의사항 + +- **트랜잭션 순서**: 이벤트 발행은 엔티티 변경 후 `@TransactionalEventListener`로 처리 +- **비동기 처리**: `@Async`로 메인 트랜잭션 블로킹 방지 +- **이미지 비교**: 수정 시 "변경된 이미지"만 INVALIDATED 처리 (동일 이미지 제외) +- **Soft Delete**: 엔티티가 소프트 삭제되어도 이미지 상태는 DELETED로 기록 + +--- + +## 10. 구현 완료 내역 + +### 10.1 추가된 파일 + +| 파일 | 위치 | +|------|------| +| `ImageResourceInvalidatedEvent.java` | `bottlenote-mono/.../file/event/payload/` | +| `ImageResourceDeletedEvent.java` | `bottlenote-mono/.../file/event/payload/` | + +### 10.2 수정된 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `ResourceCommandService.java` | `invalidateImageResource`, `deleteImageResource` 메서드 추가 | +| `ResourceEventListener.java` | `handleImageResourceInvalidated`, `handleImageResourceDeleted` 핸들러 추가 | +| `ReviewService.java` | 수정/삭제 시 INVALIDATED/DELETED 이벤트 발행 로직 추가 | +| `ResourceCommandServiceTest.java` | 메서드 시그니처 변경 및 삭제 테스트 추가 | +| `ImageUploadIntegrationTest.java` | INVALIDATED, DELETED 이벤트 통합 테스트 추가 | + +### 10.3 남은 작업 + +- User 도메인: 프로필 이미지 변경 시 INVALIDATED 이벤트 발행 +- Help 도메인: 수정/삭제 시 이벤트 발행 +- Business 도메인: 수정/삭제 시 이벤트 발행 + +--- + +*작성일: 2026-01-15* +*업데이트: 2026-01-15 (2차 작업 분석 추가)* +*업데이트: 2026-01-15 (Review 도메인 구현 완료)* From 502b48b08e25e68d81e5db359cbdcd89057e8c58 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 15 Jan 2026 22:38:09 +0900 Subject: [PATCH 49/95] =?UTF-8?q?feat(image):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B5=90=EC=B2=B4/=EC=82=AD=EC=A0=9C=20=EC=8B=9C?= =?UTF-8?q?=20INVALIDATED=20=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReviewService: 리뷰 수정/삭제 시 제거된 이미지 INVALIDATED 처리 - HelpService: 문의 수정/삭제 시 제거된 이미지 INVALIDATED 처리 - BusinessSupportService: 제휴/광고 수정/삭제 시 제거된 이미지 INVALIDATED 처리 - UserBasicService: 프로필 이미지 교체 시 기존 이미지 INVALIDATED 처리 - 통합 테스트 3개 추가 (이미지 교체, 삭제, 전체 교체 시나리오) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../review/service/ReviewService.java | 50 +++ .../service/BusinessSupportService.java | 53 +++ .../support/help/service/HelpService.java | 50 +++ .../user/service/UserBasicService.java | 15 + .../ImageUploadIntegrationTest.java | 379 ++++++++++++++++++ 5 files changed, 547 insertions(+) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java index bcc5fc868..829ade2c2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java @@ -10,6 +10,7 @@ import app.bottlenote.alcohols.facade.AlcoholFacade; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; import app.bottlenote.common.image.ImageUtil; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.history.event.publisher.HistoryEventPublisher; @@ -33,8 +34,10 @@ import app.bottlenote.review.facade.payload.ReviewInfo; import app.bottlenote.user.facade.UserFacade; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -154,6 +157,12 @@ public ReviewResultResponse modifyReview( .findByIdAndUserId(reviewId, currentUserId) .orElseThrow(() -> new ReviewException(REVIEW_NOT_FOUND)); + // 기존 이미지 목록 추출 (수정 전) + List oldImageUrls = + review.getReviewImages().getViewInfo().stream() + .map(ReviewImageInfoRequest::viewUrl) + .toList(); + ReviewModifyRequestWrapperItem reviewModifyRequestWrapperItem = ReviewModifyRequestWrapperItem.create(request); List reviewImageInfoRequests = request.imageUrlList(); @@ -162,6 +171,18 @@ public ReviewResultResponse modifyReview( review.imageInitialization(reviewImageInfoRequests); review.updateTastingTags(request.tastingTagList()); + // 새 이미지 목록 추출 + List newImageUrls = + Objects.requireNonNullElse( + reviewImageInfoRequests, Collections.emptyList()) + .stream() + .map(ReviewImageInfoRequest::viewUrl) + .toList(); + + // 제거된 이미지에 대해 INVALIDATED 이벤트 발행 + publishImageInvalidatedEvent(oldImageUrls, newImageUrls, reviewId); + + // 새 이미지에 대해 ACTIVATED 이벤트 발행 publishImageActivatedEvent(reviewImageInfoRequests, reviewId); return ReviewResultResponse.response(MODIFY_SUCCESS, reviewId); @@ -174,8 +195,18 @@ public ReviewResultResponse deleteReview(Long reviewId, Long currentUserId) { reviewRepository .findByIdAndUserId(reviewId, currentUserId) .orElseThrow(() -> new ReviewException(REVIEW_NOT_FOUND)); + + // 기존 이미지 목록 추출 (삭제 전) + List oldImageUrls = + review.getReviewImages().getViewInfo().stream() + .map(ReviewImageInfoRequest::viewUrl) + .toList(); + ReviewResultMessage reviewResultMessage = review.updateReviewActiveStatus(DELETED); + // 모든 이미지에 대해 INVALIDATED 이벤트 발행 + publishImageInvalidatedEvent(oldImageUrls, Collections.emptyList(), reviewId); + log.info( "리뷰 삭제 - reviewId: {}, userId: {}, alcoholId: {}, traceId: {}", reviewId, @@ -219,4 +250,23 @@ private void publishImageActivatedEvent(List imageList, ImageResourceActivatedEvent.of(resourceKeys, reviewId, REFERENCE_TYPE_REVIEW)); } } + + private void publishImageInvalidatedEvent( + List oldImageUrls, List newImageUrls, Long reviewId) { + if (oldImageUrls == null || oldImageUrls.isEmpty() || reviewId == null) { + return; + } + Set newUrlSet = + new HashSet<>(Objects.requireNonNullElse(newImageUrls, Collections.emptyList())); + List removedResourceKeys = + oldImageUrls.stream() + .filter(url -> !newUrlSet.contains(url)) + .map(ImageUtil::extractResourceKey) + .filter(Objects::nonNull) + .toList(); + if (!removedResourceKeys.isEmpty()) { + eventPublisher.publishEvent( + ImageResourceInvalidatedEvent.of(removedResourceKeys, reviewId, REFERENCE_TYPE_REVIEW)); + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java b/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java index cbe772fc4..f87821455 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/business/service/BusinessSupportService.java @@ -8,6 +8,7 @@ import static app.bottlenote.support.business.exception.BusinessSupportExceptionCode.BUSINESS_SUPPORT_NOT_FOUND; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; import app.bottlenote.common.image.ImageUtil; import app.bottlenote.common.profanity.ProfanityClient; import app.bottlenote.global.data.response.CollectionResponse; @@ -21,8 +22,11 @@ import app.bottlenote.support.business.dto.response.BusinessSupportResultResponse; import app.bottlenote.support.business.exception.BusinessSupportException; import app.bottlenote.user.facade.UserFacade; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -71,6 +75,13 @@ public BusinessSupportResultResponse modify( .findById(id) .orElseThrow(() -> new BusinessSupportException(BUSINESS_SUPPORT_NOT_FOUND)); if (!bs.isMyPost(userId)) throw new BusinessSupportException(BUSINESS_SUPPORT_NOT_AUTHORIZED); + + // 기존 이미지 목록 추출 (수정 전) + List oldImageUrls = + bs.getBusinessImageList().getBusinessImages().stream() + .map(image -> image.getBusinessImageInfo().getImageUrl()) + .toList(); + String filteredTitle = profanityClient.filter(req.title()); String filteredContent = profanityClient.filter(req.content()); bs.update( @@ -80,6 +91,17 @@ public BusinessSupportResultResponse modify( req.businessSupportType(), req.imageUrlList()); + // 새 이미지 목록 추출 + List newImageUrls = + Objects.requireNonNullElse(req.imageUrlList(), Collections.emptyList()) + .stream() + .map(BusinessImageItem::viewUrl) + .toList(); + + // 제거된 이미지에 대해 INVALIDATED 이벤트 발행 + publishImageInvalidatedEvent(oldImageUrls, newImageUrls, bs.getId()); + + // 새 이미지에 대해 ACTIVATED 이벤트 발행 publishImageActivatedEvent(req.imageUrlList(), bs.getId()); return BusinessSupportResultResponse.response(MODIFY_SUCCESS, bs.getId()); @@ -92,7 +114,18 @@ public BusinessSupportResultResponse delete(Long id, Long userId) { .findById(id) .orElseThrow(() -> new BusinessSupportException(BUSINESS_SUPPORT_NOT_FOUND)); if (!bs.isMyPost(userId)) throw new BusinessSupportException(BUSINESS_SUPPORT_NOT_AUTHORIZED); + + // 기존 이미지 목록 추출 (삭제 전) + List oldImageUrls = + bs.getBusinessImageList().getBusinessImages().stream() + .map(image -> image.getBusinessImageInfo().getImageUrl()) + .toList(); + bs.delete(); + + // 모든 이미지에 대해 INVALIDATED 이벤트 발행 + publishImageInvalidatedEvent(oldImageUrls, Collections.emptyList(), bs.getId()); + return BusinessSupportResultResponse.response(DELETE_SUCCESS, bs.getId()); } @@ -153,4 +186,24 @@ private void publishImageActivatedEvent(List imageList, Long ImageResourceActivatedEvent.of(resourceKeys, businessId, REFERENCE_TYPE_BUSINESS)); } } + + private void publishImageInvalidatedEvent( + List oldImageUrls, List newImageUrls, Long businessId) { + if (oldImageUrls == null || oldImageUrls.isEmpty() || businessId == null) { + return; + } + Set newUrlSet = + new HashSet<>(Objects.requireNonNullElse(newImageUrls, Collections.emptyList())); + List removedResourceKeys = + oldImageUrls.stream() + .filter(url -> !newUrlSet.contains(url)) + .map(ImageUtil::extractResourceKey) + .filter(Objects::nonNull) + .toList(); + if (!removedResourceKeys.isEmpty()) { + eventPublisher.publishEvent( + ImageResourceInvalidatedEvent.of( + removedResourceKeys, businessId, REFERENCE_TYPE_BUSINESS)); + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java b/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java index c7bc32baa..df6051746 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/support/help/service/HelpService.java @@ -7,6 +7,7 @@ import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_FOUND; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; import app.bottlenote.common.image.ImageUtil; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.support.help.domain.Help; @@ -19,8 +20,11 @@ import app.bottlenote.support.help.dto.response.HelpResultResponse; import app.bottlenote.support.help.exception.HelpException; import app.bottlenote.user.facade.UserFacade; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -72,12 +76,30 @@ public HelpResultResponse modifyHelp( throw new HelpException(HELP_NOT_AUTHORIZED); } + // 기존 이미지 목록 추출 (수정 전) + List oldImageUrls = + help.getHelpImageList().getHelpImages().stream() + .map(image -> image.getHelpimageInfo().getImageUrl()) + .toList(); + help.updateHelp( helpUpsertRequest.title(), helpUpsertRequest.content(), helpUpsertRequest.imageUrlList(), helpUpsertRequest.type()); + // 새 이미지 목록 추출 + List newImageUrls = + Objects.requireNonNullElse( + helpUpsertRequest.imageUrlList(), Collections.emptyList()) + .stream() + .map(HelpImageItem::viewUrl) + .toList(); + + // 제거된 이미지에 대해 INVALIDATED 이벤트 발행 + publishImageInvalidatedEvent(oldImageUrls, newImageUrls, help.getId()); + + // 새 이미지에 대해 ACTIVATED 이벤트 발행 publishImageActivatedEvent(helpUpsertRequest.imageUrlList(), help.getId()); return HelpResultResponse.response(MODIFY_SUCCESS, help.getId()); @@ -93,8 +115,17 @@ public HelpResultResponse deleteHelp(Long helpId, Long currentUserId) { throw new HelpException(HELP_NOT_AUTHORIZED); } + // 기존 이미지 목록 추출 (삭제 전) + List oldImageUrls = + help.getHelpImageList().getHelpImages().stream() + .map(image -> image.getHelpimageInfo().getImageUrl()) + .toList(); + help.deleteHelp(); + // 모든 이미지에 대해 INVALIDATED 이벤트 발행 + publishImageInvalidatedEvent(oldImageUrls, Collections.emptyList(), help.getId()); + return HelpResultResponse.response(DELETE_SUCCESS, help.getId()); } @@ -148,4 +179,23 @@ private void publishImageActivatedEvent(List imageList, Long help ImageResourceActivatedEvent.of(resourceKeys, helpId, REFERENCE_TYPE_HELP)); } } + + private void publishImageInvalidatedEvent( + List oldImageUrls, List newImageUrls, Long helpId) { + if (oldImageUrls == null || oldImageUrls.isEmpty() || helpId == null) { + return; + } + Set newUrlSet = + new HashSet<>(Objects.requireNonNullElse(newImageUrls, Collections.emptyList())); + List removedResourceKeys = + oldImageUrls.stream() + .filter(url -> !newUrlSet.contains(url)) + .map(ImageUtil::extractResourceKey) + .filter(Objects::nonNull) + .toList(); + if (!removedResourceKeys.isEmpty()) { + eventPublisher.publishEvent( + ImageResourceInvalidatedEvent.of(removedResourceKeys, helpId, REFERENCE_TYPE_HELP)); + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java b/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java index d25195265..4f81b022d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/service/UserBasicService.java @@ -7,6 +7,7 @@ import static app.bottlenote.user.exception.UserExceptionCode.USER_NOT_FOUND; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; import app.bottlenote.common.image.ImageUtil; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.user.constant.MyBottleType; @@ -77,8 +78,22 @@ public ProfileImageChangeResponse profileImageChange(Long userId, String viewUrl User user = userRepository.findById(userId).orElseThrow(() -> new UserException(USER_NOT_FOUND)); + // 기존 프로필 이미지 URL 저장 (교체 전) + String oldImageUrl = user.getImageUrl(); + user.changeProfileImage(viewUrl); + // 기존 이미지가 있고 새 이미지와 다른 경우 INVALIDATED 이벤트 발행 + if (oldImageUrl != null && !oldImageUrl.equals(viewUrl)) { + String oldResourceKey = ImageUtil.extractResourceKey(oldImageUrl); + if (oldResourceKey != null && user.getId() != null) { + eventPublisher.publishEvent( + ImageResourceInvalidatedEvent.of( + oldResourceKey, user.getId(), REFERENCE_TYPE_PROFILE)); + } + } + + // 새 이미지에 대해 ACTIVATED 이벤트 발행 String resourceKey = ImageUtil.extractResourceKey(viewUrl); if (resourceKey != null && user.getId() != null) { eventPublisher.publishEvent( diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index f5a761432..75e835b1c 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -558,5 +558,384 @@ void test_modify_review_does_not_duplicate_activated_log() throws Exception { log.info("리뷰 수정 후 총 로그 수: {}, 모두 ACTIVATED 상태", allLogs.size()); } + + @Test + @DisplayName("리뷰 수정 시 기존 이미지가 제거되면 해당 이미지의 상태가 INVALIDATED로 변경된다") + void test_modify_review_removes_image_changes_status_to_invalidated() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + // 1. PreSigned URL 2개 생성 + MvcTestResult presignResult = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "2") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse uploadResponse = extractData(presignResult, ImageUploadResponse.class); + String imageUrl1 = uploadResponse.imageUploadInfo().get(0).viewUrl(); + String imageUrl2 = uploadResponse.imageUploadInfo().get(1).viewUrl(); + + // CREATED 로그 저장 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(2, logs.size()); + }); + + // 2. 리뷰 생성 (이미지 2개 포함) + ReviewCreateRequest createRequest = + new ReviewCreateRequest( + alcohol.getId(), + ReviewDisplayStatus.PUBLIC, + "이미지 2개 리뷰", + SizeType.GLASS, + BigDecimal.valueOf(30000), + createTestLocationInfo(), + List.of( + new ReviewImageInfoRequest(1L, imageUrl1), + new ReviewImageInfoRequest(2L, imageUrl2)), + List.of(), + 4.0); + + MvcTestResult createResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse createResponse = extractData(createResult, ReviewCreateResponse.class); + Long reviewId = createResponse.getId(); + + // ACTIVATED 상태로 변경 대기 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long activatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.ACTIVATED) + .count(); + assertEquals(2, activatedCount); + }); + + // 3. 리뷰 수정 (이미지 1개만 유지, 다른 1개 제거) + ReviewModifyRequest modifyRequest = + new ReviewModifyRequest( + "수정된 리뷰 - 이미지 1개 제거", + ReviewDisplayStatus.PUBLIC, + null, + List.of(new ReviewImageInfoRequest(1L, imageUrl1)), + null, + null, + createTestLocationInfo()); + + // when + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(modifyRequest)) + .with(csrf()) + .exchange(); + + // then - 이미지1은 ACTIVATED 유지, 이미지2는 INVALIDATED로 변경 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long invalidatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.INVALIDATED) + .count(); + assertEquals(1, invalidatedCount); + }); + + List allLogs = resourceLogRepository.findByUserId(userId); + assertEquals(2, allLogs.size()); + + long activatedCount = + allLogs.stream().filter(l -> l.getEventType() == ResourceEventType.ACTIVATED).count(); + long invalidatedCount = + allLogs.stream().filter(l -> l.getEventType() == ResourceEventType.INVALIDATED).count(); + + assertEquals(1, activatedCount); + assertEquals(1, invalidatedCount); + + log.info("이미지 제거 후 - ACTIVATED: {}, INVALIDATED: {}", activatedCount, invalidatedCount); + } + + @Test + @DisplayName("리뷰 삭제 시 모든 이미지의 상태가 INVALIDATED로 변경된다") + void test_delete_review_changes_all_images_to_invalidated() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + // 1. PreSigned URL 생성 + MvcTestResult presignResult = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "2") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse uploadResponse = extractData(presignResult, ImageUploadResponse.class); + String imageUrl1 = uploadResponse.imageUploadInfo().get(0).viewUrl(); + String imageUrl2 = uploadResponse.imageUploadInfo().get(1).viewUrl(); + + // CREATED 로그 저장 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(2, logs.size()); + }); + + // 2. 리뷰 생성 (이미지 2개 포함) + ReviewCreateRequest createRequest = + new ReviewCreateRequest( + alcohol.getId(), + ReviewDisplayStatus.PUBLIC, + "삭제 테스트 리뷰", + SizeType.GLASS, + BigDecimal.valueOf(30000), + createTestLocationInfo(), + List.of( + new ReviewImageInfoRequest(1L, imageUrl1), + new ReviewImageInfoRequest(2L, imageUrl2)), + List.of(), + 4.0); + + MvcTestResult createResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse createResponse = extractData(createResult, ReviewCreateResponse.class); + Long reviewId = createResponse.getId(); + + // ACTIVATED 상태로 변경 대기 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long activatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.ACTIVATED) + .count(); + assertEquals(2, activatedCount); + }); + + // when - 리뷰 삭제 + mockMvcTester + .delete() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then - 모든 이미지가 INVALIDATED로 변경 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long invalidatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.INVALIDATED) + .count(); + assertEquals(2, invalidatedCount); + }); + + List allLogs = resourceLogRepository.findByUserId(userId); + assertEquals(2, allLogs.size()); + assertTrue(allLogs.stream().allMatch(l -> l.getEventType() == ResourceEventType.INVALIDATED)); + + log.info("리뷰 삭제 후 모든 이미지가 INVALIDATED 상태로 변경됨"); + } + + @Test + @DisplayName("리뷰 수정 시 모든 이미지를 교체하면 기존 이미지는 INVALIDATED, 새 이미지는 ACTIVATED가 된다") + void test_modify_review_replaces_all_images() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + // 1. 첫 번째 PreSigned URL 생성 (기존 이미지용) + MvcTestResult presignResult1 = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "1") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse uploadResponse1 = extractData(presignResult1, ImageUploadResponse.class); + String oldImageUrl = uploadResponse1.imageUploadInfo().get(0).viewUrl(); + + // CREATED 로그 저장 대기 + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(1, logs.size()); + }); + + // 2. 리뷰 생성 (기존 이미지 포함) + ReviewCreateRequest createRequest = + new ReviewCreateRequest( + alcohol.getId(), + ReviewDisplayStatus.PUBLIC, + "이미지 교체 테스트 리뷰", + SizeType.GLASS, + BigDecimal.valueOf(30000), + createTestLocationInfo(), + List.of(new ReviewImageInfoRequest(1L, oldImageUrl)), + List.of(), + 4.0); + + MvcTestResult createResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse createResponse = extractData(createResult, ReviewCreateResponse.class); + Long reviewId = createResponse.getId(); + + // ACTIVATED 상태로 변경 대기 + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.ACTIVATED, logs.get(0).getEventType()); + }); + + // 3. 두 번째 PreSigned URL 생성 (새 이미지용) + MvcTestResult presignResult2 = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", "1") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ImageUploadResponse uploadResponse2 = extractData(presignResult2, ImageUploadResponse.class); + String newImageUrl = uploadResponse2.imageUploadInfo().get(0).viewUrl(); + + // 새 이미지 CREATED 로그 저장 대기 (총 2개) + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(2, logs.size()); + }); + + // 4. 리뷰 수정 (기존 이미지를 새 이미지로 완전 교체) + ReviewModifyRequest modifyRequest = + new ReviewModifyRequest( + "수정된 리뷰 - 이미지 완전 교체", + ReviewDisplayStatus.PUBLIC, + null, + List.of(new ReviewImageInfoRequest(1L, newImageUrl)), + null, + null, + createTestLocationInfo()); + + // when + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(modifyRequest)) + .with(csrf()) + .exchange(); + + // then - 기존 이미지 INVALIDATED, 새 이미지 ACTIVATED + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + long invalidatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.INVALIDATED) + .count(); + long activatedCount = + logs.stream() + .filter(l -> l.getEventType() == ResourceEventType.ACTIVATED) + .count(); + assertEquals(1, invalidatedCount); + assertEquals(1, activatedCount); + }); + + List allLogs = resourceLogRepository.findByUserId(userId); + assertEquals(2, allLogs.size()); + + ResourceLog oldImageLog = + allLogs.stream() + .filter(l -> l.getViewUrl().equals(oldImageUrl)) + .findFirst() + .orElseThrow(); + ResourceLog newImageLog = + allLogs.stream() + .filter(l -> l.getViewUrl().equals(newImageUrl)) + .findFirst() + .orElseThrow(); + + assertEquals(ResourceEventType.INVALIDATED, oldImageLog.getEventType()); + assertEquals(ResourceEventType.ACTIVATED, newImageLog.getEventType()); + + log.info( + "이미지 완전 교체 후 - 기존 이미지: {}, 새 이미지: {}", + oldImageLog.getEventType(), + newImageLog.getEventType()); + } } } From 3a0c5a64d3cb7755922ef80b3815b256eab49736 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 15 Jan 2026 22:54:30 +0900 Subject: [PATCH 50/95] =?UTF-8?q?fix:=20Docker=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20registry=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/actions/docker-build-push/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/docker-build-push/action.yml b/.github/actions/docker-build-push/action.yml index a1cea9b7d..9a5ffb474 100644 --- a/.github/actions/docker-build-push/action.yml +++ b/.github/actions/docker-build-push/action.yml @@ -168,8 +168,8 @@ runs: GIT_BRANCH=${{ github.ref_name }} BUILD_TIME=${{ steps.build-time.outputs.time }} ${{ inputs.build-args }} - cache-from: type=gha,scope=${{ inputs.cache-scope }} - cache-to: type=gha,scope=${{ inputs.cache-scope }},mode=max + cache-from: type=registry,ref=${{ inputs.registry-url }}/${{ inputs.image-name }}:cache + cache-to: type=registry,ref=${{ inputs.registry-url }}/${{ inputs.image-name }}:cache,mode=max - name: Install Cosign if: inputs.sign-image == 'true' From d1589454bfe169212bbd56f0094ec827e49d874e Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 15 Jan 2026 23:15:50 +0900 Subject: [PATCH 51/95] =?UTF-8?q?chore:=20=EC=BA=90=EC=8B=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cache-cleanup.yml | 114 ++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .github/workflows/cache-cleanup.yml diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 000000000..ea2ac9e12 --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,114 @@ +name: cache cleanup + +on: + schedule: + - cron: '0 0 * * *' # 매일 자정 (UTC) + workflow_dispatch: + inputs: + threshold_gb: + description: '삭제 기준 용량 (GB)' + required: false + default: '5' + dry_run: + description: '테스트 모드 (삭제 안 함)' + required: false + type: boolean + default: false + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Check cache usage + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + USAGE=$(gh api repos/${{ github.repository }}/actions/cache/usage --jq '.active_caches_size_in_bytes') + USAGE_GB=$(echo "scale=2; $USAGE / 1024 / 1024 / 1024" | bc) + THRESHOLD=${{ inputs.threshold_gb || '5' }} + + echo "현재 캐시 사용량: ${USAGE_GB}GB / 10GB" + echo "삭제 기준: ${THRESHOLD}GB" + echo "usage_gb=$USAGE_GB" >> $GITHUB_OUTPUT + echo "threshold=$THRESHOLD" >> $GITHUB_OUTPUT + + if (( $(echo "$USAGE_GB > $THRESHOLD" | bc -l) )); then + echo "should_clean=true" >> $GITHUB_OUTPUT + echo "기준 초과 - 정리 필요" + else + echo "should_clean=false" >> $GITHUB_OUTPUT + echo "기준 이하 - 정리 불필요" + fi + + - name: List caches before cleanup + if: steps.check.outputs.should_clean == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "=== 캐시 목록 (크기순) ===" + gh api repos/${{ github.repository }}/actions/caches --paginate \ + --jq '.actions_caches | sort_by(-.size_in_bytes) | .[:20][] | "\(.key[0:50]) | \(.ref[0:25]) | \(.size_in_bytes / 1024 / 1024 | floor)MB"' + + - name: Delete PR branch caches + if: steps.check.outputs.should_clean == 'true' && inputs.dry_run != true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "=== PR 브랜치 캐시 삭제 ===" + gh api repos/${{ github.repository }}/actions/caches --paginate \ + --jq '.actions_caches[] | select(.ref | startswith("refs/pull/")) | .id' | \ + while read id; do + if [ -n "$id" ]; then + gh api -X DELETE repos/${{ github.repository }}/actions/caches/$id && echo "Deleted: $id" + fi + done + + - name: Check usage after PR cleanup + if: steps.check.outputs.should_clean == 'true' && inputs.dry_run != true + id: after_pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + sleep 3 + USAGE=$(gh api repos/${{ github.repository }}/actions/cache/usage --jq '.active_caches_size_in_bytes') + USAGE_GB=$(echo "scale=2; $USAGE / 1024 / 1024 / 1024" | bc) + THRESHOLD=${{ inputs.threshold_gb || '5' }} + + echo "PR 정리 후 사용량: ${USAGE_GB}GB" + + if (( $(echo "$USAGE_GB > $THRESHOLD" | bc -l) )); then + echo "still_over=true" >> $GITHUB_OUTPUT + else + echo "still_over=false" >> $GITHUB_OUTPUT + fi + + - name: Delete old main branch caches (if still over) + if: steps.after_pr.outputs.still_over == 'true' && inputs.dry_run != true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "=== main 브랜치 오래된 캐시 삭제 (7일 이상) ===" + CUTOFF=$(date -d '7 days ago' -Iseconds) + + gh api repos/${{ github.repository }}/actions/caches --paginate \ + --jq ".actions_caches[] | select(.ref == \"refs/heads/main\" and .last_accessed_at < \"$CUTOFF\") | .id" | \ + while read id; do + if [ -n "$id" ]; then + gh api -X DELETE repos/${{ github.repository }}/actions/caches/$id && echo "Deleted old cache: $id" + fi + done + + - name: Final usage report + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + sleep 3 + USAGE=$(gh api repos/${{ github.repository }}/actions/cache/usage --jq '.active_caches_size_in_bytes') + USAGE_GB=$(echo "scale=2; $USAGE / 1024 / 1024 / 1024" | bc) + COUNT=$(gh api repos/${{ github.repository }}/actions/caches --jq '.total_count') + + echo "==============================" + echo "최종 캐시 사용량: ${USAGE_GB}GB / 10GB" + echo "캐시 개수: ${COUNT}개" + echo "==============================" From 2ff7032053368eeafab82c3f3bd0ef2d8d17272f Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 12:04:43 +0900 Subject: [PATCH 52/95] =?UTF-8?q?feat(security):=20=EC=95=85=EC=84=B1=20?= =?UTF-8?q?=EB=B4=87/=EC=8A=A4=EC=BA=90=EB=84=88=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaliciousPathPattern Enum 추가 (40개 악성 경로 패턴 정의) - SecurityConfig에 denyAll() 패턴 등록 - Spring Security Filter 레벨에서 조기 차단하여 NoResourceFoundException 로그 노이즈 제거 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../constant/MaliciousPathPattern.java | 83 +++++++++++++ .../constant/MaliciousPathPatternTest.java | 110 ++++++++++++++++++ .../app/global/security/SecurityConfig.java | 5 +- .../SecurityConfigIntegrationTest.java | 80 +++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/global/security/constant/MaliciousPathPatternTest.java create mode 100644 bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java new file mode 100644 index 000000000..ab4037430 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/constant/MaliciousPathPattern.java @@ -0,0 +1,83 @@ +package app.bottlenote.global.security.constant; + +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** 악성 봇/스캐너가 자주 탐색하는 경로 패턴 목록. Spring Security에서 denyAll() 처리에 사용. */ +@Getter +@RequiredArgsConstructor +public enum MaliciousPathPattern { + + // 환경 설정 파일 + ENV_FILE("/.env*", "환경 변수 파일"), + ENV_LOCAL("/.env.local", "로컬 환경 변수"), + ENV_PRODUCTION("/.env.production", "프로덕션 환경 변수"), + ENV_BACKUP("/.env.backup", "환경 변수 백업"), + + // 버전 관리 + GIT_DIRECTORY("/.git/**", "Git 저장소"), + GITIGNORE("/.gitignore", "Git ignore 파일"), + SVN_DIRECTORY("/.svn/**", "SVN 저장소"), + + // WordPress + WP_ADMIN("/wp-admin/**", "WordPress 관리자"), + WP_LOGIN("/wp-login.php", "WordPress 로그인"), + WP_CONFIG("/wp-config.php", "WordPress 설정"), + WP_INCLUDES("/wp-includes/**", "WordPress 인클루드"), + WP_CONTENT("/wp-content/**", "WordPress 컨텐츠"), + XMLRPC("/xmlrpc.php", "XML-RPC 엔드포인트"), + + // PHP/DB 관리 도구 + PHP_MY_ADMIN("/phpmyadmin/**", "phpMyAdmin"), + PMA("/pma/**", "phpMyAdmin 별칭"), + MYSQL_ADMIN("/mysql/**", "MySQL 관리"), + ADMINER("/adminer.php", "Adminer DB 관리"), + PHP_INFO("/phpinfo.php", "PHP 정보"), + INFO_PHP("/info.php", "서버 정보"), + + // 서버 상태 + SERVER_STATUS("/server-status", "Apache 서버 상태"), + SERVER_INFO("/server-info", "Apache 서버 정보"), + + // 백업/덤프 파일 (루트 경로만 차단, ** 뒤에 패턴 불가) + SQL_FILE("/*.sql", "루트 SQL 덤프 파일"), + BAK_FILE("/*.bak", "루트 백업 파일"), + BACKUP_FILE("/*.backup", "루트 백업 파일"), + DUMP_FILE("/*.dump", "루트 덤프 파일"), + DB_DUMP("/db.sql", "DB 덤프 파일"), + DATABASE_DUMP("/database.sql", "DB 덤프 파일"), + BACKUP_DIR("/backup/**", "백업 디렉토리"), + BACKUPS_DIR("/backups/**", "백업 디렉토리"), + + // SSL/인증서 관련 + WELL_KNOWN("/.well-known/**", "ACME 챌린지/인증"), + + // 기타 스캔 대상 + DS_STORE("/.DS_Store", "macOS 메타데이터"), + VSCODE("/.vscode/**", "VSCode 설정"), + IDEA("/.idea/**", "IntelliJ 설정"), + AWS_CREDENTIALS("/aws/credentials", "AWS 자격증명"), + DOCKER_COMPOSE("/docker-compose.yml", "Docker Compose"), + CGI_BIN("/cgi-bin/**", "CGI 스크립트"), + + // 관리자 경로 (일반적인 스캔 대상) + ADMIN("/admin/**", "관리자 페이지"), + ADMINISTRATOR("/administrator/**", "관리자 페이지"); + + private final String pattern; + private final String description; + + /** 모든 악성 경로 패턴 배열 반환 */ + public static String[] getAllPatterns() { + return Arrays.stream(values()).map(MaliciousPathPattern::getPattern).toArray(String[]::new); + } + + /** 특정 카테고리의 패턴만 반환 (패턴 문자열 포함 여부로 필터링) */ + public static String[] getPatternsContaining(String keyword) { + return Arrays.stream(values()) + .filter(p -> p.pattern.contains(keyword) || p.description.contains(keyword)) + .map(MaliciousPathPattern::getPattern) + .toArray(String[]::new); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/global/security/constant/MaliciousPathPatternTest.java b/bottlenote-mono/src/test/java/app/bottlenote/global/security/constant/MaliciousPathPatternTest.java new file mode 100644 index 000000000..ac880f4a5 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/global/security/constant/MaliciousPathPatternTest.java @@ -0,0 +1,110 @@ +package app.bottlenote.global.security.constant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.web.util.pattern.PathPatternParser; + +@Tag("unit") +@DisplayName("MaliciousPathPattern 단위 테스트") +class MaliciousPathPatternTest { + + @Test + @DisplayName("getAllPatterns는 모든 악성 경로 패턴을 반환한다") + void getAllPatterns_모든_패턴_반환() { + // when + String[] patterns = MaliciousPathPattern.getAllPatterns(); + + // then + assertThat(patterns).hasSize(MaliciousPathPattern.values().length); + assertThat(patterns).contains("/.env*", "/.git/**", "/wp-admin/**", "/phpmyadmin/**"); + } + + @Test + @DisplayName("getAllPatterns는 빈 배열이 아니다") + void getAllPatterns_비어있지_않음() { + // when + String[] patterns = MaliciousPathPattern.getAllPatterns(); + + // then + assertThat(patterns).isNotEmpty(); + } + + @Test + @DisplayName("getPatternsContaining은 키워드를 포함하는 패턴만 반환한다") + void getPatternsContaining_키워드_필터링() { + // when + String[] wpPatterns = MaliciousPathPattern.getPatternsContaining("WordPress"); + + // then + assertThat(wpPatterns).contains("/wp-admin/**", "/wp-login.php", "/wp-config.php"); + assertThat(wpPatterns).doesNotContain("/.env*", "/.git/**"); + } + + @Test + @DisplayName("getPatternsContaining은 패턴 문자열로도 필터링할 수 있다") + void getPatternsContaining_패턴_문자열_필터링() { + // when + String[] envPatterns = MaliciousPathPattern.getPatternsContaining(".env"); + + // then + assertThat(envPatterns).contains("/.env*", "/.env.local", "/.env.production", "/.env.backup"); + } + + @Test + @DisplayName("모든 패턴은 슬래시로 시작한다") + void 모든_패턴_슬래시로_시작() { + // when & then + for (MaliciousPathPattern pattern : MaliciousPathPattern.values()) { + assertThat(pattern.getPattern()).as("패턴 '%s'는 슬래시로 시작해야 한다", pattern.name()).startsWith("/"); + } + } + + @Test + @DisplayName("모든 패턴은 설명을 가진다") + void 모든_패턴_설명_존재() { + // when & then + for (MaliciousPathPattern pattern : MaliciousPathPattern.values()) { + assertThat(pattern.getDescription()).as("패턴 '%s'는 설명이 있어야 한다", pattern.name()).isNotBlank(); + } + } + + @Test + @DisplayName("주요 보안 취약점 경로가 포함되어 있다") + void 주요_보안_취약점_경로_포함() { + // when + String[] patterns = MaliciousPathPattern.getAllPatterns(); + + // then - 환경 설정 파일 + assertThat(patterns).contains("/.env*"); + + // then - 버전 관리 + assertThat(patterns).contains("/.git/**"); + + // then - WordPress + assertThat(patterns).contains("/wp-admin/**", "/wp-login.php"); + + // then - DB 관리 도구 + assertThat(patterns).contains("/phpmyadmin/**"); + + // then - SSL/인증서 + assertThat(patterns).contains("/.well-known/**"); + } + + @Test + @DisplayName("모든 패턴은 Spring Security PathPattern으로 파싱 가능해야 한다") + void 모든_패턴_PathPattern_호환() { + // given + PathPatternParser parser = new PathPatternParser(); + + // when & then + for (MaliciousPathPattern pattern : MaliciousPathPattern.values()) { + assertThatCode(() -> parser.parse(pattern.getPattern())) + .as("패턴 '%s' (%s)는 PathPattern으로 파싱 가능해야 한다", pattern.name(), pattern.getPattern()) + .doesNotThrowAnyException(); + } + } +} diff --git a/bottlenote-product-api/src/main/java/app/global/security/SecurityConfig.java b/bottlenote-product-api/src/main/java/app/global/security/SecurityConfig.java index d2b98f444..daba0c793 100644 --- a/bottlenote-product-api/src/main/java/app/global/security/SecurityConfig.java +++ b/bottlenote-product-api/src/main/java/app/global/security/SecurityConfig.java @@ -2,6 +2,7 @@ import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; +import app.bottlenote.global.security.constant.MaliciousPathPattern; import app.bottlenote.global.security.jwt.JwtAuthenticationEntryPoint; import app.bottlenote.global.security.jwt.JwtAuthenticationFilter; import app.bottlenote.global.security.jwt.JwtAuthenticationManager; @@ -61,7 +62,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .authorizeHttpRequests( auth -> - auth.requestMatchers("/api/v1/picks/**") + auth.requestMatchers(MaliciousPathPattern.getAllPatterns()) + .denyAll() + .requestMatchers("/api/v1/picks/**") .authenticated() .requestMatchers("/api/v1/s3/**") .authenticated() diff --git a/bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java b/bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java new file mode 100644 index 000000000..3a0ffeec3 --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/global/security/SecurityConfigIntegrationTest.java @@ -0,0 +1,80 @@ +package app.global.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpStatus.FORBIDDEN; + +import app.bottlenote.IntegrationTestSupport; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.DispatcherServlet; + +@Tag("integration") +@DisplayName("[integration] SecurityConfig 악성 경로 차단 테스트") +class SecurityConfigIntegrationTest extends IntegrationTestSupport { + + private ListAppender logAppender; + private Logger dispatcherLogger; + + @BeforeEach + void setUpLogCapture() { + dispatcherLogger = (Logger) LoggerFactory.getLogger(DispatcherServlet.class); + logAppender = new ListAppender<>(); + logAppender.start(); + dispatcherLogger.addAppender(logAppender); + } + + @AfterEach + void tearDownLogCapture() { + dispatcherLogger.detachAppender(logAppender); + logAppender.stop(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "/.env", + "/.git/config", + "/wp-admin/test", + "/phpmyadmin/test", + "/.well-known/acme-challenge/test-token", + "/backup/dump", + "/.DS_Store" + }) + @DisplayName("악성 경로 요청 시 403 반환 및 NoResourceFoundException 로그 미발생") + void 악성_경로_차단_및_로그_미발생(String path) { + // when + var result = mockMvcTester.get().uri(path).exchange(); + + // then - 403 Forbidden 반환 + result.assertThat().hasStatus(FORBIDDEN); + + // then - NoResourceFoundException 로그가 발생하지 않아야 함 + assertThat(logAppender.list) + .extracting(ILoggingEvent::getFormattedMessage) + .noneMatch(msg -> msg != null && msg.contains("NoResourceFoundException")); + } + + @Test + @DisplayName("정상 API 경로는 차단되지 않는다") + void 정상_API_경로_허용() { + // when + var result = + mockMvcTester + .get() + .uri("/api/v1/alcohols/search") + .header("Authorization", "Bearer " + getToken()) + .exchange(); + + // then - 200 OK 또는 정상 응답 (403이 아님) + result.assertThat().hasStatusOk(); + } +} From bc2f0395d2f4a3d070a413be5ff3cd1836f83bc7 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 12:39:16 +0900 Subject: [PATCH 53/95] =?UTF-8?q?feat(admin-security):=20admin-api?= =?UTF-8?q?=EC=97=90=20=EC=95=85=EC=84=B1=20=EB=B4=87/=EC=8A=A4=EC=BA=90?= =?UTF-8?q?=EB=84=88=20=EA=B2=BD=EB=A1=9C=20=EC=B0=A8=EB=8B=A8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecurityConfig.kt에 MaliciousPathPattern.getAllPatterns() denyAll() 추가 - product-api와 동일한 악성 경로 차단 정책 적용 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/main/kotlin/app/global/security/SecurityConfig.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt index 5c3c2c5d0..105f0c001 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt @@ -1,5 +1,6 @@ package app.global.security +import app.bottlenote.global.security.constant.MaliciousPathPattern import app.bottlenote.global.security.jwt.AdminJwtAuthenticationFilter import app.bottlenote.global.security.jwt.AdminJwtAuthenticationManager import org.springframework.context.annotation.Bean @@ -30,6 +31,7 @@ class SecurityConfig( .httpBasic { it.disable() } .authorizeHttpRequests { auth -> auth + .requestMatchers(*MaliciousPathPattern.getAllPatterns()).denyAll() .requestMatchers("/auth/login", "/auth/refresh").permitAll() .requestMatchers("/actuator/**").permitAll() .anyRequest().authenticated() From 80669cb32711e95a527168889f0dcf189ae3cb9c Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 12:32:21 +0900 Subject: [PATCH 54/95] =?UTF-8?q?docs:=20batch=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - product-api에서 batch 모듈 분리 계획 정리 - K3s 배포 리소스 추가 필요 사항 정리 - GitHub Actions 워크플로우 추가 고려 사항 정리 - 현재 배치 작업 목록 및 의존성 분석 결과 포함 Co-Authored-By: Claude Opus 4.5 --- plan/batch-module-separation.md | 163 ++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 plan/batch-module-separation.md diff --git a/plan/batch-module-separation.md b/plan/batch-module-separation.md new file mode 100644 index 000000000..c0a1819cf --- /dev/null +++ b/plan/batch-module-separation.md @@ -0,0 +1,163 @@ +# Batch 모듈 분리 계획 + +## 개요 + +현재 `bottlenote-batch` 모듈이 `bottlenote-product-api`에 의존성으로 포함되어 함께 기동되고 있다. +이를 독립적인 애플리케이션으로 분리하여 별도 프로세스로 운영하는 것을 목표로 한다. + +## 현재 구조 + +``` +bottlenote-product-api (실행 가능 JAR) +├── bottlenote-mono (공유 라이브러리) +├── bottlenote-observability +└── bottlenote-batch (Quartz + Spring Batch) ← 분리 대상 +``` + +### 현재 의존성 (bottlenote-product-api/build.gradle:20) + +```gradle +implementation project(':bottlenote-batch') +``` + +### 현재 설정 연결 (application.yml) + +```yaml +spring: + profiles: + include: + - batch # batch 프로파일 활성화 +``` + +## 분리 목표 구조 + +``` +┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ +│ bottlenote-product-api │ │ bottlenote-batch-app │ +│ (REST API 서버) │ │ (배치 전용 데몬) │ +├─────────────────────────────────────┤ ├─────────────────────────────────────┤ +│ - HTTP API 제공 │ │ - Quartz 스케줄러 │ +│ - 톰캣 내장 │ │ - Spring Batch Jobs │ +│ - batch 의존성 제거 │ │ - 톰캣 없음 (web-application: none) │ +└────────────────────┬────────────────┘ └────────────────────┬────────────────┘ + │ │ + └────────────────┬──────────────────────────┘ + │ + ┌────────────────▼────────────────┐ + │ bottlenote-mono │ + │ (공유 라이브러리) │ + └─────────────────────────────────┘ +``` + +## 분리 작업 항목 + +### 1. Product-API 변경 + +| 파일 | 변경 내용 | +|------|----------| +| `bottlenote-product-api/build.gradle` | `implementation project(':bottlenote-batch')` 제거 | +| `application.yml` | `profiles.include`에서 `batch` 제거 | + +### 2. Batch 모듈 변경 + +| 파일 | 변경 내용 | +|------|----------| +| `bottlenote-batch/build.gradle` | bootJar 활성화, 실행 가능 JAR 설정 | +| `BatchApplication.java` (신규) | `@SpringBootApplication` 진입점 추가 | +| `application-batch.yml` | `web-application-type: none` 설정 | + +### 3. 배치 모듈 신규 설정 + +```yaml +# application-batch.yml 추가 설정 +spring: + main: + web-application-type: none # 톰캣 비활성화 +``` + +```java +// BatchApplication.java (신규) +@SpringBootApplication +public class BatchApplication { + public static void main(String[] args) { + SpringApplication.run(BatchApplication.class, args); + } +} +``` + +## 배포 관련 작업 + +### 1. K3s 배포 리소스 추가 (git.environment-variables) + +서브모듈 `git.environment-variables/deploy` 경로에 배치 서버 배포 리소스 추가 필요. + +| 경로 | 설명 | +|------|------| +| `deploy/base/batch-app.yaml` | Deployment 정의 (CronJob 또는 Deployment) | +| `deploy/overlays/development/batch-app-patch.yaml` | dev 환경 패치 | +| `deploy/overlays/production/batch-app-patch.yaml` | prod 환경 패치 | +| `deploy/base/kustomization.yaml` | batch-app.yaml 리소스 추가 | + +### 2. GitHub Actions 워크플로우 추가 + +기존 워크플로우 참조: `.github/workflows/deploy_v2_development.yml` + +신규 워크플로우 생성 필요: `deploy_v2_batch.yml` (예상) + +| 작업 | 설명 | +|------|------| +| JAR 빌드 | `./gradlew :bottlenote-batch:build` | +| Docker 이미지 빌드 | `Dockerfile-batch` 신규 생성 | +| 이미지 푸시 | 레지스트리에 batch 이미지 푸시 | +| Kustomize 업데이트 | batch 이미지 태그 업데이트 | + +### 3. 기존 워크플로우 구조 참고 + +현재 `deploy_v2_development.yml` 구조: +- `prepare-build`: JAR 빌드 및 artifact 업로드 +- `build-product-image`: Docker 이미지 빌드/푸시 +- `build-admin-image`: Docker 이미지 빌드/푸시 +- `update-development`: Kustomize 이미지 태그 업데이트 + +**고려 사항**: 기존 워크플로우가 product/admin을 함께 빌드하는 구조인데, batch를 추가할지 별도 워크플로우로 분리할지 결정 필요. + +## 현재 배치 작업 목록 + +| Job 이름 | Cron 표현식 | 역할 | +|----------|------------|------| +| bestReviewSelectedJob | 0 0 0 * * ? (매일 자정) | 베스트 리뷰 선정 | +| popularAlcoholJob | 0 0 0 * * ? (매일 자정) | 인기 위스키 선정 | +| dailyDataReportJob | 0 0 10 * * ? (매일 10시) | 일일 데이터 리포트 (Discord) | + +## 의존성 분석 결과 + +### Product-API에서 Batch 직접 참조 + +- **코드 레벨**: 없음 (import 없음) +- **설정 레벨**: `application.yml`에서 batch 프로파일 include +- **빈 레벨**: ComponentScan으로 Quartz Job들 자동 등록 + +### 분리 영향도 + +| 항목 | 영향도 | 설명 | +|------|--------|------| +| API 기능 | 없음 | API 코드가 batch를 참조하지 않음 | +| 스케줄 작업 | 높음 | 분리 후 batch 별도 기동 필요 | +| 테스트 | 중간 | DailyDataReportService 관련 테스트 확인 필요 | + +## 체크리스트 + +- [ ] Product-API에서 batch 의존성 제거 +- [ ] Batch 모듈 독립 실행 가능하게 변경 +- [ ] Dockerfile-batch 생성 +- [ ] K3s 배포 리소스 추가 (서브모듈) +- [ ] GitHub Actions 워크플로우 추가/수정 +- [ ] 로컬 테스트 (batch 단독 기동) +- [ ] 개발 환경 배포 테스트 +- [ ] 운영 환경 배포 + +## 미결정 사항 + +1. **워크플로우 전략**: 기존 워크플로우에 batch 추가 vs 별도 워크플로우 생성 +2. **배포 방식**: Deployment (상주형) vs CronJob (실행형) - 현재 Quartz 사용 중이므로 Deployment 권장 +3. **리소스 할당**: batch 서버 CPU/메모리 스펙 From ab7d50f87e464f21ca5cf714182f1a9817bdbbd0 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 13:02:20 +0900 Subject: [PATCH 55/95] =?UTF-8?q?docs:=20batch=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3개 배포 모듈 구조 다이어그램 추가 (product/admin/batch) - 공유 라이브러리 (mono/observability) 구조 명시 - 헬스체크: 톰캣 사용 (포트 8082), actuator 활용 - 배치 전용 application.yml 설정 예시 추가 - 결정/미결정 사항 정리 Co-Authored-By: Claude Opus 4.5 --- plan/batch-module-separation.md | 79 ++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/plan/batch-module-separation.md b/plan/batch-module-separation.md index c0a1819cf..13115b265 100644 --- a/plan/batch-module-separation.md +++ b/plan/batch-module-separation.md @@ -32,23 +32,30 @@ spring: ## 분리 목표 구조 ``` -┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ -│ bottlenote-product-api │ │ bottlenote-batch-app │ -│ (REST API 서버) │ │ (배치 전용 데몬) │ -├─────────────────────────────────────┤ ├─────────────────────────────────────┤ -│ - HTTP API 제공 │ │ - Quartz 스케줄러 │ -│ - 톰캣 내장 │ │ - Spring Batch Jobs │ -│ - batch 의존성 제거 │ │ - 톰캣 없음 (web-application: none) │ -└────────────────────┬────────────────┘ └────────────────────┬────────────────┘ - │ │ - └────────────────┬──────────────────────────┘ - │ - ┌────────────────▼────────────────┐ - │ bottlenote-mono │ - │ (공유 라이브러리) │ - └─────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ 배포 모듈 (실행 가능 JAR) │ +├───────────────────────┬───────────────────────┬───────────────────────────────┤ +│ product-api │ admin-api │ batch-app │ +│ :8080 │ :8081 │ :8082 │ +│ (REST API) │ (관리자 API) │ (Quartz + Batch) │ +└───────────┬───────────┴───────────┬───────────┴───────────────┬───────────────┘ + │ │ │ + └───────────────────────┼───────────────────────────┘ + │ + ┌───────────────────────▼───────────────────────┐ + │ 공유 라이브러리 (JAR) │ + ├───────────────────────┬───────────────────────┤ + │ bottlenote-mono │ bottlenote- │ + │ (도메인/비즈니스) │ observability │ + └───────────────────────┴───────────────────────┘ ``` +### 설계 원칙 + +- **mono 모듈**: 거대한 공유 라이브러리로 유지 (web 의존성 포함) +- **배포 모듈**: 각각 독립 실행 가능한 Spring Boot JAR +- **헬스체크**: mono가 web 의존성을 가지므로 톰캣 활용, 포트만 분리 + ## 분리 작업 항목 ### 1. Product-API 변경 @@ -69,10 +76,34 @@ spring: ### 3. 배치 모듈 신규 설정 ```yaml -# application-batch.yml 추가 설정 +# application.yml (batch 전용 - profile include 없이 독립 사용) +server: + port: 8082 + spring: - main: - web-application-type: none # 톰캣 비활성화 + application: + name: bottlenote-batch + + # Batch 설정 + batch: + job: + enabled: false + jdbc: + initialize-schema: never + + # Quartz 설정 + quartz: + job-store-type: jdbc + # ... 기존 quartz 설정 유지 + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: always ``` ```java @@ -156,8 +187,16 @@ public class BatchApplication { - [ ] 개발 환경 배포 테스트 - [ ] 운영 환경 배포 +## 결정 사항 + +| 항목 | 결정 | 이유 | +|------|------|------| +| 헬스체크 | 톰캣 사용 (포트 8082) | mono가 web 의존성 포함, actuator `/health` 활용 | +| 배포 방식 | Deployment (상주형) | Quartz 내장 스케줄러 사용 | +| 설정 방식 | 독립 `application.yml` | profile include 없이 배치 전용 설정 | + ## 미결정 사항 1. **워크플로우 전략**: 기존 워크플로우에 batch 추가 vs 별도 워크플로우 생성 -2. **배포 방식**: Deployment (상주형) vs CronJob (실행형) - 현재 Quartz 사용 중이므로 Deployment 권장 -3. **리소스 할당**: batch 서버 CPU/메모리 스펙 +2. **리소스 할당**: batch 서버 CPU/메모리 스펙 +3. **DB 커넥션 풀**: product/admin/batch 3개가 각각 커넥션 풀 가짐, 총 커넥션 수 조정 필요 여부 From a786a09b87009d875e2cf993ead1ea8859d9cc13 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 13:09:01 +0900 Subject: [PATCH 56/95] =?UTF-8?q?docs:=20DB=20=EC=BB=A4=EB=84=A5=EC=85=98?= =?UTF-8?q?=20=ED=92=80=20=EC=84=A4=EA=B3=84=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프리티어 DB 제약 고려한 커넥션 풀 배분 가이드 - product-api 10~15, admin-api 5, batch-app 3 권장 - batch-app HikariCP 설정 예시 추가 Co-Authored-By: Claude Opus 4.5 --- plan/batch-module-separation.md | 35 ++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/plan/batch-module-separation.md b/plan/batch-module-separation.md index 13115b265..701966b86 100644 --- a/plan/batch-module-separation.md +++ b/plan/batch-module-separation.md @@ -195,8 +195,41 @@ public class BatchApplication { | 배포 방식 | Deployment (상주형) | Quartz 내장 스케줄러 사용 | | 설정 방식 | 독립 `application.yml` | profile include 없이 배치 전용 설정 | +## DB 커넥션 풀 설계 + +### 배경 + +- 프리티어 DB 사용 중 (max_connections: 60~100개 추정) +- 3개 앱이 각각 커넥션 풀을 가지므로 총량 조정 필요 + +### 권장 배분 + +| 앱 | 풀 사이즈 | 이유 | +|---|---|---| +| product-api | 10~15 | 메인 트래픽 처리, 동시 요청 많음 | +| admin-api | 5 | 관리자 전용, 사용 빈도 낮음 | +| batch-app | 3 | 스케줄 작업, 동시성 낮음 | +| **여유분** | 나머지 | 시스템/모니터링 용도 | + +### batch-app 설정 예시 + +```yaml +spring: + datasource: + hikari: + maximum-pool-size: 3 + minimum-idle: 1 + connection-timeout: 30000 + idle-timeout: 600000 +``` + +### 고려사항 + +- 배치는 동시 요청이 거의 없음 (스케줄 기반) +- 매일 자정에 Job 3개 순차 실행 → 커넥션 2~3개면 충분 +- product-api가 주력이므로 커넥션 여유 확보 필요 + ## 미결정 사항 1. **워크플로우 전략**: 기존 워크플로우에 batch 추가 vs 별도 워크플로우 생성 2. **리소스 할당**: batch 서버 CPU/메모리 스펙 -3. **DB 커넥션 풀**: product/admin/batch 3개가 각각 커넥션 풀 가짐, 총 커넥션 수 조정 필요 여부 From 7d905cb5fbdb772bb78ada3dd98f2dcbb585b551 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 16:29:41 +0900 Subject: [PATCH 57/95] =?UTF-8?q?docs(batch):=20BestReviewReader=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=20=EB=A3=A8=ED=94=84=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- plan/batch-module-separation.md | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/plan/batch-module-separation.md b/plan/batch-module-separation.md index 701966b86..3d0539825 100644 --- a/plan/batch-module-separation.md +++ b/plan/batch-module-separation.md @@ -233,3 +233,83 @@ spring: 1. **워크플로우 전략**: 기존 워크플로우에 batch 추가 vs 별도 워크플로우 생성 2. **리소스 할당**: batch 서버 CPU/메모리 스펙 + +--- + +## 알려진 버그: BestReviewReader 무한 루프 + +> 분리 작업 전 반드시 수정 필요 + +### 현상 + +- `bestReviewSelectedStep`이 종료되지 않고 `STARTED` 상태로 유지 +- READ_COUNT/WRITE_COUNT가 비정상적으로 증가 (수천만~수억 건) +- 베스트 리뷰 초기화(`resetBestReviewStep`)는 완료되지만, 새로운 베스트 리뷰 선정이 끝나지 않음 + +### 증거 (BATCH_STEP_EXECUTION 테이블) + +``` +STEP_NAME | STATUS | READ_COUNT | WRITE_COUNT +-----------------------|---------|-------------|------------- +bestReviewSelectedStep | STARTED | 111,320,100 | 111,320,100 ← 무한 루프 +resetBestReviewStep | COMPLETED | 0 | 0 ← 정상 +``` + +### 원인 + +`BestReviewReader.read()` 메서드의 논리 오류: + +```java +@Override +public BestReviewPayload read() { + if (results == null) { + results = jdbcTemplate.query(query, ...); // 쿼리 실행 + currentIndex = 0; + } + + BestReviewPayload nextItem = null; + if (currentIndex < results.size()) { + nextItem = results.get(currentIndex); + currentIndex++; + } + + if (currentIndex >= results.size()) { + results = null; // ← 문제: 마지막 아이템 반환 시 null로 초기화 + } + + return nextItem; // ← 마지막 아이템 반환 +} +``` + +**문제 흐름** (size=100 가정): + +1. `currentIndex = 99` (마지막) +2. `nextItem = results.get(99)` 할당 +3. `currentIndex++` → 100 +4. `100 >= 100` → `results = null` +5. `return nextItem` (마지막 아이템 반환) +6. 다음 `read()` 호출 → `results == null` → 쿼리 다시 실행 → **무한 반복** + +**올바른 동작**: 마지막 아이템 반환 후, 다음 `read()` 호출 시 `null`을 반환해야 배치가 종료됨. + +### 해결 방안 + +```java +@Override +public BestReviewPayload read() { + if (results == null) { + results = jdbcTemplate.query(query, new BestReviewPayload.BestReviewMapper()); + currentIndex = 0; + } + + if (currentIndex >= results.size()) { + return null; // 모든 아이템 처리 완료 → 배치 종료 + } + + return results.get(currentIndex++); +} +``` + +### 파일 위치 + +`bottlenote-batch/src/main/java/app/batch/bottlenote/job/BestReviewSelectionJobConfig.java` (132~162 라인) From a6a3f16bd7e689b7676e743d39da21215223beca Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 16:47:28 +0900 Subject: [PATCH 58/95] =?UTF-8?q?fix:=20=EC=98=88=EC=99=B8=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/handler/GlobalExceptionHandler.java | 4 ++-- git.environment-variables | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java index 4118afcfc..3642562b6 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java @@ -41,7 +41,7 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(AbstractCustomException.class) public ResponseEntity handleCustomException(AbstractCustomException exception) { - log.warn("사용자 정의 예외 발생 : ", exception); + log.warn("사용자 정의 예외 발생 : {}", exception.getMessage()); return GlobalResponse.error(exception); } @@ -53,7 +53,7 @@ public ResponseEntity handleCustomException(AbstractCustomException exception */ @ExceptionHandler(value = {Exception.class}) public ResponseEntity handleGenericException(Exception exception) { - log.error("Exception.class 예외 발생 : ", exception); + log.error("Exception.class 예외 발생 : {}", exception.getMessage()); Error error = Error.of(UNKNOWN_ERROR.message(exception.getMessage())); return GlobalResponse.error(error); } diff --git a/git.environment-variables b/git.environment-variables index e9740a429..d5729fa8f 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit e9740a4297c2c90d3ca0a7c23ad1682a918e4b78 +Subproject commit d5729fa8f9074654a24f120704ede2ef13c657bc From ea4c21be9a1c630164ada9fde7cbac0590f75d15 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 16:49:16 +0900 Subject: [PATCH 59/95] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EB=B0=8F=20Quartz=20=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bottlenote-product-api/build.gradle | 1 - .../src/main/resources/application.yml | 1 - .../app/bottlenote/IntegrationTestSupport.java | 2 +- .../src/test/resources/application-test.yml | 17 ----------------- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/bottlenote-product-api/build.gradle b/bottlenote-product-api/build.gradle index 4d9527e56..6bab1e515 100644 --- a/bottlenote-product-api/build.gradle +++ b/bottlenote-product-api/build.gradle @@ -17,7 +17,6 @@ dependencies { //noinspection GrUnresolvedAccess testImplementation project(':bottlenote-mono').sourceSets.test.output implementation project(':bottlenote-observability') - implementation project(':bottlenote-batch') // ===== Spring Boot Web ===== implementation libs.spring.boot.starter.web diff --git a/bottlenote-product-api/src/main/resources/application.yml b/bottlenote-product-api/src/main/resources/application.yml index 0a562bb9a..d16be02ac 100644 --- a/bottlenote-product-api/src/main/resources/application.yml +++ b/bottlenote-product-api/src/main/resources/application.yml @@ -26,7 +26,6 @@ spring: - datasource - external - observability - - batch jackson: time-zone: Asia/Seoul diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java b/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java index 50fe7678c..f915cab69 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java @@ -24,7 +24,7 @@ import org.springframework.test.web.servlet.assertj.MvcTestResult; @Import(TestContainersConfig.class) -@ActiveProfiles({"test", "batch"}) +@ActiveProfiles("test") @Tag("integration") @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) diff --git a/bottlenote-product-api/src/test/resources/application-test.yml b/bottlenote-product-api/src/test/resources/application-test.yml index 0865add11..7cb838067 100644 --- a/bottlenote-product-api/src/test/resources/application-test.yml +++ b/bottlenote-product-api/src/test/resources/application-test.yml @@ -31,12 +31,6 @@ spring: # - classpath:storage/mysql/init/01-init-core-table.sql # # - classpath:storage/mysql/init/*.sql - batch: - jdbc: - initialize-schema: always - job: - enabled: false - # JPA jpa: properties: @@ -51,17 +45,6 @@ spring: generate-ddl: false # SQL 스크립트로 스키마 생성 database-platform: org.hibernate.dialect.MySQL8Dialect - quartz: - job-store-type: jdbc # 메모리(memory) 또는 데이터베이스(jdbc) - jdbc: - initialize-schema: embedded - properties: - org: - quartz: - scheduler: - instanceName: bottle_note_quartz_scheduler - instanceId: AUTO - # Spring Security security: jwt: From 5d3ffef65f8c27b2a64403c369ed25ed6193b5ec Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 16 Jan 2026 16:54:12 +0900 Subject: [PATCH 60/95] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=B4=88=EA=B8=B0=20=EC=84=A4=EC=A0=95=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 - BatchApplication 클래스 생성 - Spring Batch 및 Quartz 의존성 추가 - 빌드 설정 및 JAR 생성 규칙 수정 --- bottlenote-batch/VERSION | 1 + bottlenote-batch/build.gradle | 23 ++++++++++++++++++- .../batch/bottlenote/BatchApplication.java | 11 +++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 bottlenote-batch/VERSION create mode 100644 bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java diff --git a/bottlenote-batch/VERSION b/bottlenote-batch/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/bottlenote-batch/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/bottlenote-batch/build.gradle b/bottlenote-batch/build.gradle index cf1550141..a7ce1752b 100644 --- a/bottlenote-batch/build.gradle +++ b/bottlenote-batch/build.gradle @@ -1,10 +1,31 @@ // batch-module + +bootJar { + enabled = true + archiveFileName = 'bottlenote-batch.jar' +} + +jar { + enabled = true +} + dependencies { implementation project(':bottlenote-mono') + //noinspection GrUnresolvedAccess + testImplementation project(':bottlenote-mono').sourceSets.test.output + + // batch + implementation libs.spring.boot.starter implementation libs.spring.boot.starter.batch - testImplementation libs.spring.batch.test implementation libs.spring.boot.starter.quartz + + // Test - Lombok + testAnnotationProcessor libs.lombok + + // Test + testImplementation libs.bundles.testcontainers.complete + testImplementation libs.spring.batch.test } tasks.register("prepareKotlinBuildScriptModel") {} diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java new file mode 100644 index 000000000..c53f1f2e3 --- /dev/null +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java @@ -0,0 +1,11 @@ +package app.batch.bottlenote; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = {"app.batch.bottlenote", "app.bottlenote"}) +public class BatchApplication { + public static void main(String[] args) { + SpringApplication.run(BatchApplication.class, args); + } +} From 3f450beba05162c6a4e170312dd08bc013999573 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 17 Jan 2026 04:33:54 +0900 Subject: [PATCH 61/95] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배치 모듈 설정 리팩토링 및 환경별 프로파일 설정 추가 - Quartz Job 관련 클래스 리팩토링 및 Dockerfile 추가 - ECR 관련 리소스 제거 - aws-credentials.sops.yaml 파일 참조 제거 - SecretStore 리소스 제거 및 트레이싱 설정 비활성화 - eso-secret-store 리소스 참조 제거 - 불필요한 배치 관련 클래스 및 설정 파일 제거 --- Dockerfile-batch | 20 + bottlenote-batch/BATCH_TESTING_GUIDE.md | 514 ++++++++++++++++++ bottlenote-batch/build.gradle | 37 +- .../batch/bottlenote/BatchApplication.java | 14 +- .../{schedule => }/BatchQuartzJob.java | 9 +- .../{schedule => config}/QuartzConfig.java | 6 +- .../bottlenote/config/SecurityConfig.java | 17 + .../data/payload/BestReviewPayload.java | 17 - .../data/payload/PopularItemPayload.java | 38 -- .../BestReviewSelectionJobConfig.java | 46 +- .../PopularAlcoholSelectionJobConfig.java | 67 ++- .../DailyDataReportJobConfig.java | 37 +- .../PopularAlcoholProperties.java | 2 +- .../schedule/BestReviewQuartzJob.java | 35 -- .../schedule/DailyDataReportQuartzJob.java | 54 -- .../schedule/PopularAlcoholQuartzJob.java | 35 -- .../src/main/resources/application-batch.yml | 26 - .../main/resources/application-datasource.yml | 69 +++ .../main/resources/application-external.yml | 20 + .../src/main/resources/application.yml | 77 +++ .../bottlenote/config/BatchConfigTest.java | 8 - .../DailyDataReportQuartzJobTest.java | 132 ----- .../src/test/resources/application.yml | 86 +++ .../security/jwt/AppleTokenValidator.java | 2 +- build.gradle | 6 + git.environment-variables | 2 +- tmpclaude-d340-cwd | 1 - 27 files changed, 979 insertions(+), 398 deletions(-) create mode 100644 Dockerfile-batch create mode 100644 bottlenote-batch/BATCH_TESTING_GUIDE.md rename bottlenote-batch/src/main/java/app/batch/bottlenote/{schedule => }/BatchQuartzJob.java (97%) rename bottlenote-batch/src/main/java/app/batch/bottlenote/{schedule => config}/QuartzConfig.java (93%) create mode 100644 bottlenote-batch/src/main/java/app/batch/bottlenote/config/SecurityConfig.java delete mode 100644 bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java delete mode 100644 bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/PopularItemPayload.java rename bottlenote-batch/src/main/java/app/batch/bottlenote/job/{ => ranking}/BestReviewSelectionJobConfig.java (83%) rename bottlenote-batch/src/main/java/app/batch/bottlenote/job/{ => ranking}/PopularAlcoholSelectionJobConfig.java (71%) rename bottlenote-batch/src/main/java/app/batch/bottlenote/job/{ => report}/DailyDataReportJobConfig.java (63%) rename bottlenote-batch/src/main/java/app/batch/bottlenote/{config => properties}/PopularAlcoholProperties.java (99%) delete mode 100644 bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/BestReviewQuartzJob.java delete mode 100644 bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java delete mode 100644 bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/PopularAlcoholQuartzJob.java delete mode 100644 bottlenote-batch/src/main/resources/application-batch.yml create mode 100644 bottlenote-batch/src/main/resources/application-datasource.yml create mode 100644 bottlenote-batch/src/main/resources/application-external.yml create mode 100644 bottlenote-batch/src/main/resources/application.yml delete mode 100644 bottlenote-batch/src/test/java/app/batch/bottlenote/config/BatchConfigTest.java delete mode 100644 bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java create mode 100644 bottlenote-batch/src/test/resources/application.yml delete mode 100644 tmpclaude-d340-cwd diff --git a/Dockerfile-batch b/Dockerfile-batch new file mode 100644 index 000000000..52c2479c5 --- /dev/null +++ b/Dockerfile-batch @@ -0,0 +1,20 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +ARG GIT_COMMIT=unknown +ARG GIT_BRANCH=unknown +ARG BUILD_TIME=unknown + +ENV GIT_COMMIT=${GIT_COMMIT} +ENV GIT_BRANCH=${GIT_BRANCH} +ENV BUILD_TIME=${BUILD_TIME} +ENV TZ=Asia/Seoul + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +RUN mkdir -p config + +COPY bottlenote-batch/build/libs/bottlenote-batch.jar /app.jar + +ENV SPRING_PROFILES_ACTIVE=default + +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/bottlenote-batch/BATCH_TESTING_GUIDE.md b/bottlenote-batch/BATCH_TESTING_GUIDE.md new file mode 100644 index 000000000..b4cb0a583 --- /dev/null +++ b/bottlenote-batch/BATCH_TESTING_GUIDE.md @@ -0,0 +1,514 @@ +# Spring Batch 테스트 가이드 + +## 목차 +1. [현재 프로젝트 테스트 현황](#1-현재-프로젝트-테스트-현황) +2. [테스트 유형별 접근법](#2-테스트-유형별-접근법) +3. [핵심 테스트 도구](#3-핵심-테스트-도구) +4. [프로젝트 Job별 테스트 전략](#4-프로젝트-job별-테스트-전략) +5. [테스트 코드 예시](#5-테스트-코드-예시) +6. [JDBC vs InMemoryRepository](#6-jdbc-vs-inmemoryrepository) +7. [권장 사항](#7-권장-사항) + +--- + +## 1. 현재 프로젝트 테스트 현황 + +| 파일 | 상태 | 설명 | +|------|------|------| +| `BatchConfigTest.java` | 비어있음 | 구현 필요 | +| `DailyDataReportQuartzJobTest.java` | 구현됨 | Quartz Job 단위 테스트 (Mock 기반) | + +현재는 Quartz Job의 호출 여부만 검증하고, 실제 Batch Job 로직 테스트는 없는 상태입니다. + +### 배치 모듈 구조 + +``` +app.batch.bottlenote/ +├── config/ +│ └── QuartzConfig.java # Quartz 스케줄러 설정 +├── properties/ +│ └── PopularAlcoholProperties.java # 인기 주류 설정 +└── job/ + ├── ranking/ + │ ├── BestReviewSelectionJobConfig.java # Chunk 기반 (다중 Step) + │ └── PopularAlcoholSelectionJobConfig.java # Chunk 기반 + └── report/ + └── DailyDataReportJobConfig.java # Tasklet 기반 +``` + +--- + +## 2. 테스트 유형별 접근법 + +### 2.1 단위 테스트 (Unit Test) + +**목표**: Reader, Processor, Writer 개별 비즈니스 로직 검증 + +**특징**: +- 외부 의존성 Mock 처리 +- 빠른 실행 속도 +- `@Tag("unit")` 사용 + +```java +@Tag("unit") +@SpringBatchTest +@SpringJUnitConfig(classes = {BatchConfig.class}) +class MyItemReaderTest { + @Autowired + private ItemReader itemReader; + + // StepScope 빈의 JobParameters 주입을 위한 팩토리 메서드 + public StepExecution getStepExecution() { + return MetaDataInstanceFactory.createStepExecution(); + } + + @Test + @DisplayName("ItemReader가 데이터를 정상적으로 읽는다") + void testRead() { + // when + Data data = itemReader.read(); + + // then + assertThat(data).isNotNull(); + } +} +``` + +### 2.2 통합 테스트 (Integration Test) + +**목표**: End-to-End Job 실행 및 DB 상태 변화 검증 + +**특징**: +- TestContainers로 실제 DB 환경 구성 +- 전체 Job 흐름 검증 +- `@Tag("integration")` 사용 + +```java +@Tag("integration") +@SpringBatchTest +@SpringBootTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = NONE) +class BatchJobIntegrationTest { + @Autowired + JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private TestRepository testRepository; + + @Test + @DisplayName("Job이 정상적으로 완료된다") + void testCompleteJobExecution() { + // given: 테스트 데이터 준비 + testRepository.save(testData); + + // when: Job 실행 + JobExecution execution = jobLauncherTestUtils.launchJob(); + + // then: 결과 검증 + assertThat(execution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + assertThat(testRepository.findAll()).hasSize(expectedSize); + } +} +``` + +### 2.3 슬라이스 테스트 (Slice Test) + +**목표**: 특정 Step만 격리하여 테스트 + +**특징**: +- 다중 Step Job에서 특정 Step만 검증 +- 이전 Step 영향 없이 독립 테스트 + +```java +@Test +@DisplayName("베스트 리뷰 초기화 Step이 정상 동작한다") +void testResetBestReviewStep() { + // when: 특정 Step만 실행 + JobExecution execution = jobLauncherTestUtils.launchStep("resetBestReviewStep"); + + // then + assertThat(execution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); +} +``` + +--- + +## 3. 핵심 테스트 도구 + +### 3.1 @SpringBatchTest + +Spring Batch 테스트를 위한 통합 설정 어노테이션 + +**자동 제공 기능**: +- `JobLauncherTestUtils` 빈 자동 등록 +- `JobRepositoryTestUtils` 빈 자동 등록 +- `StepScopeTestExecutionListener` 자동 추가 +- `JobScopeTestExecutionListener` 자동 추가 + +### 3.2 JobLauncherTestUtils + +| 메서드 | 설명 | +|--------|------| +| `launchJob(JobParameters)` | 전체 Job 실행 | +| `launchStep(String stepName)` | 특정 Step만 실행 | +| `setJob(Job job)` | 테스트할 Job 설정 | + +### 3.3 StepScopeTestExecutionListener + +`@StepScope` 빈의 JobParameters 주입을 위한 리스너 + +**사용 방법**: +```java +// 팩토리 메서드 정의 (리스너가 자동 감지) +public StepExecution getStepExecution() { + StepExecution execution = MetaDataInstanceFactory.createStepExecution(); + // JobParameters 추가 가능 + execution.getJobExecution().getJobParameters() + .addString("input.file", "test.csv"); + return execution; +} +``` + +### 3.4 MetaDataInstanceFactory + +테스트용 메타데이터 객체 생성 유틸리티 + +| 메서드 | 설명 | +|--------|------| +| `createStepExecution()` | 기본 StepExecution 생성 | +| `createStepExecution(String, Long)` | 이름과 ID로 생성 | +| `createJobExecution()` | JobExecution 생성 | + +--- + +## 4. 프로젝트 Job별 테스트 전략 + +### 4.1 DailyDataReportJobConfig (Tasklet 기반) + +**특성**: 단순 작업 (데이터 수집 -> Discord 웹훅 전송) + +**테스트 전략**: +- Mock 서비스 + 단위 테스트 +- `DailyDataReportService` Mock 처리 +- 웹훅 호출 여부 검증 + +```java +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class DailyDataReportJobConfigTest { + @Mock + private DailyDataReportService dailyDataReportService; + + @Mock + private DiscordWebhookProperties discordWebhookProperties; + + @Test + @DisplayName("일일 리포트 Job이 서비스를 정상 호출한다") + void testDailyDataReportJob() { + // given + when(discordWebhookProperties.getUrl()).thenReturn("https://webhook.url"); + + // when: Job 실행 + // ... + + // then + verify(dailyDataReportService, times(1)) + .collectAndSendDailyReport(any(LocalDate.class), anyString()); + } +} +``` + +### 4.2 PopularAlcoholSelectionJobConfig (Chunk 기반) + +**특성**: 복잡한 처리 (ItemReader -> Processor -> ItemWriter) + +**테스트 전략**: +- TestContainers + 통합 테스트 +- 실제 DB 데이터 기반 검증 +- 청크 처리 결과 검증 + +```java +@Tag("integration") +@SpringBatchTest +@SpringBootTest +@Import(TestContainersConfig.class) +class PopularAlcoholSelectionJobIntegrationTest { + @Autowired + JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // 테스트 데이터 준비 + jdbcTemplate.execute("INSERT INTO alcohols ..."); + } + + @Test + @DisplayName("인기 주류 선정 Job이 정상 완료된다") + void testPopularAlcoholJob() { + // when + JobExecution execution = jobLauncherTestUtils.launchJob(); + + // then + assertThat(execution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM popular_alcohols WHERE year = ? AND month = ? AND day = ?", + Integer.class, LocalDate.now().getYear(), LocalDate.now().getMonthValue(), LocalDate.now().getDayOfMonth() + ); + assertThat(count).isGreaterThan(0); + } +} +``` + +### 4.3 BestReviewSelectionJobConfig (다중 Step Chunk 기반) + +**특성**: 다중 Step (초기화 Step -> 선정 Step) + +**테스트 전략**: +- Step 슬라이스 테스트 + 통합 테스트 +- 각 Step 독립 검증 +- 전체 Job 흐름 검증 + +```java +@Tag("integration") +@SpringBatchTest +@SpringBootTest +@Import(TestContainersConfig.class) +class BestReviewSelectionJobIntegrationTest { + @Autowired + JobLauncherTestUtils jobLauncherTestUtils; + + @Test + @DisplayName("베스트 리뷰 초기화 Step이 모든 리뷰를 초기화한다") + void testResetStep() { + // given: 베스트 리뷰 데이터 준비 + + // when + JobExecution execution = jobLauncherTestUtils.launchStep("resetBestReviewStep"); + + // then + assertThat(execution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + // 모든 is_best가 false인지 검증 + } + + @Test + @DisplayName("베스트 리뷰 선정 Step이 올바른 리뷰를 선정한다") + void testSelectionStep() { + // given: 리뷰 데이터 준비 + + // when + JobExecution execution = jobLauncherTestUtils.launchStep("bestReviewSelectedStep"); + + // then + assertThat(execution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + // 선정된 리뷰 검증 + } +} +``` + +--- + +## 5. 테스트 코드 예시 + +### 5.1 Quartz Job 테스트 (현재 구현됨) + +```java +@Tag("unit") +@DisplayName("[unit] [schedule] DailyDataReportQuartzJob") +@ExtendWith(MockitoExtension.class) +class DailyDataReportQuartzJobTest { + + @Mock private JobLauncher jobLauncher; + @Mock private JobRegistry jobRegistry; + @Mock private JobExecutionContext context; + @Mock private Job job; + + private DailyDataReportQuartzJob dailyDataReportQuartzJob; + private AppInfoConfig appInfoConfig; + + @BeforeEach + void setUp() { + appInfoConfig = new AppInfoConfig(); + appInfoConfig.setEnvironment("prod"); + dailyDataReportQuartzJob = new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); + } + + @Test + @DisplayName("prod 환경에서는 Job이 정상 실행된다") + void testExecuteInternal_prod환경실행() throws Exception { + // given + when(jobRegistry.getJob(eq(DAILY_DATA_REPORT_JOB_NAME))).thenReturn(job); + when(jobLauncher.run(any(Job.class), any(JobParameters.class))).thenReturn(null); + + // when + dailyDataReportQuartzJob.executeInternal(context); + + // then + verify(jobRegistry, times(1)).getJob(DAILY_DATA_REPORT_JOB_NAME); + verify(jobLauncher, times(1)).run(any(Job.class), any(JobParameters.class)); + } + + @Test + @DisplayName("dev 환경에서는 Job이 실행되지 않는다") + void testExecuteInternal_dev환경스킵() throws Exception { + // given + appInfoConfig.setEnvironment("dev"); + dailyDataReportQuartzJob = new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); + + // when + dailyDataReportQuartzJob.executeInternal(context); + + // then + verify(jobRegistry, times(0)).getJob(any()); + verify(jobLauncher, times(0)).run(any(Job.class), any(JobParameters.class)); + } +} +``` + +### 5.2 Batch Job 통합 테스트 템플릿 + +```java +@Tag("integration") +@DisplayName("[integration] [batch] PopularAlcoholSelectionJob") +@SpringBatchTest +@SpringBootTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class PopularAlcoholSelectionJobIntegrationTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private Job popularAlcoholJob; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(popularAlcoholJob); + // 테스트 데이터 초기화 + jdbcTemplate.execute("DELETE FROM popular_alcohols"); + } + + @Test + @DisplayName("인기 주류 선정 Job이 정상적으로 완료된다") + void 인기_주류_선정_Job이_정상적으로_완료된다() throws Exception { + // given: 테스트 데이터 준비 + // ... + + // when + JobExecution jobExecution = jobLauncherTestUtils.launchJob(); + + // then + assertThat(jobExecution.getExitStatus()).isEqualTo(ExitStatus.COMPLETED); + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + } + + @Test + @DisplayName("인기 주류 데이터가 정상적으로 저장된다") + void 인기_주류_데이터가_정상적으로_저장된다() throws Exception { + // given + LocalDate today = LocalDate.now(); + + // when + jobLauncherTestUtils.launchJob(); + + // then + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM popular_alcohols WHERE year = ? AND month = ? AND day = ?", + Integer.class, + today.getYear(), + today.getMonthValue(), + today.getDayOfMonth() + ); + assertThat(count).isGreaterThan(0); + } +} +``` + +--- + +## 6. JDBC vs InMemoryRepository + +### 6.1 Spring Batch가 JDBC를 사용하는 이유 + +**JobRepository 메타데이터 저장**: +- `BATCH_JOB_INSTANCE`, `BATCH_JOB_EXECUTION` 등 테이블 +- Job 실행 상태, 재시작 지점, 청크 커밋 추적 +- 실패 시 어디서부터 재시작할지 기록 + +이것은 **배치 프레임워크 인프라** 요구사항이다. + +### 6.2 비즈니스 로직에서 JdbcTemplate 직접 사용 이유 + +**대량 데이터 처리 성능**: +| 항목 | JPA/Repository | JdbcTemplate | +|------|----------------|--------------| +| 벌크 연산 | 엔티티 하나씩 로드 후 변경 | 한 방 쿼리 | +| 메모리 | 1차 캐시에 전부 로드 (OOM 위험) | 필요한 것만 처리 | +| 변경 감지 | Hibernate dirty checking 오버헤드 | 없음 | + +### 6.3 테스트에서의 선택 + +**프로덕션**: 대량 처리 → JdbcTemplate 유리 + +**테스트**: 데이터 몇 건 → **InMemoryRepository로 충분** + +현재 `BestReviewSelectionJobConfig`가 JdbcTemplate을 직접 사용하는 이유는 ReviewRepository에 배치용 메서드가 없기 때문. 필요한 메서드를 추가하면 InMemoryRepository로 테스트 가능: +- `resetAllBestReviews()` - 모든 is_best 초기화 +- `findBestReviewCandidates()` - 베스트 후보 조회 +- `updateBestReviews(List ids)` - 베스트 업데이트 + +--- + +## 7. 권장 사항 + +### 7.1 테스트 분리 원칙 + +| 구분 | Quartz Job | Spring Batch Job | +|------|------------|------------------| +| 역할 | 언제/어떻게 실행 | 무엇을 실행 | +| 테스트 방식 | Mock 기반 단위 테스트 | 통합 테스트 / 슬라이스 테스트 | +| 검증 포인트 | JobLauncher 호출 여부 | 실제 데이터 처리 결과 | + +### 7.2 프로젝트 패턴 적용 + +1. **InMemory Repository 패턴**: DB 의존성 제거한 단위 테스트 +2. **TestFactory 패턴**: 테스트 데이터 빌더 +3. **Given-When-Then 구조**: 명확한 테스트 구조 +4. **테스트 태그 구분**: `@Tag("unit")`, `@Tag("integration")` + +### 7.3 테스트 실행 명령어 + +```bash +# 배치 모듈 테스트 (@Tag("batch")) +./gradlew :bottlenote-batch:batch_test + +# 특정 테스트 클래스 실행 +./gradlew :bottlenote-batch:batch_test --tests "BestReviewSelectionJobConfigTest" + +# 루트에서 전체 배치 테스트 +./gradlew batch_test +``` + +### 7.4 CI/CD 고려사항 + +- 배치 테스트는 실행 시간이 길 수 있으므로 별도 파이프라인 고려 +- 통합 테스트는 TestContainers 필요 (Docker 환경) +- 단위 테스트와 통합 테스트를 분리하여 실행 + +--- + +## 참고 자료 + +- [Spring Batch Testing Reference](https://docs.spring.io/spring-batch/reference/testing.html) +- [Testing a Spring Batch Job | Baeldung](https://www.baeldung.com/spring-batch-testing-job) +- [JobLauncherTestUtils API](https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/test/JobLauncherTestUtils.html) +- [StepScopeTestExecutionListener API](https://docs.spring.io/spring-batch/docs/current/api/org/springframework/batch/test/StepScopeTestExecutionListener.html) +- [Spring Batch - Tasklets vs Chunks](https://www.baeldung.com/spring-batch-tasklet-chunk) diff --git a/bottlenote-batch/build.gradle b/bottlenote-batch/build.gradle index a7ce1752b..609ad3b89 100644 --- a/bottlenote-batch/build.gradle +++ b/bottlenote-batch/build.gradle @@ -14,11 +14,11 @@ dependencies { //noinspection GrUnresolvedAccess testImplementation project(':bottlenote-mono').sourceSets.test.output - // batch implementation libs.spring.boot.starter implementation libs.spring.boot.starter.batch implementation libs.spring.boot.starter.quartz + implementation libs.spring.boot.starter.actuator // Test - Lombok testAnnotationProcessor libs.lombok @@ -26,6 +26,41 @@ dependencies { // Test testImplementation libs.bundles.testcontainers.complete testImplementation libs.spring.batch.test + + //security 필요없지만 mono 호환성을 위해 + implementation libs.spring.boot.starter.security + implementation libs.spring.security.test + testImplementation libs.spring.security.test +} + +sourceSets { + main { + resources { + srcDirs = ['src/main/resources', + "${rootProject.projectDir}/git.environment-variables"] + } + } + test { + resources { + srcDirs = ['src/test/resources', + "${rootProject.projectDir}/git.environment-variables"] + } + } } tasks.register("prepareKotlinBuildScriptModel") {} + +// 배치 테스트 태그 설정 +tasks.register('batch_test', Test) { + group = 'verification' + description = '배치 모듈 테스트 실행 (@Tag("batch"))' + useJUnitPlatform { + includeTags 'batch' + } +} + +// CI에서 batch 테스트 제외 - batch는 독립 모듈로 별도 테스트 +tasks.named('unit_test') { enabled = false } +tasks.named('integration_test') { enabled = false } +tasks.named('check_rule_test') { enabled = false } +tasks.named('admin_integration_test') { enabled = false } diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java index c53f1f2e3..ece17edc1 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java @@ -1,11 +1,17 @@ package app.batch.bottlenote; +import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.ComponentScan; -@SpringBootApplication(scanBasePackages = {"app.batch.bottlenote", "app.bottlenote"}) +@EntityScan(basePackages = "app") +@SpringBootApplication(scanBasePackages = "app") +@ComponentScan(basePackages = "app") public class BatchApplication { - public static void main(String[] args) { - SpringApplication.run(BatchApplication.class, args); - } + public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + SpringApplication.run(BatchApplication.class, args); + } } diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/BatchQuartzJob.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchQuartzJob.java similarity index 97% rename from bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/BatchQuartzJob.java rename to bottlenote-batch/src/main/java/app/batch/bottlenote/BatchQuartzJob.java index 6df3e8559..49a99a331 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/BatchQuartzJob.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchQuartzJob.java @@ -1,6 +1,8 @@ -package app.batch.bottlenote.schedule; +package app.batch.bottlenote; +import static java.time.LocalDateTime.now; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.quartz.JobExecutionContext; @@ -12,9 +14,6 @@ import org.springframework.lang.NonNull; import org.springframework.scheduling.quartz.QuartzJobBean; -import static java.time.LocalDateTime.now; - - /** * Quartz 스케줄러를 이용한 Spring Batch Job 실행을 위한 추상 기본 클래스입니다. *

    @@ -22,7 +21,7 @@ * Spring Batch Job을 실행하기 위한 공통 로직을 포함하고 있어 코드 중복을 줄이고 * 일관된 방식으로 배치 작업을 실행할 수 있게 합니다. *

    - * Quartz는 스케줄링을 전담하고, 실제 비즈니스 로직은 Spring Batch가 담당하여 + * Quartz는 스케줄링을 전담하고, 실제 비즈니스 로직은 Spring Batch가 담당하여 * 관심사를 명확히 분리합니다. */ @Slf4j diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/QuartzConfig.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/config/QuartzConfig.java similarity index 93% rename from bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/QuartzConfig.java rename to bottlenote-batch/src/main/java/app/batch/bottlenote/config/QuartzConfig.java index 4cc7c2c45..71eae3e80 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/QuartzConfig.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/config/QuartzConfig.java @@ -1,6 +1,8 @@ -package app.batch.bottlenote.schedule; - +package app.batch.bottlenote.config; +import app.batch.bottlenote.job.ranking.BestReviewSelectionJobConfig.BestReviewQuartzJob; +import app.batch.bottlenote.job.ranking.PopularAlcoholSelectionJobConfig.PopularAlcoholQuartzJob; +import app.batch.bottlenote.job.report.DailyDataReportJobConfig.DailyDataReportQuartzJob; import lombok.RequiredArgsConstructor; import org.quartz.CronScheduleBuilder; import org.quartz.JobBuilder; diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/config/SecurityConfig.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/config/SecurityConfig.java new file mode 100644 index 000000000..44ea889a3 --- /dev/null +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/config/SecurityConfig.java @@ -0,0 +1,17 @@ +package app.batch.bottlenote.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java deleted file mode 100644 index 68a5d0598..000000000 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/BestReviewPayload.java +++ /dev/null @@ -1,17 +0,0 @@ -package app.batch.bottlenote.data.payload; - -import lombok.Builder; -import org.springframework.jdbc.core.RowMapper; - -import java.sql.ResultSet; -import java.sql.SQLException; - -@Builder -public record BestReviewPayload(Long id, Long alcoholId) { - public static class BestReviewMapper implements RowMapper { - @Override - public BestReviewPayload mapRow(ResultSet rs, int rowNum) throws SQLException { - return new BestReviewPayload(rs.getLong("id"), rs.getLong("alcohol_id")); - } - } -} diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/PopularItemPayload.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/PopularItemPayload.java deleted file mode 100644 index efaf61ccc..000000000 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/data/payload/PopularItemPayload.java +++ /dev/null @@ -1,38 +0,0 @@ -package app.batch.bottlenote.data.payload; - -import lombok.Builder; -import org.springframework.jdbc.core.RowMapper; - -import java.math.BigDecimal; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDate; - -@Builder -public record PopularItemPayload( - Long alcoholId, - Integer year, - Integer month, - Integer day, - BigDecimal reviewScore, - BigDecimal ratingScore, - BigDecimal pickScore, - BigDecimal popularScore -) { - public static class PopularItemMapper implements RowMapper { - @Override - public PopularItemPayload mapRow(ResultSet rs, int rowNum) throws SQLException { - LocalDate currentDate = LocalDate.now(); - return PopularItemPayload.builder() - .alcoholId(rs.getLong("alcohol_id")) - .year(currentDate.getYear()) - .month(currentDate.getMonthValue()) - .day(currentDate.getDayOfMonth()) - .reviewScore(rs.getObject("review_score") != null ? rs.getBigDecimal("review_score") : BigDecimal.ZERO) - .ratingScore(rs.getObject("rating_score") != null ? rs.getBigDecimal("rating_score") : BigDecimal.ZERO) - .pickScore(rs.getObject("pick_score") != null ? rs.getBigDecimal("pick_score") : BigDecimal.ZERO) - .popularScore(rs.getObject("popular_score") != null ? rs.getBigDecimal("popular_score") : BigDecimal.ZERO) - .build(); - } - } -} diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/BestReviewSelectionJobConfig.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/ranking/BestReviewSelectionJobConfig.java similarity index 83% rename from bottlenote-batch/src/main/java/app/batch/bottlenote/job/BestReviewSelectionJobConfig.java rename to bottlenote-batch/src/main/java/app/batch/bottlenote/job/ranking/BestReviewSelectionJobConfig.java index afe61af12..93993c9ec 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/BestReviewSelectionJobConfig.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/ranking/BestReviewSelectionJobConfig.java @@ -1,14 +1,19 @@ -package app.batch.bottlenote.job; +package app.batch.bottlenote.job.ranking; -import app.batch.bottlenote.data.payload.BestReviewPayload; +import app.batch.bottlenote.BatchQuartzJob; import java.io.IOException; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.Collections; import java.util.List; +import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.JobRegistry; import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.item.Chunk; @@ -19,6 +24,8 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.util.FileCopyUtils; @@ -91,15 +98,9 @@ public Step getBestReviewSelectedStep(JobRepository jobRepository, PlatformTrans */ private String getQueryByResource() { try { - // resources 디렉토리 하위의 파일 경로로 접근 Resource resource = new ClassPathResource("storage/mysql/sql/best-review-selected.sql"); - - // getFile() 호출 없이 리소스 내용 읽기 String query = new String(FileCopyUtils.copyToByteArray(resource.getInputStream())); - - // 로그는 파일 경로가 아닌 리소스 설명으로 대체 log.debug("베스트 리뷰 쿼리 로드 완료: {}", resource.getDescription()); - return query; } catch (IOException e) { log.error("[CRITICAL] 베스트 리뷰 SQL 파일을 찾을 수 없습니다: best-review-selected.sql", e); @@ -113,11 +114,9 @@ private String getQueryByResource() { private void updateBestReviews(Chunk chunk) { if (chunk.isEmpty()) return; - // 배치 업데이트를 위한 SQL 준비 String sql = "UPDATE reviews SET is_best = true WHERE id IN (" + String.join(",", Collections.nCopies(chunk.size(), "?")) + ")"; - // 파라미터 배열 준비 Object[] params = chunk.getItems().stream() .map(BestReviewPayload::id) .toArray(); @@ -147,17 +146,28 @@ public BestReviewPayload read() { currentIndex = 0; } - BestReviewPayload nextItem = null; - if (currentIndex < results.size()) { - nextItem = results.get(currentIndex); - currentIndex++; - } - if (currentIndex >= results.size()) { - results = null; + return null; } - return nextItem; + return results.get(currentIndex++); + } + } + + @Component + public static class BestReviewQuartzJob extends BatchQuartzJob { + public BestReviewQuartzJob(JobLauncher jobLauncher, JobRegistry jobRegistry) { + super(jobLauncher, jobRegistry, BEST_REVIEW_JOB_NAME, "bestReviewSelectedJob"); + } + } + + @Builder + public record BestReviewPayload(Long id, Long alcoholId) { + public static class BestReviewMapper implements RowMapper { + @Override + public BestReviewPayload mapRow(ResultSet rs, int rowNum) throws SQLException { + return new BestReviewPayload(rs.getLong("id"), rs.getLong("alcohol_id")); + } } } } diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/PopularAlcoholSelectionJobConfig.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/ranking/PopularAlcoholSelectionJobConfig.java similarity index 71% rename from bottlenote-batch/src/main/java/app/batch/bottlenote/job/PopularAlcoholSelectionJobConfig.java rename to bottlenote-batch/src/main/java/app/batch/bottlenote/job/ranking/PopularAlcoholSelectionJobConfig.java index 04fbfb2f5..e2f72bd53 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/PopularAlcoholSelectionJobConfig.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/ranking/PopularAlcoholSelectionJobConfig.java @@ -1,15 +1,21 @@ -package app.batch.bottlenote.job; +package app.batch.bottlenote.job.ranking; -import app.batch.bottlenote.data.payload.PopularItemPayload; +import app.batch.bottlenote.BatchQuartzJob; import java.io.IOException; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.JobRegistry; import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.item.Chunk; @@ -19,6 +25,8 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.util.FileCopyUtils; @@ -72,13 +80,8 @@ private Step getPopularAlcoholStep(JobRepository jobRepository, PlatformTransact private String getQueryByResource() { try { Resource resource = new ClassPathResource("storage/mysql/sql/popularity.sql"); - - // getFile() 호출 없이 리소스 내용 읽기 String query = new String(FileCopyUtils.copyToByteArray(resource.getInputStream())); - - // 로그는 파일 경로가 아닌 리소스 설명으로 대체 log.info("인기 주류 선정 쿼리 로드 완료: {}", resource.getDescription()); - return query; } catch (IOException e) { log.error("cant find popularity.sql files", e); @@ -92,18 +95,15 @@ private String getQueryByResource() { private void savePopularItems(Chunk chunk) { if (chunk.isEmpty()) return; - // 먼저 오늘 날짜에 해당하는 데이터를 확인하고 있으면 삭제 LocalDate today = LocalDate.now(); String clearSql = "DELETE FROM popular_alcohols " + "WHERE year = ? AND month = ? AND day = ?"; int deleted = jdbcTemplate.update(clearSql, today.getYear(), today.getMonthValue(), today.getDayOfMonth()); log.debug("기존 인기 주류 데이터 삭제: {}", deleted); - // 배치 삽입을 위한 SQL 준비 String sql = "INSERT INTO popular_alcohols (alcohol_id, year, month, day, review_score, rating_score, pick_score, popular_score, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; - // 각 항목에 대한 배치 업데이트 수행 List batchArgs = chunk.getItems().stream() .map(item -> new Object[]{ item.alcoholId(), @@ -143,19 +143,52 @@ public PopularItemReader(JdbcTemplate jdbcTemplate, String query) { @Override public PopularItemPayload read() { if (results == null) { - // 데이터 초기 로드 results = jdbcTemplate.query(query, new PopularItemPayload.PopularItemMapper()); currentIndex = 0; - log.debug("인기 주류 데이터 로드 완료: {} 건", results.size()); } - PopularItemPayload nextItem = null; - if (currentIndex < results.size()) { - nextItem = results.get(currentIndex); - currentIndex++; + if (currentIndex >= results.size()) { + return null; + } + + return results.get(currentIndex++); + } + } + + @Component + public static class PopularAlcoholQuartzJob extends BatchQuartzJob { + public PopularAlcoholQuartzJob(JobLauncher jobLauncher, JobRegistry jobRegistry) { + super(jobLauncher, jobRegistry, POPULAR_JOB_NAME, "popularAlcoholJob"); + } + } + + @Builder + public record PopularItemPayload( + Long alcoholId, + Integer year, + Integer month, + Integer day, + BigDecimal reviewScore, + BigDecimal ratingScore, + BigDecimal pickScore, + BigDecimal popularScore + ) { + public static class PopularItemMapper implements RowMapper { + @Override + public PopularItemPayload mapRow(ResultSet rs, int rowNum) throws SQLException { + LocalDate currentDate = LocalDate.now(); + return PopularItemPayload.builder() + .alcoholId(rs.getLong("alcohol_id")) + .year(currentDate.getYear()) + .month(currentDate.getMonthValue()) + .day(currentDate.getDayOfMonth()) + .reviewScore(rs.getObject("review_score") != null ? rs.getBigDecimal("review_score") : BigDecimal.ZERO) + .ratingScore(rs.getObject("rating_score") != null ? rs.getBigDecimal("rating_score") : BigDecimal.ZERO) + .pickScore(rs.getObject("pick_score") != null ? rs.getBigDecimal("pick_score") : BigDecimal.ZERO) + .popularScore(rs.getObject("popular_score") != null ? rs.getBigDecimal("popular_score") : BigDecimal.ZERO) + .build(); } - return nextItem; } } } diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/DailyDataReportJobConfig.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/report/DailyDataReportJobConfig.java similarity index 63% rename from bottlenote-batch/src/main/java/app/batch/bottlenote/job/DailyDataReportJobConfig.java rename to bottlenote-batch/src/main/java/app/batch/bottlenote/job/report/DailyDataReportJobConfig.java index f520d6267..5df70b4cb 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/job/DailyDataReportJobConfig.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/job/report/DailyDataReportJobConfig.java @@ -1,18 +1,29 @@ -package app.batch.bottlenote.job; +package app.batch.bottlenote.job.report; +import static java.time.LocalDateTime.now; + +import app.batch.bottlenote.BatchQuartzJob; import app.bottlenote.support.report.service.DailyDataReportService; +import app.external.version.config.AppInfoConfig; import app.external.webhook.config.DiscordWebhookProperties; import java.time.LocalDate; +import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.JobRegistry; import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.repeat.RepeatStatus; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; /** @@ -55,9 +66,31 @@ private Step getCollectAndSendStep(JobRepository jobRepository, PlatformTransact dailyDataReportService.collectAndSendDailyReport(targetDate, webhookUrl); log.info("일일 데이터 리포트 배치 작업 완료: {}", targetDate); - return RepeatStatus.FINISHED; }, transactionManager) .build(); } + + @Slf4j + @Component + public static class DailyDataReportQuartzJob extends BatchQuartzJob { + private static final String DEV_ENVIRONMENT = "dev"; + private final AppInfoConfig appInfoConfig; + + public DailyDataReportQuartzJob( + JobLauncher jobLauncher, JobRegistry jobRegistry, AppInfoConfig appInfoConfig) { + super(jobLauncher, jobRegistry, DAILY_DATA_REPORT_JOB_NAME, "dailyDataReportJob"); + this.appInfoConfig = appInfoConfig; + } + + @Override + protected void executeInternal(@NonNull JobExecutionContext context) throws JobExecutionException { + String environment = Objects.requireNonNullElse(appInfoConfig.getEnvironment(), "unknown"); + if (DEV_ENVIRONMENT.equals(environment)) { + log.info("[BATCH SKIP] dailyDataReportJob skipped on dev environment at: {}", now()); + return; + } + super.executeInternal(context); + } + } } diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/config/PopularAlcoholProperties.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/properties/PopularAlcoholProperties.java similarity index 99% rename from bottlenote-batch/src/main/java/app/batch/bottlenote/config/PopularAlcoholProperties.java rename to bottlenote-batch/src/main/java/app/batch/bottlenote/properties/PopularAlcoholProperties.java index 6a9a90dc6..c52ce4108 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/config/PopularAlcoholProperties.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/properties/PopularAlcoholProperties.java @@ -1,4 +1,4 @@ -package app.batch.bottlenote.config; +package app.batch.bottlenote.properties; import java.math.BigDecimal; import java.util.ArrayList; diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/BestReviewQuartzJob.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/BestReviewQuartzJob.java deleted file mode 100644 index a928fe799..000000000 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/BestReviewQuartzJob.java +++ /dev/null @@ -1,35 +0,0 @@ -package app.batch.bottlenote.schedule; - - -import static app.batch.bottlenote.job.BestReviewSelectionJobConfig.BEST_REVIEW_JOB_NAME; - -import org.springframework.batch.core.configuration.JobRegistry; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.stereotype.Component; - -/** - * 베스트 리뷰 선정을 위한 Quartz Job 구현 클래스입니다. - *

    - * 이 클래스는 BatchQuartzJob을 상속받아 베스트 리뷰 선정 배치 작업에 특화된 - * Quartz Job을 구현합니다. Spring의 @Component 어노테이션을 통해 스프링 빈으로 등록되며, - * QuartzConfig에서 이 클래스를 사용하여 JobDetail과 Trigger를 설정합니다. - */ -@Component -public class BestReviewQuartzJob extends BatchQuartzJob { - - /** - * 베스트 리뷰 선정 Quartz Job 생성자입니다. - *

    - * 상위 클래스에 필요한 파라미터를 전달합니다: - * - jobLauncher: Spring Batch Job을 실행하기 위한 런처 - * - jobRegistry: Spring Batch Job을 저장하고 관리하는 레지스트리 - * - 베스트 리뷰 선정 작업 이름(BEST_REVIEW_JOB_NAME) - * - 스케줄러 식별 이름("bestReviewSelectedJob") - * - * @param jobLauncher Spring Batch Job 실행을 위한 런처 - * @param jobRegistry Spring Batch Job 레지스트리 - */ - public BestReviewQuartzJob(JobLauncher jobLauncher, JobRegistry jobRegistry) { - super(jobLauncher, jobRegistry, BEST_REVIEW_JOB_NAME, "bestReviewSelectedJob"); - } -} diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java deleted file mode 100644 index 84c6ba03e..000000000 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJob.java +++ /dev/null @@ -1,54 +0,0 @@ -package app.batch.bottlenote.schedule; - -import static app.batch.bottlenote.job.DailyDataReportJobConfig.DAILY_DATA_REPORT_JOB_NAME; -import static java.time.LocalDateTime.now; - -import app.external.version.config.AppInfoConfig; -import java.util.Objects; -import lombok.extern.slf4j.Slf4j; -import org.quartz.JobExecutionContext; -import org.quartz.JobExecutionException; -import org.springframework.batch.core.configuration.JobRegistry; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; - -/** - * 일일 데이터 리포트를 위한 Quartz Job 구현 클래스입니다. - * - *

    이 클래스는 BatchQuartzJob을 상속받아 일일 데이터 리포트 배치 작업에 특화된 Quartz Job을 구현합니다. Spring의 @Component 어노테이션을 - * 통해 스프링 빈으로 등록되며, QuartzConfig에서 이 클래스를 사용하여 JobDetail과 Trigger를 설정합니다. - * - *

    dev 환경에서는 실행되지 않습니다. - */ -@Slf4j -@Component -public class DailyDataReportQuartzJob extends BatchQuartzJob { - - private static final String DEV_ENVIRONMENT = "dev"; - private final AppInfoConfig appInfoConfig; - - /** - * 일일 데이터 리포트 Quartz Job 생성자입니다. - * - * @param jobLauncher Spring Batch Job 실행을 위한 런처 - * @param jobRegistry Spring Batch Job 레지스트리 - * @param appInfoConfig 앱 정보 설정 - */ - public DailyDataReportQuartzJob( - JobLauncher jobLauncher, JobRegistry jobRegistry, AppInfoConfig appInfoConfig) { - super(jobLauncher, jobRegistry, DAILY_DATA_REPORT_JOB_NAME, "dailyDataReportJob"); - this.appInfoConfig = appInfoConfig; - } - - @Override - protected void executeInternal(@NonNull JobExecutionContext context) - throws JobExecutionException { - String environment = Objects.requireNonNullElse(appInfoConfig.getEnvironment(), "unknown"); - if (DEV_ENVIRONMENT.equals(environment)) { - log.info("[BATCH SKIP] dailyDataReportJob skipped on dev environment at: {}", now()); - return; - } - super.executeInternal(context); - } -} diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/PopularAlcoholQuartzJob.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/PopularAlcoholQuartzJob.java deleted file mode 100644 index 3e5b4ff1f..000000000 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/schedule/PopularAlcoholQuartzJob.java +++ /dev/null @@ -1,35 +0,0 @@ -package app.batch.bottlenote.schedule; - - -import static app.batch.bottlenote.job.PopularAlcoholSelectionJobConfig.POPULAR_JOB_NAME; - -import org.springframework.batch.core.configuration.JobRegistry; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.stereotype.Component; - -/** - * 인기 위스키 선정을 위한 Quartz Job 구현 클래스입니다. - *

    - * 이 클래스는 BatchQuartzJob을 상속받아 인기 위스키 선정 배치 작업에 특화된 - * Quartz Job을 구현합니다. Spring의 @Component 어노테이션을 통해 스프링 빈으로 등록되며, - * QuartzConfig에서 이 클래스를 사용하여 JobDetail과 Trigger를 설정합니다. - */ -@Component -public class PopularAlcoholQuartzJob extends BatchQuartzJob { - - /** - * 인기 위스키 선정 Quartz Job 생성자입니다. - *

    - * 상위 클래스에 필요한 파라미터를 전달합니다: - * - jobLauncher: Spring Batch Job을 실행하기 위한 런처 - * - jobRegistry: Spring Batch Job을 저장하고 관리하는 레지스트리 - * - 인기 위스키 선정 작업 이름(POPULAR_JOB_NAME) - * - 스케줄러 식별 이름("popularReviewSelectedJob") - * - * @param jobLauncher Spring Batch Job 실행을 위한 런처 - * @param jobRegistry Spring Batch Job 레지스트리 - */ - public PopularAlcoholQuartzJob(JobLauncher jobLauncher, JobRegistry jobRegistry) { - super(jobLauncher, jobRegistry, POPULAR_JOB_NAME, "popularReviewSelectedJob"); - } -} diff --git a/bottlenote-batch/src/main/resources/application-batch.yml b/bottlenote-batch/src/main/resources/application-batch.yml deleted file mode 100644 index 58528498a..000000000 --- a/bottlenote-batch/src/main/resources/application-batch.yml +++ /dev/null @@ -1,26 +0,0 @@ -# batch 설정 yaml 파일 -spring: - batch: - job: - enabled: false # 배치 실행 여부 - jdbc.initialize-schema: never # 배치 실행 시 DB 초기화 여부 - - quartz: - overwrite-existing-jobs: true # 시작 - job-store-type: jdbc # 메모리(memory) 또는 데이터베이스(jdbc) - jdbc: - initialize-schema: never # 스키마 초기화 (always, never, embedded) - properties: - org: - quartz: - scheduler: - instanceName: bottle_note_quartz_scheduler - instanceId: AUTO - threadPool: - threadCount: 5 - threadPriority: 5 - jobStore: - #class: org.quartz.impl.jdbcjobstore.JobStoreTX - driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate - isClustered: true - clusterCheckinInterval: 20000 diff --git a/bottlenote-batch/src/main/resources/application-datasource.yml b/bottlenote-batch/src/main/resources/application-datasource.yml new file mode 100644 index 000000000..e5309b037 --- /dev/null +++ b/bottlenote-batch/src/main/resources/application-datasource.yml @@ -0,0 +1,69 @@ +# application-datasource.yml (batch) + +spring: + datasource: + driver-class-name: com.p6spy.engine.spy.P6SpyDriver + url: jdbc:p6spy:mysql://${DB_HOST:localhost:3306}/${DB_NAME:bottle_note}?useSSL=${DB_USE_SSL:false}&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: ${DB_USERNAME:localhost_bottle_note_username} + password: ${DB_PASSWORD:localhost_bottle_note_password} + hikari: + maximum-pool-size: 3 + minimum-idle: 1 + idle-timeout: 600000 + max-lifetime: 1800000 + connection-timeout: 30000 + pool-name: HikariPool-Batch + leak-detection-threshold: 60000 + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: false + use_sql_comments: false + open-in-view: false + + data: + redis: + mode: ${REDIS_MODE:standalone} + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:16379} + password: ${REDIS_PASSWORD:} + timeout: 15s + cluster: + nodes: ${REDIS_CLUSTER_NODES:} + +--- # default/local 환경 +spring: + config: + activate: + on-profile: default,local + datasource: + driver-class-name: com.p6spy.engine.spy.P6SpyDriver + url: jdbc:p6spy:mysql://${DB_HOST:localhost:3306}/${DB_NAME:bottle_note}?useSSL=${DB_USE_SSL:false}&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: ${DB_USERNAME:localhost_bottle_note_username} + password: ${DB_PASSWORD:localhost_bottle_note_password} + +--- # debug 환경 +spring: + config: + activate: + on-profile: debug + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST:localhost:3306}/${DB_NAME:bottle_note}?useSSL=${DB_USE_SSL:false}&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: ${DB_USERNAME:localhost_bottle_note_username} + password: ${DB_PASSWORD:localhost_bottle_note_password} + +--- # 개발/운영 환경 +spring: + config: + activate: + on-profile: "dev,prod" + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST}/${DB_NAME}?useSSL=${DB_USE_SSL}&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true + username: ${DB_USERNAME} + password: ${DB_PASSWORD} diff --git a/bottlenote-batch/src/main/resources/application-external.yml b/bottlenote-batch/src/main/resources/application-external.yml new file mode 100644 index 000000000..396dfe3ba --- /dev/null +++ b/bottlenote-batch/src/main/resources/application-external.yml @@ -0,0 +1,20 @@ +# Profanity Filter +profanity: + filter: + url: https://api.profanity.kr-filter.com/api/v1 + key: ${PROFANITY_FILTER_KEY} + +# Firebase +app: + thirdParty: + firebase-configuration-file: /application/springboot/service_account_credentials.json + base64file: ${FIREBASE_BASE64_FILE} + +# AWS S3 +amazon: + aws: + accessKey: ${AWS_ACCESS_KEY:AMAZON_AWS_ACCESS_KEY} + secretKey: ${AWS_SECRET_KEY:AMAZON_AWS_SECRET_KEY} + region: ${AWS_REGION:ap-northeast-2} + bucket: ${AWS_BUCKET:AMAZON_AWS_BUCKET} + cloudFrontUrl: ${AWS_CLOUDFRONT_URL:AMAZON_AWS_CLOUDFRONT_URL} diff --git a/bottlenote-batch/src/main/resources/application.yml b/bottlenote-batch/src/main/resources/application.yml new file mode 100644 index 000000000..010a8f48a --- /dev/null +++ b/bottlenote-batch/src/main/resources/application.yml @@ -0,0 +1,77 @@ +# Batch 서버 설정 + +app: + type: batch + info: + server-name: ${SERVER_NAME:unknown} + environment: ${SPRING_PROFILES_ACTIVE:local} + git-branch: ${GIT_BRANCH:unknown} + git-commit: ${GIT_COMMIT:unknown} + git-build-time: ${BUILD_TIME:unknown} + +server: + port: ${SERVER_PORT:8080} + +spring: + application: + name: bottlenote-batch + profiles: + include: + - datasource + - external + jackson: + time-zone: Asia/Seoul + + # Batch 설정 + batch: + job: + enabled: false + jdbc: + initialize-schema: never + + # Quartz 설정 + quartz: + overwrite-existing-jobs: true + job-store-type: jdbc + jdbc: + initialize-schema: never + properties: + org: + quartz: + scheduler: + instanceName: bottle_note_quartz_scheduler + instanceId: AUTO + threadPool: + threadCount: 5 + threadPriority: 5 + jobStore: + driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate + isClustered: true + clusterCheckinInterval: 20000 + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: always + otlp: + metrics: + export: + enabled: false + tracing: + endpoint: "" + tracing: + enabled: false + +logging: + level: + root: info + +security: + jwt: + secret-key: c2VjdXJlU2VjcmV0S2V5MTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QWJDRGVGR2hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlrSg== + nonce: + salt: bottle-note-secure-salt-2024 diff --git a/bottlenote-batch/src/test/java/app/batch/bottlenote/config/BatchConfigTest.java b/bottlenote-batch/src/test/java/app/batch/bottlenote/config/BatchConfigTest.java deleted file mode 100644 index 924835167..000000000 --- a/bottlenote-batch/src/test/java/app/batch/bottlenote/config/BatchConfigTest.java +++ /dev/null @@ -1,8 +0,0 @@ -package app.batch.bottlenote.config; - -import org.springframework.boot.test.context.TestConfiguration; - -@TestConfiguration -class BatchConfigTest { - -} diff --git a/bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java b/bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java deleted file mode 100644 index 6a94cc08e..000000000 --- a/bottlenote-batch/src/test/java/app/batch/bottlenote/schedule/DailyDataReportQuartzJobTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package app.batch.bottlenote.schedule; - -import static app.batch.bottlenote.job.DailyDataReportJobConfig.DAILY_DATA_REPORT_JOB_NAME; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import app.external.version.config.AppInfoConfig; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.quartz.JobExecutionContext; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.configuration.JobRegistry; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.batch.core.launch.NoSuchJobException; - -@Tag("unit") -@DisplayName("[unit] [schedule] DailyDataReportQuartzJob") -@ExtendWith(MockitoExtension.class) -class DailyDataReportQuartzJobTest { - - @Mock private JobLauncher jobLauncher; - - @Mock private JobRegistry jobRegistry; - - @Mock private JobExecutionContext context; - - @Mock private Job job; - - private DailyDataReportQuartzJob dailyDataReportQuartzJob; - - private AppInfoConfig appInfoConfig; - - @BeforeEach - void setUp() { - appInfoConfig = new AppInfoConfig(); - appInfoConfig.setEnvironment("prod"); - dailyDataReportQuartzJob = - new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); - } - - @Test - @DisplayName("DailyDataReportQuartzJob이 정상적으로 생성된다") - void testDailyDataReportQuartzJob_생성검증() { - // given & when & then - assertNotNull(dailyDataReportQuartzJob); - } - - @Test - @DisplayName("Job 실행 시 올바른 Job 이름으로 실행된다") - void testExecuteInternal_Job이름검증() throws Exception { - // given - when(jobRegistry.getJob(eq(DAILY_DATA_REPORT_JOB_NAME))).thenReturn(job); - when(jobLauncher.run(any(Job.class), any(JobParameters.class))).thenReturn(null); - - // when - dailyDataReportQuartzJob.executeInternal(context); - - // then - verify(jobRegistry, times(1)).getJob(DAILY_DATA_REPORT_JOB_NAME); - verify(jobLauncher, times(1)).run(any(Job.class), any(JobParameters.class)); - } - - @Test - @DisplayName("Job을 찾지 못하면 NoSuchJobException이 발생한다") - void testExecuteInternal_Job찾기실패() throws Exception { - // given - when(jobRegistry.getJob(eq(DAILY_DATA_REPORT_JOB_NAME))) - .thenThrow(new NoSuchJobException("Job not found")); - - // when & then - try { - dailyDataReportQuartzJob.executeInternal(context); - } catch (Exception e) { - assertEquals(NoSuchJobException.class, e.getCause().getClass()); - } - } - - @Test - @DisplayName("스케줄러 식별 이름이 올바르게 설정되어 있다") - void testSchedulerName_검증() { - // given & when - String expectedSchedulerName = "dailyDataReportJob"; - - // then - assertEquals(DAILY_DATA_REPORT_JOB_NAME, expectedSchedulerName); - } - - @Test - @DisplayName("dev 환경에서는 Job이 실행되지 않는다") - void testExecuteInternal_dev환경스킵() throws Exception { - // given - appInfoConfig.setEnvironment("dev"); - dailyDataReportQuartzJob = - new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); - - // when - dailyDataReportQuartzJob.executeInternal(context); - - // then - verify(jobRegistry, times(0)).getJob(any()); - verify(jobLauncher, times(0)).run(any(Job.class), any(JobParameters.class)); - } - - @Test - @DisplayName("prod 환경에서는 Job이 정상 실행된다") - void testExecuteInternal_prod환경실행() throws Exception { - // given - appInfoConfig.setEnvironment("prod"); - dailyDataReportQuartzJob = - new DailyDataReportQuartzJob(jobLauncher, jobRegistry, appInfoConfig); - when(jobRegistry.getJob(eq(DAILY_DATA_REPORT_JOB_NAME))).thenReturn(job); - when(jobLauncher.run(any(Job.class), any(JobParameters.class))).thenReturn(null); - - // when - dailyDataReportQuartzJob.executeInternal(context); - - // then - verify(jobRegistry, times(1)).getJob(DAILY_DATA_REPORT_JOB_NAME); - verify(jobLauncher, times(1)).run(any(Job.class), any(JobParameters.class)); - } -} diff --git a/bottlenote-batch/src/test/resources/application.yml b/bottlenote-batch/src/test/resources/application.yml new file mode 100644 index 000000000..27559bd87 --- /dev/null +++ b/bottlenote-batch/src/test/resources/application.yml @@ -0,0 +1,86 @@ +# 배치 테스트 설정 + +app: + type: batch + info: + server-name: batch-test + environment: test + git-branch: test + git-commit: test + git-build-time: test + thirdParty: + firebase-configuration-file: src/test/resources/service_account_credentials.json + +spring: + main: + allow-bean-definition-overriding: true + application: + name: bottlenote-batch-test + jackson: + time-zone: Asia/Seoul + + # JPA + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: false + show-sql: false + + # Redis + data: + redis: + repositories: + enabled: false + + # Batch + batch: + job: + enabled: false + jdbc: + initialize-schema: always + + # Quartz 비활성화 + quartz: + auto-startup: false + job-store-type: memory + +# Discord 웹훅 (테스트용) +discord: + webhook: + url: https://discord.test.webhook.url + +# AWS (테스트용) +amazon: + aws: + accessKey: fake-access-key + secretKey: fake-secret-key + region: ap-northeast-2 + bucket: fake-bucket + cloudFrontUrl: https://fake-cloudfront.net + +# Profanity Filter (테스트용) +profanity: + filter: + url: https://fake-profanity-filter.test + +# Security (테스트용) +security: + jwt: + secret-key: c2VjdXJlU2VjcmV0S2V5MTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QWJDRGVGR2hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlrSg== + nonce: + salt: test-salt-value + +# Apple (테스트용) +apple: + bundle-id: com.bottlenote.test + +# Observability 비활성화 +management: + tracing: + enabled: false + otlp: + metrics: + export: + enabled: false diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AppleTokenValidator.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AppleTokenValidator.java index 6ced5dc81..0e5d49ae5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AppleTokenValidator.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/AppleTokenValidator.java @@ -28,7 +28,7 @@ public class AppleTokenValidator implements TokenValidator { private static final String APPLE_ISSUER = "https://appleid.apple.com"; // application.yml 에서 실제 값 주입 - @Value("${apple.bundle-id}") + @Value("${apple.bundle-id:bundle-id}") private String appleAudience; // Apple 공개키는 자주 바뀌지 않으므로 캐싱하여 사용 diff --git a/build.gradle b/build.gradle index 63f0509f0..276153de1 100644 --- a/build.gradle +++ b/build.gradle @@ -189,3 +189,9 @@ tasks.register('restDocsTest') { group = 'documentation' dependsOn subprojects.findAll { it.tasks.findByName('restDocsTest') }.collect { it.tasks.restDocsTest } } + +tasks.register('batch_test') { + description = '배치 모듈 테스트 실행 (@Tag("batch"))' + group = 'verification' + dependsOn subprojects.findAll { it.tasks.findByName('batch_test') }.collect { it.tasks.batch_test } +} diff --git a/git.environment-variables b/git.environment-variables index d5729fa8f..416ba9202 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit d5729fa8f9074654a24f120704ede2ef13c657bc +Subproject commit 416ba92027f741481ff86cbece1c3b6b397e13a2 diff --git a/tmpclaude-d340-cwd b/tmpclaude-d340-cwd deleted file mode 100644 index a8c84d34f..000000000 --- a/tmpclaude-d340-cwd +++ /dev/null @@ -1 +0,0 @@ -/c/Users/rlagu/.claude-worktrees/bottle-note-api-server/exciting-germain From c9e099350ec90b9f195ae516ae7ac9570db07779 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 17 Jan 2026 04:34:23 +0900 Subject: [PATCH 62/95] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20spy.log=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f584c9b2b..60934fbee 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ cosign.key cosign.pub *.env.* + +# Log files +spy.log From eeb100076d269a6fc4d1a275664ec0bff94a233e Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 19 Jan 2026 01:33:59 +0900 Subject: [PATCH 63/95] =?UTF-8?q?feat:=20Admin=20API=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20PreSigned=20URL=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadService에 어드민용 getPreSignUrlForAdmin() 메서드 추가 - AdminImageUploadController 생성 (GET /s3/presign-url) - 통합 테스트 추가 (AdminImageUploadIntegrationTest) - CLAUDE.md에 Admin API 구현 규칙 및 빌드 명령어 추가 - build.gradle.kts에 AWS S3 테스트 의존성 추가 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 114 ++++++++++++++++- bottlenote-admin-api/build.gradle.kts | 3 + .../AdminImageUploadController.kt | 27 ++++ .../file/AdminImageUploadIntegrationTest.kt | 120 ++++++++++++++++++ .../file/service/ImageUploadService.java | 60 ++++++--- 5 files changed, 303 insertions(+), 21 deletions(-) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 7e47186c2..198bbb88d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,9 +70,43 @@ flowchart TD ### bottlenote-admin-api - **역할**: 관리자용 API 서버 모듈 (Kotlin) - **특징**: - - bottlenote-mono 모듈 의존 - - 관리자 전용 REST API + - bottlenote-mono 모듈 의존 (비즈니스 로직은 mono에서 제공) + - 관리자 전용 REST API (프레젠테이션 계층만 담당) - context-path: `/admin/api/v1` + - Spring REST Docs 기반 API 문서화 + - JWT 기반 어드민 전용 인증 체계 + - 루트 어드민 자동 초기화 (`ApplicationReadyEvent`) + +**패키지 구조**: +``` +app/ +├── Application.kt # 진입점 +├── bottlenote/{domain}/ +│ ├── presentation/ # 컨트롤러 +│ └── config/ # 설정 클래스 +└── global/ + ├── common/ # 공통 유틸 + └── security/ # 보안 설정 +``` + +**Kotlin 코딩 컨벤션**: +- `data class`: 요청/응답 DTO, 설정 클래스 +- `object`: 테스트 헬퍼 (싱글톤) +- `val` 불변성 선호, `lateinit var`는 DI 주입용으로만 사용 +- 생성자 주입: 클래스 선언부에 파라미터로 명시 +- Named parameters 활용으로 가독성 향상 + +**테스트 구조**: +- `@Tag("admin_integration")`: 통합 테스트 태그 +- `IntegrationTestSupport`: 테스트 베이스 클래스 (TestContainers, MockMvcTester) +- `app/docs/`: RestDocs 테스트 (`@WebMvcTest`) +- `app/integration/`: 통합 테스트 +- `app/helper/`: 테스트 헬퍼 (`object` 싱글톤) + +**인증 체계**: +- `AdminJwtAuthenticationFilter`, `AdminJwtAuthenticationManager` 사용 +- `SecurityContextUtil.getAdminUserIdByContext()`: 현재 어드민 ID 조회 +- RBAC 역할: `ROOT_ADMIN`, `PARTNER`, `COMMUNITY_MANAGER` ### bottlenote-batch - **역할**: 배치 처리 모듈 @@ -94,6 +128,12 @@ git submodule update --init --recursive ./gradlew check_rule_test # 아키텍처 규칙 테스트 (@Tag("rule")) ./gradlew asciidoctor # API 문서 생성 ./gradlew bootRun # 애플리케이션 실행 + +# admin-api 모듈 전용 +./gradlew :bottlenote-admin-api:build # admin-api 빌드 +./gradlew :bottlenote-admin-api:test # admin-api 테스트 실행 +./gradlew :bottlenote-admin-api:asciidoctor # admin-api 문서 생성 +./gradlew :bottlenote-admin-api:bootRun # admin-api 실행 ``` ### 서브모듈 @@ -102,6 +142,76 @@ git submodule update --init --recursive - `storage/mysql/init/*.sql`: TestContainers용 DB 초기화 스크립트 - 통합 테스트 실행 전 서브모듈 초기화 필수 +## Admin API 구현 규칙 + +### 컨트롤러 작성 규칙 + +1. **패키지 위치**: `app.bottlenote.{domain}.presentation` +2. **클래스명**: `Admin{도메인명}Controller` +3. **매핑**: `@RequestMapping("/{복수형 리소스}")` (예: `/helps`, `/alcohols`) +4. **응답 타입**: `ResponseEntity<*>` 또는 `ResponseEntity` +5. **응답 래핑**: `GlobalResponse.ok(response)` 사용 + +### API 엔드포인트 설계 + +| HTTP Method | 용도 | URL 패턴 | 예시 | +|-------------|------|----------|------| +| GET | 목록 조회 | `/{resources}` | `GET /helps` | +| GET | 단건 조회 | `/{resources}/{id}` | `GET /helps/1` | +| POST | 생성/액션 | `/{resources}` 또는 `/{resources}/{id}/{action}` | `POST /helps/1/answer` | +| PUT | 전체 수정 | `/{resources}/{id}` | `PUT /helps/1` | +| PATCH | 부분 수정 | `/{resources}/{id}` | `PATCH /helps/1` | +| DELETE | 삭제 | `/{resources}/{id}` | `DELETE /helps/1` | + +### 요청/응답 처리 + +1. **목록 조회 요청**: `@ModelAttribute` + Request DTO +2. **단건 조회**: `@PathVariable` +3. **생성/수정 요청**: `@RequestBody @Valid` + Request DTO +4. **페이징 응답**: `PageResponse.of(content, cursorPageable)` + +### 인증이 필요한 API + +```kotlin +val adminId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } +``` + +### 서비스 의존 + +- 컨트롤러는 mono 모듈의 서비스를 직접 주입받아 사용 +- 비즈니스 로직은 mono 모듈에 구현 +- admin-api는 프레젠테이션 계층만 담당 + +### 테스트 작성 규칙 + +**통합 테스트** (`app/integration/{domain}/`): +- `IntegrationTestSupport` 상속 +- `@Tag("admin_integration")` 태그 +- `@Nested` 클래스로 API별 그룹화 +- `mockMvcTester`로 API 호출 및 검증 + +**RestDocs 테스트** (`app/docs/{domain}/`): +- `@WebMvcTest(controllers = [...], excludeAutoConfiguration = [SecurityAutoConfiguration::class])` +- `@MockitoBean`으로 서비스 목킹 +- `document()` 메서드로 API 문서 스니펫 생성 + +### 헬퍼 클래스 + +- **위치**: `app/helper/{domain}/` +- **형태**: `object` 싱글톤 +- **용도**: 테스트 데이터 생성 +- **네이밍**: `{도메인명}Helper` + +```kotlin +object AlcoholsHelper { + fun createAdminAlcoholItem( + id: Long = 1L, + korName: String = "테스트 위스키" + ): AdminAlcoholItem = ... +} +``` + ## 코드 작성 규칙 ### 아키텍처 패턴 diff --git a/bottlenote-admin-api/build.gradle.kts b/bottlenote-admin-api/build.gradle.kts index 188aa6b2c..ff85cee23 100644 --- a/bottlenote-admin-api/build.gradle.kts +++ b/bottlenote-admin-api/build.gradle.kts @@ -33,6 +33,9 @@ dependencies { // Test - Testcontainers testImplementation(libs.bundles.testcontainers.complete) + + // Test - AWS S3 (for MinIO integration test) + testImplementation(libs.aws.s3) } sourceSets { diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt new file mode 100644 index 000000000..8400d8e19 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt @@ -0,0 +1,27 @@ +package app.bottlenote.common.file.presentation + +import app.bottlenote.common.file.dto.request.ImageUploadRequest +import app.bottlenote.common.file.service.ImageUploadService +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.security.SecurityContextUtil +import app.bottlenote.user.exception.UserException +import app.bottlenote.user.exception.UserExceptionCode +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/s3") +class AdminImageUploadController( + private val imageUploadService: ImageUploadService +) { + + @GetMapping("/presign-url") + fun getPreSignUrl(@ModelAttribute request: ImageUploadRequest): ResponseEntity<*> { + val adminId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + return GlobalResponse.ok(imageUploadService.getPreSignUrlForAdmin(adminId, request)) + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt new file mode 100644 index 000000000..6c3d301b6 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt @@ -0,0 +1,120 @@ +package app.integration.file + +import app.IntegrationTestSupport +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 + +@Tag("admin_integration") +@DisplayName("[integration] Admin Image Upload API 통합 테스트") +class AdminImageUploadIntegrationTest : IntegrationTestSupport() { + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("PreSigned URL 생성 API") + inner class GetPreSignUrlTest { + + @Test + @DisplayName("PreSigned URL을 생성할 수 있다") + fun getPreSignUrl() { + // when & then + assertThat( + mockMvcTester.get().uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/test") + .param("uploadSize", "1") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("여러 개의 PreSigned URL을 생성할 수 있다") + fun getMultiplePreSignUrls() { + // when & then + assertThat( + mockMvcTester.get().uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/test") + .param("uploadSize", "3") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.uploadSize").isEqualTo(3) + } + + @Test + @DisplayName("응답에 필요한 정보가 포함되어 있다") + fun responseContainsRequiredFields() { + // when + val result = mockMvcTester.get().uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/test") + .param("uploadSize", "2") + .exchange() + + // then + assertThat(result) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.bucketName").isNotNull() + + assertThat(result) + .bodyJson() + .extractingPath("$.data.expiryTime").isEqualTo(5) + + assertThat(result) + .bodyJson() + .extractingPath("$.data.imageUploadInfo").isNotNull() + } + + @Test + @DisplayName("생성된 URL 정보가 올바른 형식이다") + fun urlFormatIsCorrect() { + // when + val result = mockMvcTester.get().uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/test") + .param("uploadSize", "1") + .exchange() + + val response = parseResponse(result) + @Suppress("UNCHECKED_CAST") + val data = response.data as Map + @Suppress("UNCHECKED_CAST") + val imageUploadInfo = data["imageUploadInfo"] as List> + + // then + val firstItem = imageUploadInfo[0] + assertThat(firstItem["order"]).isEqualTo(1) + assertThat(firstItem["viewUrl"] as String).contains("admin/test") + assertThat(firstItem["uploadUrl"] as String).isNotEmpty() + + log.info("viewUrl = {}", firstItem["viewUrl"]) + log.info("uploadUrl = {}", firstItem["uploadUrl"]) + } + + @Test + @DisplayName("인증 없이 요청하면 실패한다") + fun getPreSignUrlWithoutAuth() { + // when & then + assertThat( + mockMvcTester.get().uri("/s3/presign-url") + .param("rootPath", "admin/test") + .param("uploadSize", "1") + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java index 1373688e6..58aa8a412 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java @@ -43,6 +43,25 @@ public ImageUploadService( * @return the 생성된 이미지 업로드 정보 */ public ImageUploadResponse getPreSignUrl(ImageUploadRequest request) { + List keys = generatePreSignUrls(request); + saveImageUploadLogs(request.rootPath(), keys); + return buildResponse(keys); + } + + /** + * 어드민용 업로드 인증 URL을 생성한다. + * + * @param adminId 어드민 사용자 ID + * @param request 업로드 루트 경로 + 업로드할 사이즈 + * @return the 생성된 이미지 업로드 정보 + */ + public ImageUploadResponse getPreSignUrlForAdmin(Long adminId, ImageUploadRequest request) { + List keys = generatePreSignUrls(request); + saveResourceLogs(request.rootPath(), keys, adminId); + return buildResponse(keys); + } + + private List generatePreSignUrls(ImageUploadRequest request) { String rootPath = request.rootPath(); Long uploadSize = request.uploadSize(); List keys = new ArrayList<>(); @@ -54,12 +73,13 @@ public ImageUploadResponse getPreSignUrl(ImageUploadRequest request) { keys.add( ImageUploadItem.builder().order(index).viewUrl(viewUrl).uploadUrl(preSignUrl).build()); } - saveImageUploadLogs(rootPath, keys); + return keys; + } + private ImageUploadResponse buildResponse(List keys) { log.info( - "S3 PreSignedURL 생성 완료 - rootPath: {}, uploadSize: {}, bucket: {}, expiryTime: {}분", - rootPath, - uploadSize, + "S3 PreSignedURL 생성 완료 - uploadSize: {}, bucket: {}, expiryTime: {}분", + keys.size(), imageBucketName, EXPIRY_TIME); @@ -86,21 +106,23 @@ public String generatePreSignUrl(String imageKey) { private void saveImageUploadLogs(String rootPath, List items) { SecurityContextUtil.getUserIdByContext() - .ifPresent( - userId -> - items.forEach( - item -> { - String imageKey = extractImageKey(item.viewUrl()); - ResourceLogRequest logRequest = - ResourceLogRequest.builder() - .userId(userId) - .resourceKey(imageKey) - .viewUrl(item.viewUrl()) - .rootPath(rootPath) - .bucketName(imageBucketName) - .build(); - resourceCommandService.saveImageResourceCreated(logRequest); - })); + .ifPresent(userId -> saveResourceLogs(rootPath, items, userId)); + } + + private void saveResourceLogs(String rootPath, List items, Long userId) { + items.forEach( + item -> { + String imageKey = extractImageKey(item.viewUrl()); + ResourceLogRequest logRequest = + ResourceLogRequest.builder() + .userId(userId) + .resourceKey(imageKey) + .viewUrl(item.viewUrl()) + .rootPath(rootPath) + .bucketName(imageBucketName) + .build(); + resourceCommandService.saveImageResourceCreated(logRequest); + }); } private String extractImageKey(String viewUrl) { From 2977045ae65223c33f81ee0402b5d88a24e2aea5 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 19 Jan 2026 01:46:08 +0900 Subject: [PATCH 64/95] =?UTF-8?q?test:=20Admin=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20CLAUDE.md=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=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 - 실제 PreSigned URL로 MinIO 업로드 및 검증 테스트 추가 - 여러 파일 업로드 시나리오 테스트 추가 - application-test.yml bucket 설정을 test-bucket으로 수정 - CLAUDE.md에 Admin API 구현 체크리스트 섹션 추가 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 36 ++++++ .../file/AdminImageUploadIntegrationTest.kt | 116 ++++++++++++++++++ .../src/test/resources/application-test.yml | 2 +- 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 198bbb88d..5c0cb33a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,42 @@ git submodule update --init --recursive ## Admin API 구현 규칙 +### Admin API 구현 체크리스트 + +새로운 Admin API를 구현할 때 다음 체크리스트를 따르세요: + +#### 1. 요구사항 분석 및 설계 +- [ ] product-api에 동일/유사 기능이 있는지 확인 +- [ ] mono 모듈의 기존 서비스/도메인 로직 재사용 가능 여부 확인 +- [ ] 신규 서비스 메서드가 필요한 경우 mono 모듈에 추가 계획 +- [ ] API 엔드포인트 설계 (HTTP Method, URL 패턴) + +#### 2. mono 모듈 수정 (필요 시) +- [ ] 서비스 클래스에 admin 전용 메서드 추가 (예: `xxxForAdmin`) +- [ ] 인증 방식 분리: admin은 `adminId`를 파라미터로 받도록 설계 +- [ ] 공통 로직 추출 및 리팩토링 (`private` 메서드 분리) +- [ ] 기존 테스트 영향 확인 및 수정 + +#### 3. admin-api 컨트롤러 구현 +- [ ] 패키지: `app.bottlenote.{domain}.presentation` +- [ ] 클래스명: `Admin{도메인명}Controller` +- [ ] `@RestController`, `@RequestMapping("/{리소스}")` 설정 +- [ ] 인증이 필요한 API: `SecurityContextUtil.getAdminUserIdByContext()` 호출 +- [ ] 응답: `GlobalResponse.ok(response)` 래핑 + +#### 4. 테스트 작성 +- [ ] 통합 테스트: `app/integration/{domain}/Admin{도메인명}IntegrationTest.kt` + - `IntegrationTestSupport` 상속 + - `@Tag("admin_integration")` 태그 + - 인증 성공/실패 케이스 + - 주요 비즈니스 시나리오 +- [ ] RestDocs 테스트 (API 문서화 필요 시): `app/docs/{domain}/Admin{도메인명}ControllerDocsTest.kt` + +#### 5. 검증 및 완료 +- [ ] 컴파일 확인: `./gradlew :bottlenote-admin-api:compileKotlin` +- [ ] 테스트 실행: `./gradlew :bottlenote-admin-api:admin_integration_test` +- [ ] API 문서 생성: `./gradlew :bottlenote-admin-api:asciidoctor` (RestDocs 테스트 작성 시) + ### 컨트롤러 작성 규칙 1. **패키지 위치**: `app.bottlenote.{domain}.presentation` diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt index 6c3d301b6..79305ce73 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt @@ -1,17 +1,26 @@ package app.integration.file import app.IntegrationTestSupport +import app.bottlenote.operation.utils.TestContainersConfig +import com.amazonaws.services.s3.AmazonS3 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 java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URI @Tag("admin_integration") @DisplayName("[integration] Admin Image Upload API 통합 테스트") class AdminImageUploadIntegrationTest : IntegrationTestSupport() { + @Autowired + private lateinit var amazonS3: AmazonS3 + private lateinit var accessToken: String @BeforeEach @@ -117,4 +126,111 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { .hasStatus4xxClientError() } } + + @Nested + @DisplayName("PreSigned URL 업로드 시나리오") + inner class UploadScenarioTest { + + @Test + @DisplayName("PreSigned URL로 파일을 업로드하고 S3에서 확인할 수 있다") + fun uploadAndVerifyFile() { + // given: PreSigned URL 발급 + val result = mockMvcTester.get().uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/upload-test") + .param("uploadSize", "1") + .exchange() + + val response = parseResponse(result) + @Suppress("UNCHECKED_CAST") + val data = response.data as Map + @Suppress("UNCHECKED_CAST") + val imageUploadInfo = data["imageUploadInfo"] as List> + val uploadUrl = imageUploadInfo[0]["uploadUrl"] as String + val viewUrl = imageUploadInfo[0]["viewUrl"] as String + + // viewUrl에서 S3 key 추출 (cloudFrontUrl 이후 부분) + val s3Key = viewUrl.substringAfter("fake-cloudfront.net/") + + log.info("uploadUrl = {}", uploadUrl) + log.info("s3Key = {}", s3Key) + + // when: PreSigned URL로 파일 업로드 + val testContent = "test image content" + val responseCode = uploadToPreSignedUrl(uploadUrl, testContent) + + // then: 업로드 성공 확인 + assertThat(responseCode).isEqualTo(200) + + // then: S3에서 파일 존재 확인 + val bucketName = TestContainersConfig.getTestBucket() + val exists = amazonS3.doesObjectExist(bucketName, s3Key) + assertThat(exists).isEqualTo(true) + + // then: 업로드된 내용 확인 + val s3Object = amazonS3.getObject(bucketName, s3Key) + val content = s3Object.objectContent.bufferedReader().use { it.readText() } + assertThat(content).isEqualTo(testContent) + + log.info("업로드된 파일 내용 확인 완료: {}", content) + } + + @Test + @DisplayName("여러 파일을 업로드하고 모두 S3에서 확인할 수 있다") + fun uploadMultipleFilesAndVerify() { + // given: PreSigned URL 3개 발급 + val result = mockMvcTester.get().uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/multi-upload") + .param("uploadSize", "3") + .exchange() + + val response = parseResponse(result) + @Suppress("UNCHECKED_CAST") + val data = response.data as Map + @Suppress("UNCHECKED_CAST") + val imageUploadInfo = data["imageUploadInfo"] as List> + + // when: 3개 파일 모두 업로드 + val uploadResults = imageUploadInfo.mapIndexed { index, info -> + val uploadUrl = info["uploadUrl"] as String + val viewUrl = info["viewUrl"] as String + val s3Key = viewUrl.substringAfter("fake-cloudfront.net/") + val content = "content-$index" + val responseCode = uploadToPreSignedUrl(uploadUrl, content) + Triple(s3Key, content, responseCode) + } + + // then: 모든 업로드 성공 확인 + val bucketName = TestContainersConfig.getTestBucket() + uploadResults.forEach { (s3Key, expectedContent, responseCode) -> + assertThat(responseCode).isEqualTo(200) + assertThat(amazonS3.doesObjectExist(bucketName, s3Key)).isEqualTo(true) + + val actualContent = amazonS3.getObject(bucketName, s3Key) + .objectContent.bufferedReader().use { it.readText() } + assertThat(actualContent).isEqualTo(expectedContent) + } + + log.info("3개 파일 업로드 및 검증 완료") + } + + private fun uploadToPreSignedUrl(preSignedUrl: String, content: String): Int { + val url = URI(preSignedUrl).toURL() + val connection = url.openConnection() as HttpURLConnection + return try { + connection.doOutput = true + connection.requestMethod = "PUT" + connection.setRequestProperty("Content-Type", "application/octet-stream") + + OutputStreamWriter(connection.outputStream).use { writer -> + writer.write(content) + } + + connection.responseCode + } finally { + connection.disconnect() + } + } + } } diff --git a/bottlenote-admin-api/src/test/resources/application-test.yml b/bottlenote-admin-api/src/test/resources/application-test.yml index 92ffbc168..b977be766 100644 --- a/bottlenote-admin-api/src/test/resources/application-test.yml +++ b/bottlenote-admin-api/src/test/resources/application-test.yml @@ -54,7 +54,7 @@ amazon: accessKey: fake-access-key secretKey: fake-secret-key region: ap-northeast-2 - bucket: fake-bucket + bucket: test-bucket cloudFrontUrl: https://fake-cloudfront.net # Profanity Filter From 88c179b31e5333ded298f3db683876cd0547d970 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 19 Jan 2026 01:53:59 +0900 Subject: [PATCH 65/95] =?UTF-8?q?docs:=20Admin=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminImageUploadControllerDocsTest.kt RestDocs 테스트 추가 - api/admin-file/file.adoc Asciidoc 문서 추가 - admin-api.adoc에 File API 섹션 include 추가 Co-Authored-By: Claude Opus 4.5 --- .../src/docs/asciidoc/admin-api.adoc | 6 + .../docs/asciidoc/api/admin-file/file.adoc | 30 +++++ .../AdminImageUploadControllerDocsTest.kt | 116 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-file/file.adoc create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index d855eb49f..f84daa9a4 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -41,3 +41,9 @@ include::api/admin-alcohols/alcohols.adoc[] == Help API include::api/admin-help/help.adoc[] + +''' + +== File API + +include::api/admin-file/file.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-file/file.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-file/file.adoc new file mode 100644 index 000000000..a3a302aeb --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-file/file.adoc @@ -0,0 +1,30 @@ +=== PreSigned URL 발급 === + +- S3 업로드용 PreSigned URL을 발급합니다. +- 발급된 URL은 지정된 시간(기본 5분) 동안 유효합니다. +- 여러 개의 URL을 한 번에 발급할 수 있습니다. + +[source] +---- +GET /admin/api/v1/s3/presign-url +---- + +[discrete] +==== 요청 헤더 ==== + +[discrete] +include::{snippets}/admin/file/presign-url/request-headers.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/file/presign-url/query-parameters.adoc[] +include::{snippets}/admin/file/presign-url/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/file/presign-url/response-fields.adoc[] +include::{snippets}/admin/file/presign-url/http-response.adoc[] diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt new file mode 100644 index 000000000..775415163 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt @@ -0,0 +1,116 @@ +package app.docs.file + +import app.bottlenote.common.file.dto.request.ImageUploadRequest +import app.bottlenote.common.file.dto.response.ImageUploadItem +import app.bottlenote.common.file.dto.response.ImageUploadResponse +import app.bottlenote.common.file.presentation.AdminImageUploadController +import app.bottlenote.common.file.service.ImageUploadService +import app.bottlenote.global.security.SecurityContextUtil +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.BDDMockito.given +import org.mockito.MockedStatic +import org.mockito.Mockito +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.restdocs.headers.HeaderDocumentation.headerWithName +import org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders +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.* +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.util.* + +@WebMvcTest( + controllers = [AdminImageUploadController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin 이미지 업로드 컨트롤러 RestDocs 테스트") +class AdminImageUploadControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @MockitoBean + private lateinit var imageUploadService: ImageUploadService + + @Test + @DisplayName("PreSigned URL 발급") + fun getPreSignUrl() { + // given + val imageUploadInfo = listOf( + ImageUploadItem.builder() + .order(1L) + .viewUrl("https://cdn.example.com/admin/banner/uuid-1.jpg") + .uploadUrl("https://s3.ap-northeast-2.amazonaws.com/bucket/admin/banner/uuid-1.jpg?X-Amz-Algorithm=...") + .build(), + ImageUploadItem.builder() + .order(2L) + .viewUrl("https://cdn.example.com/admin/banner/uuid-2.jpg") + .uploadUrl("https://s3.ap-northeast-2.amazonaws.com/bucket/admin/banner/uuid-2.jpg?X-Amz-Algorithm=...") + .build() + ) + + val response = ImageUploadResponse.builder() + .bucketName("bottlenote-bucket") + .expiryTime(5) + .uploadSize(2) + .imageUploadInfo(imageUploadInfo) + .build() + + given(imageUploadService.getPreSignUrlForAdmin(anyLong(), any(ImageUploadRequest::class.java))) + .willReturn(response) + + Mockito.mockStatic(SecurityContextUtil::class.java).use { mockedStatic: MockedStatic -> + mockedStatic.`when`> { SecurityContextUtil.getAdminUserIdByContext() } + .thenReturn(Optional.of(1L)) + + // when & then + assertThat( + mvc.get().uri("/s3/presign-url") + .header("Authorization", "Bearer test_access_token") + .param("rootPath", "admin/banner") + .param("uploadSize", "2") + ) + .hasStatusOk() + .apply( + document( + "admin/file/presign-url", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰") + ), + queryParameters( + parameterWithName("rootPath").description("업로드 경로 (예: admin/banner, admin/alcohol)"), + parameterWithName("uploadSize").description("발급할 URL 개수") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data.bucketName").type(JsonFieldType.STRING).description("S3 버킷명"), + fieldWithPath("data.expiryTime").type(JsonFieldType.NUMBER).description("URL 만료 시간 (분)"), + fieldWithPath("data.uploadSize").type(JsonFieldType.NUMBER).description("발급된 URL 개수"), + fieldWithPath("data.imageUploadInfo[].order").type(JsonFieldType.NUMBER).description("이미지 순서"), + fieldWithPath("data.imageUploadInfo[].viewUrl").type(JsonFieldType.STRING).description("이미지 조회 URL (CDN)"), + fieldWithPath("data.imageUploadInfo[].uploadUrl").type(JsonFieldType.STRING).description("이미지 업로드 URL (PreSigned)"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).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 e0b2ec2d75f064269d18d618334325e672702a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:10:30 +0000 Subject: [PATCH 66/95] =?UTF-8?q?feat:=20Admin=20=EC=88=A0=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 술(Alcohol) 단건 상세 조회 API 추가 (GET /alcohols/{id}) - 테이스팅 태그 목록 조회 API 추가 (GET /tasting-tags) - 지역 목록 조회 API 추가 (GET /regions) - 증류소 목록 조회 API 추가 (GET /distilleries) - 관련 DTO, Repository, Service 메서드 추가 - 통합 테스트 추가 (해피케이스 + 인증 방어로직) --- .../persentaton/AdminAlcoholsController.kt | 6 + .../persentaton/AdminDistilleryController.kt | 21 +++ .../persentaton/AdminRegionController.kt | 21 +++ .../persentaton/AdminTastingTagController.kt | 21 +++ .../alcohols/AdminAlcoholsIntegrationTest.kt | 54 +++++++ .../AdminReferenceDataIntegrationTest.kt | 150 ++++++++++++++++++ .../domain/AlcoholQueryRepository.java | 4 + .../alcohols/domain/DistilleryRepository.java | 9 ++ .../alcohols/domain/RegionRepository.java | 3 + .../alcohols/domain/TastingTagRepository.java | 3 + .../response/AdminAlcoholDetailResponse.java | 35 ++++ .../dto/response/AdminDistilleryItem.java | 11 ++ .../dto/response/AdminRegionItem.java | 12 ++ .../dto/response/AdminTastingTagItem.java | 12 ++ .../CustomAlcoholQueryRepository.java | 30 ++++ .../CustomAlcoholQueryRepositoryImpl.java | 80 ++++++++++ .../repository/JpaDistilleryRepository.java | 24 +++ .../repository/JpaRegionQueryRepository.java | 17 +- .../repository/JpaTastingTagRepository.java | 16 +- .../alcohols/service/AlcoholQueryService.java | 57 +++++++ 20 files changed, 582 insertions(+), 4 deletions(-) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholDetailResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminAlcoholsController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminAlcoholsController.kt index c9f98a6ed..db8a37305 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminAlcoholsController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminAlcoholsController.kt @@ -6,6 +6,7 @@ import app.bottlenote.global.data.response.GlobalResponse import org.springframework.http.ResponseEntity 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.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -20,4 +21,9 @@ class AdminAlcoholsController( fun searchAlcohols(@ModelAttribute request: AdminAlcoholSearchRequest): ResponseEntity { return ResponseEntity.ok(alcoholQueryService.searchAdminAlcohols(request)) } + + @GetMapping("/{alcoholId}") + fun getAlcoholDetail(@PathVariable alcoholId: Long): ResponseEntity<*> { + return ResponseEntity.ok(GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId))) + } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt new file mode 100644 index 000000000..eda988c71 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt @@ -0,0 +1,21 @@ +package app.bottlenote.alcohols.persentaton + +import app.bottlenote.alcohols.domain.DistilleryRepository +import app.bottlenote.global.data.response.GlobalResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + + +@RestController +@RequestMapping("/distilleries") +class AdminDistilleryController( + private val distilleryRepository: DistilleryRepository +) { + + @GetMapping + fun getAllDistilleries(): ResponseEntity<*> { + return ResponseEntity.ok(GlobalResponse.ok(distilleryRepository.findAllDistilleries())) + } +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt new file mode 100644 index 000000000..ff26d6217 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt @@ -0,0 +1,21 @@ +package app.bottlenote.alcohols.persentaton + +import app.bottlenote.alcohols.domain.RegionRepository +import app.bottlenote.global.data.response.GlobalResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + + +@RestController +@RequestMapping("/regions") +class AdminRegionController( + private val regionRepository: RegionRepository +) { + + @GetMapping + fun getAllRegions(): ResponseEntity<*> { + return ResponseEntity.ok(GlobalResponse.ok(regionRepository.findAllRegions())) + } +} diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt new file mode 100644 index 000000000..6faa0afb4 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt @@ -0,0 +1,21 @@ +package app.bottlenote.alcohols.persentaton + +import app.bottlenote.alcohols.domain.TastingTagRepository +import app.bottlenote.global.data.response.GlobalResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + + +@RestController +@RequestMapping("/tasting-tags") +class AdminTastingTagController( + private val tastingTagRepository: TastingTagRepository +) { + + @GetMapping + fun getAllTastingTags(): ResponseEntity<*> { + return ResponseEntity.ok(GlobalResponse.ok(tastingTagRepository.findAllTastingTags())) + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index 579c62c6b..064488f8b 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -8,6 +8,7 @@ import app.bottlenote.global.service.cursor.SortOrder 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.junit.jupiter.params.ParameterizedTest @@ -148,4 +149,57 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .bodyJson() .extractingPath("$.meta.size").isEqualTo(size) } + + @Nested + @DisplayName("술 단건 상세 조회 API") + inner class GetAlcoholDetail { + + @Test + @DisplayName("관리자용 술 단건 상세 정보를 조회할 수 있다") + fun getAlcoholDetailSuccess() { + // given + val alcohol = alcoholTestFactory.persistAlcoholWithName("글렌피딕 12년", "Glenfiddich 12") + + // when & then - 성공 응답 확인 + assertThat( + mockMvcTester.get().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + // 상세 데이터 검증 + assertThat( + mockMvcTester.get().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.korName").isEqualTo("글렌피딕 12년") + } + + @Test + @DisplayName("모든 상세 필드가 포함되어 응답한다") + fun getAlcoholDetailWithAllFields() { + // given + val alcohol = alcoholTestFactory.persistAlcoholWithName("맥캘란 18년", "Macallan 18") + + // when & then - 필수 필드 존재 여부 확인 + val result = mockMvcTester.get().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + + assertThat(result) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.alcoholId").isNotNull + + // 방어로직: 존재하지 않는 ID로 조회 시 실패 + assertThat( + mockMvcTester.get().uri("/alcohols/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt new file mode 100644 index 000000000..e5dafed05 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt @@ -0,0 +1,150 @@ +package app.integration.alcohols + +import app.IntegrationTestSupport +import app.bottlenote.alcohols.fixture.AlcoholTestFactory +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 + +@Tag("admin_integration") +@DisplayName("[integration] Admin 참조 데이터 API 통합 테스트") +class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { + + @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 TastingTagsApi { + + @Test + @DisplayName("전체 테이스팅 태그 목록을 조회할 수 있다") + fun getAllTastingTagsSuccess() { + // when & then + assertThat( + mockMvcTester.get().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + + // 응답이 리스트 형태임을 확인 + assertThat( + mockMvcTester.get().uri("/tasting-tags") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data").isNotNull + } + + @Test + @DisplayName("인증 없이 요청 시 실패한다") + fun getTastingTagsWithoutAuth() { + // when & then - 방어로직: 인증 없이 요청 시 실패 + assertThat( + mockMvcTester.get().uri("/tasting-tags") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("지역 목록 조회 API") + inner class RegionsApi { + + @Test + @DisplayName("전체 지역 목록을 조회할 수 있다") + fun getAllRegionsSuccess() { + // given - alcoholTestFactory에서 region 데이터가 함께 생성됨 + alcoholTestFactory.persistAlcohols(1) + + // when & then + assertThat( + mockMvcTester.get().uri("/regions") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("지역 목록이 메타 정보를 포함한다") + fun getRegionsWithMetaInfo() { + // given + alcoholTestFactory.persistAlcohols(1) + + // when & then - 응답 데이터 확인 + val result = mockMvcTester.get().uri("/regions") + .header("Authorization", "Bearer $accessToken") + + assertThat(result) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data").isNotNull + + // 방어로직: 인증 없이 요청 시 실패 + assertThat( + mockMvcTester.get().uri("/regions") + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("증류소 목록 조회 API") + inner class DistilleriesApi { + + @Test + @DisplayName("전체 증류소 목록을 조회할 수 있다") + fun getAllDistilleriesSuccess() { + // given - alcoholTestFactory에서 distillery 데이터가 함께 생성됨 + alcoholTestFactory.persistAlcohols(1) + + // when & then + assertThat( + mockMvcTester.get().uri("/distilleries") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("증류소 목록이 필수 필드를 포함한다") + fun getDistilleriesWithRequiredFields() { + // given + alcoholTestFactory.persistAlcohols(1) + + // when & then + val result = mockMvcTester.get().uri("/distilleries") + .header("Authorization", "Bearer $accessToken") + + assertThat(result) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data").isNotNull + + // 방어로직: 인증 없이 요청 시 실패 + assertThat( + mockMvcTester.get().uri("/distilleries") + ) + .hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java index 99f5a9444..6f70cdd7e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java @@ -15,6 +15,8 @@ import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; +import static app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; + /** 알코올 조회 질의에 관한 애그리거트를 정의합니다. */ public interface AlcoholQueryRepository { @@ -40,4 +42,6 @@ Pair> getStandardExplore( Long userId, List keyword, Long cursor, Integer size); Page searchAdminAlcohols(AdminAlcoholSearchRequest request); + + Optional findAdminAlcoholDetailById(Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java new file mode 100644 index 000000000..902f4c8ff --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java @@ -0,0 +1,9 @@ +package app.bottlenote.alcohols.domain; + +import app.bottlenote.alcohols.dto.response.AdminDistilleryItem; +import java.util.List; + +public interface DistilleryRepository { + + List findAllDistilleries(); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java index e406586e9..796250fdf 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java @@ -1,9 +1,12 @@ package app.bottlenote.alcohols.domain; +import app.bottlenote.alcohols.dto.response.AdminRegionItem; import app.bottlenote.alcohols.dto.response.RegionsItem; import java.util.List; public interface RegionRepository { List findAllRegionsResponse(); + + List findAllRegions(); } 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 fd20bf58e..a28625d5f 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 @@ -1,8 +1,11 @@ package app.bottlenote.alcohols.domain; +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import java.util.List; public interface TastingTagRepository { List findAll(); + + List findAllTastingTags(); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholDetailResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholDetailResponse.java new file mode 100644 index 000000000..87996f4c4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminAlcoholDetailResponse.java @@ -0,0 +1,35 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record AdminAlcoholDetailResponse( + Long alcoholId, + String korName, + String engName, + String imageUrl, + String type, + String korCategory, + String engCategory, + String categoryGroup, + String abv, + String age, + String cask, + String volume, + String description, + Long regionId, + String korRegion, + String engRegion, + Long distilleryId, + String korDistillery, + String engDistillery, + List tastingTags, + Double avgRating, + Long totalRatingsCount, + Long reviewCount, + Long pickCount, + LocalDateTime createdAt, + LocalDateTime modifiedAt) { + + public record TastingTagInfo(Long id, String korName, String engName) {} +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java new file mode 100644 index 000000000..8e1c5503c --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminDistilleryItem.java @@ -0,0 +1,11 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; + +public record AdminDistilleryItem( + Long id, + String korName, + String engName, + String logoImgUrl, + LocalDateTime createdAt, + LocalDateTime modifiedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java new file mode 100644 index 000000000..34655c496 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java @@ -0,0 +1,12 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; + +public record AdminRegionItem( + Long id, + String korName, + String engName, + String continent, + String description, + LocalDateTime createdAt, + LocalDateTime 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 new file mode 100644 index 000000000..c7fa78074 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminTastingTagItem.java @@ -0,0 +1,12 @@ +package app.bottlenote.alcohols.dto.response; + +import java.time.LocalDateTime; + +public record AdminTastingTagItem( + Long id, + String korName, + String engName, + String icon, + String description, + LocalDateTime createdAt, + LocalDateTime modifiedAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java index e50794619..4fd4ce410 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Optional; import org.apache.commons.lang3.tuple.Pair; +import java.time.LocalDateTime; import org.springframework.data.domain.Page; public interface CustomAlcoholQueryRepository { @@ -25,4 +26,33 @@ Pair> getStandardExplore( Long userId, List keyword, Long cursor, Integer size); Page searchAdminAlcohols(AdminAlcoholSearchRequest request); + + Optional findAdminAlcoholDetailById(Long alcoholId); + + record AdminAlcoholDetailProjection( + Long alcoholId, + String korName, + String engName, + String imageUrl, + String type, + String korCategory, + String engCategory, + String categoryGroup, + String abv, + String age, + String cask, + String volume, + String description, + Long regionId, + String korRegion, + String engRegion, + Long distilleryId, + String korDistillery, + String engDistillery, + Double avgRating, + Long totalRatingsCount, + Long reviewCount, + Long pickCount, + LocalDateTime createdAt, + LocalDateTime modifiedAt) {} } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java index 1308d6d46..c390c610f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java @@ -330,4 +330,84 @@ public Page searchAdminAlcohols(AdminAlcoholSearchRequest requ return new PageImpl<>( content, PageRequest.of(request.page(), request.size()), total != null ? total : 0L); } + + /** Admin용 알코올 단건 상세 조회 */ + @Override + public Optional findAdminAlcoholDetailById(Long alcoholId) { + AdminAlcoholDetailProjection result = + queryFactory + .select( + Projections.constructor( + AdminAlcoholDetailProjection.class, + alcohol.id, + alcohol.korName, + alcohol.engName, + alcohol.imageUrl, + alcohol.type.stringValue(), + alcohol.korCategory, + alcohol.engCategory, + alcohol.categoryGroup.stringValue(), + alcohol.abv, + alcohol.age, + alcohol.cask, + alcohol.volume, + alcohol.description, + region.id, + region.korName, + region.engName, + distillery.id, + distillery.korName, + distillery.engName, + rating + .ratingPoint + .rating + .avg() + .multiply(2) + .castToNum(Double.class) + .round() + .divide(2) + .coalesce(0.0), + rating.id.count(), + review.id.countDistinct(), + picks.id.countDistinct(), + alcohol.createAt, + alcohol.lastModifyAt)) + .from(alcohol) + .leftJoin(rating) + .on(rating.id.alcoholId.eq(alcohol.id)) + .leftJoin(review) + .on(review.alcoholId.eq(alcohol.id)) + .leftJoin(picks) + .on(picks.alcoholId.eq(alcohol.id)) + .leftJoin(region) + .on(alcohol.region.id.eq(region.id)) + .leftJoin(distillery) + .on(alcohol.distillery.id.eq(distillery.id)) + .where(alcohol.id.eq(alcoholId)) + .groupBy( + alcohol.id, + alcohol.korName, + alcohol.engName, + alcohol.imageUrl, + alcohol.type, + alcohol.korCategory, + alcohol.engCategory, + alcohol.categoryGroup, + alcohol.abv, + alcohol.age, + alcohol.cask, + alcohol.volume, + alcohol.description, + region.id, + region.korName, + region.engName, + distillery.id, + distillery.korName, + distillery.engName, + alcohol.createAt, + alcohol.lastModifyAt) + .fetchOne(); + + return Optional.ofNullable(result); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java new file mode 100644 index 000000000..9b8fe4686 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java @@ -0,0 +1,24 @@ +package app.bottlenote.alcohols.repository; + +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.DistilleryRepository; +import app.bottlenote.alcohols.dto.response.AdminDistilleryItem; +import app.bottlenote.common.annotation.JpaRepositoryImpl; +import java.util.List; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; + +@JpaRepositoryImpl +public interface JpaDistilleryRepository + extends DistilleryRepository, CrudRepository { + + @Override + @Query( + """ + select new app.bottlenote.alcohols.dto.response.AdminDistilleryItem( + d.id, d.korName, d.engName, d.logoImgPath, d.createAt, d.lastModifyAt + ) + from distillery d order by d.id asc + """) + List findAllDistilleries(); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java index 605d57a38..449652815 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java @@ -2,6 +2,7 @@ import app.bottlenote.alcohols.domain.Region; import app.bottlenote.alcohols.domain.RegionRepository; +import app.bottlenote.alcohols.dto.response.AdminRegionItem; import app.bottlenote.alcohols.dto.response.RegionsItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; import java.util.List; @@ -14,8 +15,18 @@ public interface JpaRegionQueryRepository extends RegionRepository, CrudReposito @Override @Query( """ - select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description) - from region r order by r.id asc - """) + select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description) + from region r order by r.id asc + """) List findAllRegionsResponse(); + + @Override + @Query( + """ + select new app.bottlenote.alcohols.dto.response.AdminRegionItem( + r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt + ) + from region r order by r.id asc + """) + List findAllRegions(); } 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 fbbf74463..62f4032ad 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 @@ -2,9 +2,23 @@ import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.domain.TastingTagRepository; +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; +import java.util.List; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; @JpaRepositoryImpl public interface JpaTastingTagRepository - extends TastingTagRepository, CrudRepository {} + extends TastingTagRepository, CrudRepository { + + @Override + @Query( + """ + select new app.bottlenote.alcohols.dto.response.AdminTastingTagItem( + t.id, t.korName, t.engName, t.icon, t.description, t.createAt, t.lastModifyAt + ) + from tasting_tag t order by t.id asc + """) + List findAllTastingTags(); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java index 58fe781fb..4f6394a97 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java @@ -1,13 +1,19 @@ package app.bottlenote.alcohols.service; +import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; import app.bottlenote.alcohols.dto.dsl.AlcoholSearchCriteria; import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; import app.bottlenote.alcohols.dto.request.AlcoholSearchRequest; +import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse; +import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse.TastingTagInfo; import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholDetailResponse; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.FriendsDetailResponse; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.alcohols.exception.AlcoholExceptionCode; +import app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; @@ -96,4 +102,55 @@ public Pair> getStandardExplore( public GlobalResponse searchAdminAlcohols(AdminAlcoholSearchRequest request) { return GlobalResponse.fromPage(alcoholQueryRepository.searchAdminAlcohols(request)); } + + @Transactional(readOnly = true) + public AdminAlcoholDetailResponse findAdminAlcoholDetailById(Long alcoholId) { + AdminAlcoholDetailProjection projection = + alcoholQueryRepository + .findAdminAlcoholDetailById(alcoholId) + .orElseThrow(() -> new AlcoholException(AlcoholExceptionCode.ALCOHOL_NOT_FOUND)); + + Alcohol alcohol = + alcoholQueryRepository + .findById(alcoholId) + .orElseThrow(() -> new AlcoholException(AlcoholExceptionCode.ALCOHOL_NOT_FOUND)); + + List tastingTags = + alcohol.getAlcoholsTastingTags().stream() + .map( + att -> + new TastingTagInfo( + att.getTastingTag().getId(), + att.getTastingTag().getKorName(), + att.getTastingTag().getEngName())) + .toList(); + + return new AdminAlcoholDetailResponse( + projection.alcoholId(), + projection.korName(), + projection.engName(), + projection.imageUrl(), + projection.type(), + projection.korCategory(), + projection.engCategory(), + projection.categoryGroup(), + projection.abv(), + projection.age(), + projection.cask(), + projection.volume(), + projection.description(), + projection.regionId(), + projection.korRegion(), + projection.engRegion(), + projection.distilleryId(), + projection.korDistillery(), + projection.engDistillery(), + tastingTags, + projection.avgRating(), + projection.totalRatingsCount(), + projection.reviewCount(), + projection.pickCount(), + projection.createdAt(), + projection.modifiedAt()); + } } From 967fce1917aa0858d52c33b7e07ba8be62b99682 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:15:13 +0000 Subject: [PATCH 67/95] =?UTF-8?q?docs:=20Admin=20API=20RestDocs=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 술 단건 상세 조회 RestDocs 테스트 추가 - 테이스팅 태그 목록 RestDocs 테스트 추가 - 지역 목록 RestDocs 테스트 추가 - 증류소 목록 RestDocs 테스트 추가 - AlcoholsHelper에 테스트 데이터 생성 메서드 추가 - asciidoc 문서 확장 (alcohols.adoc, reference.adoc) --- .../src/docs/asciidoc/admin-api.adoc | 6 ++ .../asciidoc/api/admin-alcohols/alcohols.adoc | 27 ++++++ .../api/admin-reference/reference.adoc | 75 +++++++++++++++ .../AdminAlcoholsControllerDocsTest.kt | 69 ++++++++++++++ .../AdminDistilleryControllerDocsTest.kt | 75 +++++++++++++++ .../alcohols/AdminRegionControllerDocsTest.kt | 76 +++++++++++++++ .../AdminTastingTagControllerDocsTest.kt | 76 +++++++++++++++ .../app/helper/alcohols/AlcoholsHelper.kt | 95 +++++++++++++++++++ 8 files changed, 499 insertions(+) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index f84daa9a4..8f22e5675 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -47,3 +47,9 @@ include::api/admin-help/help.adoc[] == File API include::api/admin-file/file.adoc[] + +''' + +== Reference API + +include::api/admin-reference/reference.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc index 49f02646a..933bf581c 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc @@ -22,3 +22,30 @@ include::{snippets}/admin/alcohols/search/http-request.adoc[] [discrete] include::{snippets}/admin/alcohols/search/response-fields.adoc[] include::{snippets}/admin/alcohols/search/http-response.adoc[] + +''' + +=== 술(Alcohol) 단건 상세 조회 === + +- 관리자용 술 단건 상세 조회 API입니다. +- 기본 정보, 카테고리, 스펙, 지역, 증류소, 테이스팅 태그, 통계 정보를 모두 포함합니다. + +[source] +---- +GET /admin/api/v1/alcohols/{alcoholId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/detail/path-parameters.adoc[] +include::{snippets}/admin/alcohols/detail/curl-request.adoc[] +include::{snippets}/admin/alcohols/detail/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/detail/response-fields.adoc[] +include::{snippets}/admin/alcohols/detail/http-response.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 new file mode 100644 index 000000000..6e3f972e3 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-reference/reference.adoc @@ -0,0 +1,75 @@ +=== 테이스팅 태그 목록 조회 === + +- 전체 테이스팅 태그 목록을 조회합니다. +- 술의 향미를 표현하는 태그 정보를 제공합니다. + +[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[] + +''' + +=== 지역 목록 조회 === + +- 전체 지역(국가) 목록을 조회합니다. +- 술의 원산지 정보를 제공합니다. + +[source] +---- +GET /admin/api/v1/regions +---- + +[discrete] +==== 요청 ==== + +[discrete] +include::{snippets}/admin/regions/list/curl-request.adoc[] +include::{snippets}/admin/regions/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/regions/list/response-fields.adoc[] +include::{snippets}/admin/regions/list/http-response.adoc[] + +''' + +=== 증류소 목록 조회 === + +- 전체 증류소 목록을 조회합니다. +- 술을 생산하는 증류소 정보를 제공합니다. + +[source] +---- +GET /admin/api/v1/distilleries +---- + +[discrete] +==== 요청 ==== + +[discrete] +include::{snippets}/admin/distilleries/list/curl-request.adoc[] +include::{snippets}/admin/distilleries/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/distilleries/list/response-fields.adoc[] +include::{snippets}/admin/distilleries/list/http-response.adoc[] diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index 9d49ef6c0..a75ae00bb 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -3,6 +3,7 @@ package app.docs.alcohols import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest +import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse import app.bottlenote.alcohols.persentaton.AdminAlcoholsController import app.bottlenote.alcohols.service.AlcoholQueryService import app.bottlenote.global.service.cursor.SortOrder @@ -12,6 +13,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.BDDMockito.given import org.mockito.Mockito.any +import org.mockito.Mockito.anyLong import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs @@ -22,6 +24,7 @@ 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.pathParameters import org.springframework.restdocs.request.RequestDocumentation.queryParameters import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester @@ -117,4 +120,70 @@ class AdminAlcoholsControllerDocsTest { ) ) } + + @Test + @DisplayName("관리자용 술 단건 상세 조회를 할 수 있다") + fun getAlcoholDetail() { + // given + val response = AlcoholsHelper.createAdminAlcoholDetailResponse() + + given(alcoholQueryService.findAdminAlcoholDetailById(anyLong())) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/alcohols/{alcoholId}", 1L) + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("alcoholId").description("술 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("술 상세 정보"), + fieldWithPath("data.alcoholId").type(JsonFieldType.NUMBER).description("술 ID"), + fieldWithPath("data.korName").type(JsonFieldType.STRING).description("한글 이름"), + fieldWithPath("data.engName").type(JsonFieldType.STRING).description("영문 이름"), + fieldWithPath("data.imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("data.type").type(JsonFieldType.STRING).description("술 타입 (WHISKY 등)"), + fieldWithPath("data.korCategory").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("data.engCategory").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("data.categoryGroup").type(JsonFieldType.STRING).description("카테고리 그룹"), + fieldWithPath("data.abv").type(JsonFieldType.STRING).description("도수"), + fieldWithPath("data.age").type(JsonFieldType.STRING).description("숙성년도"), + fieldWithPath("data.cask").type(JsonFieldType.STRING).description("캐스크 타입"), + fieldWithPath("data.volume").type(JsonFieldType.STRING).description("용량"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("data.regionId").type(JsonFieldType.NUMBER).description("지역 ID"), + fieldWithPath("data.korRegion").type(JsonFieldType.STRING).description("지역 한글명"), + fieldWithPath("data.engRegion").type(JsonFieldType.STRING).description("지역 영문명"), + fieldWithPath("data.distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID"), + fieldWithPath("data.korDistillery").type(JsonFieldType.STRING).description("증류소 한글명"), + fieldWithPath("data.engDistillery").type(JsonFieldType.STRING).description("증류소 영문명"), + fieldWithPath("data.tastingTags").type(JsonFieldType.ARRAY).description("테이스팅 태그 목록"), + fieldWithPath("data.tastingTags[].id").type(JsonFieldType.NUMBER).description("태그 ID"), + fieldWithPath("data.tastingTags[].korName").type(JsonFieldType.STRING).description("태그 한글명"), + fieldWithPath("data.tastingTags[].engName").type(JsonFieldType.STRING).description("태그 영문명"), + fieldWithPath("data.avgRating").type(JsonFieldType.NUMBER).description("평균 평점"), + fieldWithPath("data.totalRatingsCount").type(JsonFieldType.NUMBER).description("평점 수"), + fieldWithPath("data.reviewCount").type(JsonFieldType.NUMBER).description("리뷰 수"), + fieldWithPath("data.pickCount").type(JsonFieldType.NUMBER).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).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() + ) + ) + ) + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt new file mode 100644 index 000000000..2cc4c2596 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt @@ -0,0 +1,75 @@ +package app.docs.alcohols + +import app.bottlenote.alcohols.domain.DistilleryRepository +import app.bottlenote.alcohols.persentaton.AdminDistilleryController +import app.helper.alcohols.AlcoholsHelper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +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.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.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester + +@WebMvcTest( + controllers = [AdminDistilleryController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Distillery 컨트롤러 RestDocs 테스트") +class AdminDistilleryControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @MockitoBean + private lateinit var distilleryRepository: DistilleryRepository + + @Test + @DisplayName("증류소 목록을 조회할 수 있다") + fun getAllDistilleries() { + // given + val items = AlcoholsHelper.createAdminDistilleryItems(3) + + given(distilleryRepository.findAllDistilleries()) + .willReturn(items) + + // when & then + assertThat( + mvc.get().uri("/distilleries") + ) + .hasStatusOk() + .apply( + document( + "admin/distilleries/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + 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[].logoImgUrl").type(JsonFieldType.STRING).description("로고 이미지 URL"), + 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).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() + ) + ) + ) + } +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt new file mode 100644 index 000000000..648d458bb --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt @@ -0,0 +1,76 @@ +package app.docs.alcohols + +import app.bottlenote.alcohols.domain.RegionRepository +import app.bottlenote.alcohols.persentaton.AdminRegionController +import app.helper.alcohols.AlcoholsHelper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +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.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.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester + +@WebMvcTest( + controllers = [AdminRegionController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Region 컨트롤러 RestDocs 테스트") +class AdminRegionControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @MockitoBean + private lateinit var regionRepository: RegionRepository + + @Test + @DisplayName("지역 목록을 조회할 수 있다") + fun getAllRegions() { + // given + val items = AlcoholsHelper.createAdminRegionItems(3) + + given(regionRepository.findAllRegions()) + .willReturn(items) + + // when & then + assertThat( + mvc.get().uri("/regions") + ) + .hasStatusOk() + .apply( + document( + "admin/regions/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + 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[].continent").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.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() + ) + ) + ) + } +} 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 new file mode 100644 index 000000000..0c8d6b723 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -0,0 +1,76 @@ +package app.docs.alcohols + +import app.bottlenote.alcohols.domain.TastingTagRepository +import app.bottlenote.alcohols.persentaton.AdminTastingTagController +import app.helper.alcohols.AlcoholsHelper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +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.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.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester + +@WebMvcTest( + controllers = [AdminTastingTagController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin TastingTag 컨트롤러 RestDocs 테스트") +class AdminTastingTagControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @MockitoBean + private lateinit var tastingTagRepository: TastingTagRepository + + @Test + @DisplayName("테이스팅 태그 목록을 조회할 수 있다") + fun getAllTastingTags() { + // given + val items = AlcoholsHelper.createAdminTastingTagItems(3) + + given(tastingTagRepository.findAllTastingTags()) + .willReturn(items) + + // when & then + assertThat( + mvc.get().uri("/tasting-tags") + ) + .hasStatusOk() + .apply( + document( + "admin/tasting-tags/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + 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.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() + ) + ) + ) + } +} 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 6985dd374..a549b93df 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 @@ -1,6 +1,11 @@ package app.helper.alcohols +import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse +import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse.TastingTagInfo import app.bottlenote.alcohols.dto.response.AdminAlcoholItem +import app.bottlenote.alcohols.dto.response.AdminDistilleryItem +import app.bottlenote.alcohols.dto.response.AdminRegionItem +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem import app.bottlenote.global.data.response.GlobalResponse import java.time.LocalDateTime @@ -59,4 +64,94 @@ object AlcoholsHelper { ) .build() } + + fun createAdminAlcoholDetailResponse( + alcoholId: Long = 1L, + korName: String = "글렌피딕 12년", + engName: String = "Glenfiddich 12 Year" + ): AdminAlcoholDetailResponse = AdminAlcoholDetailResponse( + alcoholId, + korName, + engName, + "https://example.com/image.jpg", + "WHISKY", + "싱글몰트", + "Single Malt", + "SINGLE_MALT", + "40%", + "12", + "오크", + "700ml", + "스코틀랜드의 대표적인 싱글몰트 위스키", + 1L, + "스코틀랜드", + "Scotland", + 1L, + "글렌피딕", + "Glenfiddich", + listOf( + TastingTagInfo(1L, "바닐라", "Vanilla"), + TastingTagInfo(2L, "꿀", "Honey") + ), + 4.2, + 150L, + 45L, + 200L, + LocalDateTime.of(2024, 1, 1, 0, 0), + LocalDateTime.of(2024, 6, 1, 0, 0) + ) + + fun createAdminTastingTagItems(count: Int = 3): List = + (1..count).map { i -> + AdminTastingTagItem( + i.toLong(), + "태그$i", + "Tag$i", + "icon$i.png", + "테이스팅 태그 설명 $i", + LocalDateTime.of(2024, 1, i, 0, 0), + LocalDateTime.of(2024, 6, i, 0, 0) + ) + } + + fun createAdminRegionItems(count: Int = 3): List = + (1..count).map { i -> + AdminRegionItem( + i.toLong(), + listOf("스코틀랜드", "아일랜드", "일본")[i - 1], + listOf("Scotland", "Ireland", "Japan")[i - 1], + listOf("유럽", "유럽", "아시아")[i - 1], + "지역 설명 $i", + LocalDateTime.of(2024, 1, i, 0, 0), + LocalDateTime.of(2024, 6, i, 0, 0) + ) + } + + fun createAdminDistilleryItems(count: Int = 3): List = + (1..count).map { i -> + AdminDistilleryItem( + i.toLong(), + listOf("글렌피딕", "맥캘란", "야마자키")[i - 1], + listOf("Glenfiddich", "Macallan", "Yamazaki")[i - 1], + "https://example.com/logo$i.png", + LocalDateTime.of(2024, 1, i, 0, 0), + LocalDateTime.of(2024, 6, i, 0, 0) + ) + } + + fun createListResponse(items: List): GlobalResponse = + GlobalResponse.builder() + .success(true) + .code(200) + .data(items) + .errors(emptyList()) + .meta( + mapOf( + "serverVersion" to "1.0.0", + "serverEncoding" to "UTF-8", + "serverResponseTime" to LocalDateTime.now().toString(), + "serverPathVersion" to "v1" + ) + ) + .build() } From 24215c7769c719cb6570a801b2a80c3fb899a56e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Jan 2026 16:17:01 +0000 Subject: [PATCH 68/95] chore: apply code formatting [skip ci] --- .../bottlenote/alcohols/domain/AlcoholQueryRepository.java | 4 ++-- .../alcohols/repository/CustomAlcoholQueryRepository.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java index 6f70cdd7e..43aaab210 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java @@ -1,5 +1,7 @@ package app.bottlenote.alcohols.domain; +import static app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; + import app.bottlenote.alcohols.constant.AlcoholType; import app.bottlenote.alcohols.dto.dsl.AlcoholSearchCriteria; import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest; @@ -15,8 +17,6 @@ import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; -import static app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; - /** 알코올 조회 질의에 관한 애그리거트를 정의합니다. */ public interface AlcoholQueryRepository { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java index 4fd4ce410..2178a09db 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java @@ -8,10 +8,10 @@ import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.apache.commons.lang3.tuple.Pair; -import java.time.LocalDateTime; import org.springframework.data.domain.Page; public interface CustomAlcoholQueryRepository { From 88c734f06e0cb226262e20ab0d823b45210c20dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:30:44 +0000 Subject: [PATCH 69/95] =?UTF-8?q?feat:=20Admin=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20API=EC=97=90=20Pagination=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테이스팅 태그, 지역, 증류소 목록 API에 페이지네이션 기능을 추가합니다. - AdminReferenceSearchRequest DTO 추가 (keyword, sortOrder, page, size) - TastingTag/Region/Distillery Repository에 Page 반환 및 keyword 검색 지원 - 컨트롤러에서 PageRequest 생성 및 GlobalResponse.fromPage() 사용 - RestDocs 테스트에 queryParameters 및 pagination meta 필드 문서화 - 통합 테스트에 pagination 파라미터 및 meta 검증 추가 --- .../persentaton/AdminDistilleryController.kt | 14 ++++++- .../persentaton/AdminRegionController.kt | 14 ++++++- .../persentaton/AdminTastingTagController.kt | 14 ++++++- .../AdminDistilleryControllerDocsTest.kt | 24 +++++++++-- .../alcohols/AdminRegionControllerDocsTest.kt | 24 +++++++++-- .../AdminTastingTagControllerDocsTest.kt | 24 +++++++++-- .../AdminReferenceDataIntegrationTest.kt | 40 ++++++++++--------- .../alcohols/domain/DistilleryRepository.java | 5 ++- .../alcohols/domain/RegionRepository.java | 4 +- .../alcohols/domain/TastingTagRepository.java | 4 +- .../request/AdminReferenceSearchRequest.java | 25 ++++++++++++ .../repository/JpaDistilleryRepository.java | 11 +++-- .../repository/JpaRegionQueryRepository.java | 10 ++++- .../repository/JpaTastingTagRepository.java | 11 +++-- 14 files changed, 179 insertions(+), 45 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt index eda988c71..035b9607d 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt @@ -1,9 +1,13 @@ package app.bottlenote.alcohols.persentaton import app.bottlenote.alcohols.domain.DistilleryRepository +import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.global.data.response.GlobalResponse +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -15,7 +19,13 @@ class AdminDistilleryController( ) { @GetMapping - fun getAllDistilleries(): ResponseEntity<*> { - return ResponseEntity.ok(GlobalResponse.ok(distilleryRepository.findAllDistilleries())) + fun getAllDistilleries(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { + val pageable = PageRequest.of( + request.page(), + request.size(), + Sort.by(Sort.Direction.fromString(request.sortOrder().name), "id") + ) + val page = distilleryRepository.findAllDistilleries(request.keyword(), pageable) + return ResponseEntity.ok(GlobalResponse.fromPage(page)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt index ff26d6217..4ae5e1d7c 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt @@ -1,9 +1,13 @@ package app.bottlenote.alcohols.persentaton import app.bottlenote.alcohols.domain.RegionRepository +import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.global.data.response.GlobalResponse +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -15,7 +19,13 @@ class AdminRegionController( ) { @GetMapping - fun getAllRegions(): ResponseEntity<*> { - return ResponseEntity.ok(GlobalResponse.ok(regionRepository.findAllRegions())) + fun getAllRegions(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { + val pageable = PageRequest.of( + request.page(), + request.size(), + Sort.by(Sort.Direction.fromString(request.sortOrder().name), "id") + ) + val page = regionRepository.findAllRegions(request.keyword(), pageable) + return ResponseEntity.ok(GlobalResponse.fromPage(page)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt index 6faa0afb4..d8ab38297 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt @@ -1,9 +1,13 @@ package app.bottlenote.alcohols.persentaton import app.bottlenote.alcohols.domain.TastingTagRepository +import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.global.data.response.GlobalResponse +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -15,7 +19,13 @@ class AdminTastingTagController( ) { @GetMapping - fun getAllTastingTags(): ResponseEntity<*> { - return ResponseEntity.ok(GlobalResponse.ok(tastingTagRepository.findAllTastingTags())) + fun getAllTastingTags(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { + val pageable = PageRequest.of( + request.page(), + request.size(), + Sort.by(Sort.Direction.fromString(request.sortOrder().name), "id") + ) + val page = tastingTagRepository.findAllTastingTags(request.keyword(), pageable) + return ResponseEntity.ok(GlobalResponse.fromPage(page)) } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt index 2cc4c2596..1fdf024df 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt @@ -6,16 +6,22 @@ import app.helper.alcohols.AlcoholsHelper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyString 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.data.domain.Pageable 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.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester @@ -38,13 +44,14 @@ class AdminDistilleryControllerDocsTest { fun getAllDistilleries() { // given val items = AlcoholsHelper.createAdminDistilleryItems(3) + val page = PageImpl(items) - given(distilleryRepository.findAllDistilleries()) - .willReturn(items) + given(distilleryRepository.findAllDistilleries(anyString(), any(Pageable::class.java))) + .willReturn(page) // when & then assertThat( - mvc.get().uri("/distilleries") + mvc.get().uri("/distilleries?keyword=&page=0&size=20&sortOrder=ASC") ) .hasStatusOk() .apply( @@ -52,6 +59,12 @@ class AdminDistilleryControllerDocsTest { "admin/distilleries/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("응답 코드"), @@ -64,6 +77,11 @@ class AdminDistilleryControllerDocsTest { 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(), diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt index 648d458bb..51d3a1071 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt @@ -6,16 +6,22 @@ import app.helper.alcohols.AlcoholsHelper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyString 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.data.domain.Pageable 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.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester @@ -38,13 +44,14 @@ class AdminRegionControllerDocsTest { fun getAllRegions() { // given val items = AlcoholsHelper.createAdminRegionItems(3) + val page = PageImpl(items) - given(regionRepository.findAllRegions()) - .willReturn(items) + given(regionRepository.findAllRegions(anyString(), any(Pageable::class.java))) + .willReturn(page) // when & then assertThat( - mvc.get().uri("/regions") + mvc.get().uri("/regions?keyword=&page=0&size=20&sortOrder=ASC") ) .hasStatusOk() .apply( @@ -52,6 +59,12 @@ class AdminRegionControllerDocsTest { "admin/regions/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("응답 코드"), @@ -65,6 +78,11 @@ class AdminRegionControllerDocsTest { 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(), 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 0c8d6b723..98a4a4c74 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 @@ -6,16 +6,22 @@ import app.helper.alcohols.AlcoholsHelper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyString 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.data.domain.Pageable 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.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester @@ -38,13 +44,14 @@ class AdminTastingTagControllerDocsTest { fun getAllTastingTags() { // given val items = AlcoholsHelper.createAdminTastingTagItems(3) + val page = PageImpl(items) - given(tastingTagRepository.findAllTastingTags()) - .willReturn(items) + given(tastingTagRepository.findAllTastingTags(anyString(), any(Pageable::class.java))) + .willReturn(page) // when & then assertThat( - mvc.get().uri("/tasting-tags") + mvc.get().uri("/tasting-tags?keyword=&page=0&size=20&sortOrder=ASC") ) .hasStatusOk() .apply( @@ -52,6 +59,12 @@ class AdminTastingTagControllerDocsTest { "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("응답 코드"), @@ -65,6 +78,11 @@ class AdminTastingTagControllerDocsTest { 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(), diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt index e5dafed05..3cd4a670e 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt @@ -30,25 +30,25 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { inner class TastingTagsApi { @Test - @DisplayName("전체 테이스팅 태그 목록을 조회할 수 있다") + @DisplayName("전체 테이스팅 태그 목록을 페이지네이션으로 조회할 수 있다") fun getAllTastingTagsSuccess() { // when & then assertThat( - mockMvcTester.get().uri("/tasting-tags") + mockMvcTester.get().uri("/tasting-tags?page=0&size=20") .header("Authorization", "Bearer $accessToken") ) .hasStatusOk() .bodyJson() .extractingPath("$.success").isEqualTo(true) - // 응답이 리스트 형태임을 확인 + // 페이지네이션 메타 정보 확인 assertThat( - mockMvcTester.get().uri("/tasting-tags") + mockMvcTester.get().uri("/tasting-tags?page=0&size=10") .header("Authorization", "Bearer $accessToken") ) .hasStatusOk() .bodyJson() - .extractingPath("$.data").isNotNull + .extractingPath("$.meta.size").isEqualTo(10) } @Test @@ -67,14 +67,14 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { inner class RegionsApi { @Test - @DisplayName("전체 지역 목록을 조회할 수 있다") + @DisplayName("전체 지역 목록을 페이지네이션으로 조회할 수 있다") fun getAllRegionsSuccess() { // given - alcoholTestFactory에서 region 데이터가 함께 생성됨 alcoholTestFactory.persistAlcohols(1) // when & then assertThat( - mockMvcTester.get().uri("/regions") + mockMvcTester.get().uri("/regions?page=0&size=20") .header("Authorization", "Bearer $accessToken") ) .hasStatusOk() @@ -83,19 +83,23 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { } @Test - @DisplayName("지역 목록이 메타 정보를 포함한다") - fun getRegionsWithMetaInfo() { + @DisplayName("지역 목록이 페이지네이션 메타 정보를 포함한다") + fun getRegionsWithPaginationMeta() { // given alcoholTestFactory.persistAlcohols(1) - // when & then - 응답 데이터 확인 - val result = mockMvcTester.get().uri("/regions") + // when & then - 응답 데이터 및 페이지네이션 메타 확인 + val result = mockMvcTester.get().uri("/regions?page=0&size=10") .header("Authorization", "Bearer $accessToken") assertThat(result) .hasStatusOk() .bodyJson() - .extractingPath("$.data").isNotNull + .extractingPath("$.meta.page").isEqualTo(0) + + assertThat(result) + .bodyJson() + .extractingPath("$.meta.totalElements").isNotNull // 방어로직: 인증 없이 요청 시 실패 assertThat( @@ -110,14 +114,14 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { inner class DistilleriesApi { @Test - @DisplayName("전체 증류소 목록을 조회할 수 있다") + @DisplayName("전체 증류소 목록을 페이지네이션으로 조회할 수 있다") fun getAllDistilleriesSuccess() { // given - alcoholTestFactory에서 distillery 데이터가 함께 생성됨 alcoholTestFactory.persistAlcohols(1) // when & then assertThat( - mockMvcTester.get().uri("/distilleries") + mockMvcTester.get().uri("/distilleries?page=0&size=20") .header("Authorization", "Bearer $accessToken") ) .hasStatusOk() @@ -126,13 +130,13 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { } @Test - @DisplayName("증류소 목록이 필수 필드를 포함한다") - fun getDistilleriesWithRequiredFields() { + @DisplayName("증류소 목록을 키워드로 검색할 수 있다") + fun getDistilleriesWithKeyword() { // given alcoholTestFactory.persistAlcohols(1) - // when & then - val result = mockMvcTester.get().uri("/distilleries") + // when & then - 키워드 검색 + val result = mockMvcTester.get().uri("/distilleries?keyword=&page=0&size=20") .header("Authorization", "Bearer $accessToken") assertThat(result) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java index 902f4c8ff..e43d2cd38 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java @@ -1,9 +1,10 @@ package app.bottlenote.alcohols.domain; import app.bottlenote.alcohols.dto.response.AdminDistilleryItem; -import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface DistilleryRepository { - List findAllDistilleries(); + Page findAllDistilleries(String keyword, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java index 796250fdf..276f14b0a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java @@ -3,10 +3,12 @@ import app.bottlenote.alcohols.dto.response.AdminRegionItem; import app.bottlenote.alcohols.dto.response.RegionsItem; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface RegionRepository { List findAllRegionsResponse(); - List findAllRegions(); + Page findAllRegions(String keyword, Pageable pageable); } 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 a28625d5f..34953cfa4 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,10 +2,12 @@ import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface TastingTagRepository { List findAll(); - List findAllTastingTags(); + Page findAllTastingTags(String keyword, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java new file mode 100644 index 000000000..90d05f9fd --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java @@ -0,0 +1,25 @@ +package app.bottlenote.alcohols.dto.request; + +import app.bottlenote.global.service.cursor.SortOrder; +import lombok.Builder; + +/** + * 참조 데이터 (테이스팅 태그, 지역, 증류소) 검색용 공통 Request + * + * @param keyword 검색어 + * @param sortOrder 정렬 방향 + * @param page 페이지 번호 (0부터) + * @param size 페이지 크기 + */ +public record AdminReferenceSearchRequest( + String keyword, + SortOrder sortOrder, + Integer page, + Integer size) { + @Builder + public AdminReferenceSearchRequest { + sortOrder = sortOrder != null ? sortOrder : SortOrder.ASC; + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java index 9b8fe4686..0e701c14a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java @@ -4,9 +4,11 @@ import app.bottlenote.alcohols.domain.DistilleryRepository; import app.bottlenote.alcohols.dto.response.AdminDistilleryItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; -import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; @JpaRepositoryImpl public interface JpaDistilleryRepository @@ -18,7 +20,10 @@ public interface JpaDistilleryRepository select new app.bottlenote.alcohols.dto.response.AdminDistilleryItem( d.id, d.korName, d.engName, d.logoImgPath, d.createAt, d.lastModifyAt ) - from distillery d order by d.id asc + from distillery d + where (:keyword is null or :keyword = '' + or d.korName like concat('%', :keyword, '%') + or d.engName like concat('%', :keyword, '%')) """) - List findAllDistilleries(); + Page findAllDistilleries(@Param("keyword") String keyword, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java index 449652815..ec49e9b62 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java @@ -6,8 +6,11 @@ import app.bottlenote.alcohols.dto.response.RegionsItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; @JpaRepositoryImpl public interface JpaRegionQueryRepository extends RegionRepository, CrudRepository { @@ -26,7 +29,10 @@ public interface JpaRegionQueryRepository extends RegionRepository, CrudReposito select new app.bottlenote.alcohols.dto.response.AdminRegionItem( r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt ) - from region r order by r.id asc + from region r + where (:keyword is null or :keyword = '' + or r.korName like concat('%', :keyword, '%') + or r.engName like concat('%', :keyword, '%')) """) - List findAllRegions(); + Page findAllRegions(@Param("keyword") String keyword, Pageable pageable); } 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 62f4032ad..f1c343aed 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,9 +4,11 @@ import app.bottlenote.alcohols.domain.TastingTagRepository; import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import app.bottlenote.common.annotation.JpaRepositoryImpl; -import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; @JpaRepositoryImpl public interface JpaTastingTagRepository @@ -18,7 +20,10 @@ public interface JpaTastingTagRepository select new app.bottlenote.alcohols.dto.response.AdminTastingTagItem( t.id, t.korName, t.engName, t.icon, t.description, t.createAt, t.lastModifyAt ) - from tasting_tag t order by t.id asc + from tasting_tag t + where (:keyword is null or :keyword = '' + or t.korName like concat('%', :keyword, '%') + or t.engName like concat('%', :keyword, '%')) """) - List findAllTastingTags(); + Page findAllTastingTags(@Param("keyword") String keyword, Pageable pageable); } From 2d3ae91e993b5ad63f0758da4c44e830075d2fb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Jan 2026 16:36:43 +0000 Subject: [PATCH 70/95] chore: apply code formatting [skip ci] --- .../alcohols/dto/request/AdminReferenceSearchRequest.java | 5 +---- .../alcohols/repository/JpaDistilleryRepository.java | 3 ++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java index 90d05f9fd..10b68ed97 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java @@ -12,10 +12,7 @@ * @param size 페이지 크기 */ public record AdminReferenceSearchRequest( - String keyword, - SortOrder sortOrder, - Integer page, - Integer size) { + String keyword, SortOrder sortOrder, Integer page, Integer size) { @Builder public AdminReferenceSearchRequest { sortOrder = sortOrder != null ? sortOrder : SortOrder.ASC; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java index 0e701c14a..16f4d35eb 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaDistilleryRepository.java @@ -25,5 +25,6 @@ public interface JpaDistilleryRepository or d.korName like concat('%', :keyword, '%') or d.engName like concat('%', :keyword, '%')) """) - Page findAllDistilleries(@Param("keyword") String keyword, Pageable pageable); + Page findAllDistilleries( + @Param("keyword") String keyword, Pageable pageable); } From 699c6f5405bbdafaea8cc66a196adad9ae3bed19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:38:47 +0000 Subject: [PATCH 71/95] =?UTF-8?q?fix:=20InMemoryTastingTagRepository?= =?UTF-8?q?=EC=97=90=20=EB=88=84=EB=9D=BD=EB=90=9C=20findAllTastingTags=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TastingTagRepository 인터페이스에 Pagination 메서드 추가 후 InMemory 구현체에 누락된 메서드 구현 --- .../fixture/InMemoryTastingTagRepository.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 ce3837b94..601339a1c 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 @@ -2,9 +2,13 @@ import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.domain.TastingTagRepository; +import app.bottlenote.alcohols.dto.response.AdminTastingTagItem; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; public class InMemoryTastingTagRepository implements TastingTagRepository { @@ -16,6 +20,27 @@ public List findAll() { return List.copyOf(tags); } + @Override + public Page findAllTastingTags(String keyword, Pageable pageable) { + List filtered = tags.stream() + .filter(t -> keyword == null || 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())) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), filtered.size()); + List pageContent = start < filtered.size() + ? filtered.subList(start, end) + : List.of(); + + return new PageImpl<>(pageContent, pageable, filtered.size()); + } + public TastingTag save(TastingTag tag) { Long id = tag.getId(); if (Objects.isNull(id)) { From c26126c8dc63f91dc2de88d0b3d84ef1e3f632d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Jan 2026 16:40:00 +0000 Subject: [PATCH 72/95] chore: apply code formatting [skip ci] --- .../fixture/InMemoryTastingTagRepository.java | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) 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 601339a1c..2f8b44f4c 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 @@ -22,21 +22,30 @@ public List findAll() { @Override public Page findAllTastingTags(String keyword, Pageable pageable) { - List filtered = tags.stream() - .filter(t -> keyword == null || 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())) - .toList(); + List filtered = + tags.stream() + .filter( + t -> + keyword == null + || 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())) + .toList(); int start = (int) pageable.getOffset(); int end = Math.min(start + pageable.getPageSize(), filtered.size()); - List pageContent = start < filtered.size() - ? filtered.subList(start, end) - : List.of(); + List pageContent = + start < filtered.size() ? filtered.subList(start, end) : List.of(); return new PageImpl<>(pageContent, pageable, filtered.size()); } From 207e6e2e21ee63f8e6acb62fbeee620a34bf0f6b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:44:25 +0000 Subject: [PATCH 73/95] =?UTF-8?q?fix:=20CI=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-api에 spring-boot-starter-data-jpa 의존성 추가 (main scope) - InMemoryAlcoholQueryRepository에 누락된 findAdminAlcoholDetailById 메서드 추가 --- bottlenote-admin-api/build.gradle.kts | 3 +++ .../alcohols/fixture/InMemoryAlcoholQueryRepository.java | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/bottlenote-admin-api/build.gradle.kts b/bottlenote-admin-api/build.gradle.kts index ff85cee23..2443255db 100644 --- a/bottlenote-admin-api/build.gradle.kts +++ b/bottlenote-admin-api/build.gradle.kts @@ -18,6 +18,9 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + // Spring Data + implementation(libs.spring.boot.starter.data.jpa) + // Security implementation(libs.spring.boot.starter.security) implementation(libs.spring.security.test) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index dee5d4697..27bfc6ef6 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -9,6 +9,7 @@ import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.CategoryItem; +import app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; @@ -87,4 +88,9 @@ public Pair> getStandardExplore( public Page searchAdminAlcohols(AdminAlcoholSearchRequest request) { return new PageImpl<>(List.of()); } + + @Override + public Optional findAdminAlcoholDetailById(Long alcoholId) { + return Optional.empty(); + } } From e90e1e0b5f359fb2b7c1206fb4a406b9419274ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Jan 2026 16:45:24 +0000 Subject: [PATCH 74/95] chore: apply code formatting [skip ci] --- .../alcohols/fixture/InMemoryAlcoholQueryRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index 27bfc6ef6..53532ec6b 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -9,8 +9,8 @@ import app.bottlenote.alcohols.dto.response.AlcoholDetailItem; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.CategoryItem; -import app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; +import app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; import java.util.HashMap; From 92d5db5131e3bdb3d89742f9e8ac7f62799ccd9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:48:57 +0000 Subject: [PATCH 75/95] =?UTF-8?q?refactor:=20product-api=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-api에서 spring-data-jpa 의존성 제거 (testImplementation만 유지) - AdminReferenceSearchRequest에 toPageable() 메서드 추가 - 컨트롤러에서 PageRequest, Sort import 제거하고 toPageable() 사용 --- bottlenote-admin-api/build.gradle.kts | 3 --- .../alcohols/persentaton/AdminDistilleryController.kt | 9 +-------- .../alcohols/persentaton/AdminRegionController.kt | 9 +-------- .../alcohols/persentaton/AdminTastingTagController.kt | 9 +-------- .../dto/request/AdminReferenceSearchRequest.java | 8 ++++++++ 5 files changed, 11 insertions(+), 27 deletions(-) diff --git a/bottlenote-admin-api/build.gradle.kts b/bottlenote-admin-api/build.gradle.kts index 2443255db..ff85cee23 100644 --- a/bottlenote-admin-api/build.gradle.kts +++ b/bottlenote-admin-api/build.gradle.kts @@ -18,9 +18,6 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") - // Spring Data - implementation(libs.spring.boot.starter.data.jpa) - // Security implementation(libs.spring.boot.starter.security) implementation(libs.spring.security.test) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt index 035b9607d..34c9fae03 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt @@ -3,8 +3,6 @@ package app.bottlenote.alcohols.persentaton import app.bottlenote.alcohols.domain.DistilleryRepository import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.global.data.response.GlobalResponse -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute @@ -20,12 +18,7 @@ class AdminDistilleryController( @GetMapping fun getAllDistilleries(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - val pageable = PageRequest.of( - request.page(), - request.size(), - Sort.by(Sort.Direction.fromString(request.sortOrder().name), "id") - ) - val page = distilleryRepository.findAllDistilleries(request.keyword(), pageable) + val page = distilleryRepository.findAllDistilleries(request.keyword(), request.toPageable()) return ResponseEntity.ok(GlobalResponse.fromPage(page)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt index 4ae5e1d7c..e06a9c7c2 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt @@ -3,8 +3,6 @@ package app.bottlenote.alcohols.persentaton import app.bottlenote.alcohols.domain.RegionRepository import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.global.data.response.GlobalResponse -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute @@ -20,12 +18,7 @@ class AdminRegionController( @GetMapping fun getAllRegions(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - val pageable = PageRequest.of( - request.page(), - request.size(), - Sort.by(Sort.Direction.fromString(request.sortOrder().name), "id") - ) - val page = regionRepository.findAllRegions(request.keyword(), pageable) + val page = regionRepository.findAllRegions(request.keyword(), request.toPageable()) return ResponseEntity.ok(GlobalResponse.fromPage(page)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt index d8ab38297..928aa8dae 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt @@ -3,8 +3,6 @@ package app.bottlenote.alcohols.persentaton import app.bottlenote.alcohols.domain.TastingTagRepository import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.global.data.response.GlobalResponse -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute @@ -20,12 +18,7 @@ class AdminTastingTagController( @GetMapping fun getAllTastingTags(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - val pageable = PageRequest.of( - request.page(), - request.size(), - Sort.by(Sort.Direction.fromString(request.sortOrder().name), "id") - ) - val page = tastingTagRepository.findAllTastingTags(request.keyword(), pageable) + val page = tastingTagRepository.findAllTastingTags(request.keyword(), request.toPageable()) return ResponseEntity.ok(GlobalResponse.fromPage(page)) } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java index 10b68ed97..9cbf4db7f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java @@ -2,6 +2,9 @@ import app.bottlenote.global.service.cursor.SortOrder; import lombok.Builder; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; /** * 참조 데이터 (테이스팅 태그, 지역, 증류소) 검색용 공통 Request @@ -19,4 +22,9 @@ public record AdminReferenceSearchRequest( page = page != null ? page : 0; size = size != null ? size : 20; } + + public Pageable toPageable() { + return PageRequest.of( + page, size, Sort.by(Sort.Direction.fromString(sortOrder.name()), "id")); + } } From c4882a5c9160dc334f47abbd3eaa97321d83a711 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Jan 2026 16:49:53 +0000 Subject: [PATCH 76/95] chore: apply code formatting [skip ci] --- .../alcohols/dto/request/AdminReferenceSearchRequest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java index 9cbf4db7f..2bd360dfc 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminReferenceSearchRequest.java @@ -24,7 +24,6 @@ public record AdminReferenceSearchRequest( } public Pageable toPageable() { - return PageRequest.of( - page, size, Sort.by(Sort.Direction.fromString(sortOrder.name()), "id")); + return PageRequest.of(page, size, Sort.by(Sort.Direction.fromString(sortOrder.name()), "id")); } } From 81010be3396d0198f6d870baadaeb7df1ddd1117 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 20 Jan 2026 02:29:09 +0900 Subject: [PATCH 77/95] =?UTF-8?q?refactor:=20auth=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=EB=AA=85=20persentaton=20=E2=86=92=20presentation=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .../auth/{persentaton => presentation}/AuthController.kt | 2 +- .../src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/{persentaton => presentation}/AuthController.kt (98%) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/presentation/AuthController.kt similarity index 98% rename from bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt rename to bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/presentation/AuthController.kt index c9824ba59..71e79302a 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/persentaton/AuthController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/presentation/AuthController.kt @@ -1,4 +1,4 @@ -package app.bottlenote.auth.persentaton +package app.bottlenote.auth.presentation import app.bottlenote.auth.config.RootAdminProperties import app.bottlenote.global.data.response.GlobalResponse diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt index 61e987371..b1bd14ea1 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/auth/AuthControllerDocsTest.kt @@ -1,7 +1,7 @@ package app.docs.auth import app.bottlenote.auth.config.RootAdminProperties -import app.bottlenote.auth.persentaton.AuthController +import app.bottlenote.auth.presentation.AuthController import app.bottlenote.global.security.SecurityContextUtil import app.bottlenote.user.constant.AdminRole import app.bottlenote.user.dto.request.AdminSignupRequest From 3387920b9f1ebdbede9934e114e4b004d68f7f11 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 20 Jan 2026 02:29:57 +0900 Subject: [PATCH 78/95] =?UTF-8?q?refactor:=20alcohols=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=EB=AA=85=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20Service?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .../AdminAlcoholsController.kt | 4 ++-- .../AdminDistilleryController.kt | 9 ++++--- .../AdminRegionController.kt | 9 ++++--- .../AdminTastingTagController.kt | 9 ++++--- .../AdminAlcoholsControllerDocsTest.kt | 7 ++---- .../AdminDistilleryControllerDocsTest.kt | 15 ++++++------ .../alcohols/AdminRegionControllerDocsTest.kt | 15 ++++++------ .../AdminTastingTagControllerDocsTest.kt | 15 ++++++------ .../service/AlcoholReferenceService.java | 24 +++++++++++++++++++ git.environment-variables | 2 +- 10 files changed, 65 insertions(+), 44 deletions(-) rename bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/{persentaton => presentation}/AdminAlcoholsController.kt (86%) rename bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/{persentaton => presentation}/AdminDistilleryController.kt (66%) rename bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/{persentaton => presentation}/AdminRegionController.kt (67%) rename bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/{persentaton => presentation}/AdminTastingTagController.kt (66%) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminAlcoholsController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt similarity index 86% rename from bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminAlcoholsController.kt rename to bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt index db8a37305..b6bb2a87c 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminAlcoholsController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt @@ -1,4 +1,4 @@ -package app.bottlenote.alcohols.persentaton +package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest import app.bottlenote.alcohols.service.AlcoholQueryService @@ -24,6 +24,6 @@ class AdminAlcoholsController( @GetMapping("/{alcoholId}") fun getAlcoholDetail(@PathVariable alcoholId: Long): ResponseEntity<*> { - return ResponseEntity.ok(GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId))) + return GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminDistilleryController.kt similarity index 66% rename from bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt rename to bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminDistilleryController.kt index 34c9fae03..60dd68818 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminDistilleryController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminDistilleryController.kt @@ -1,7 +1,7 @@ -package app.bottlenote.alcohols.persentaton +package app.bottlenote.alcohols.presentation -import app.bottlenote.alcohols.domain.DistilleryRepository import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.service.AlcoholReferenceService import app.bottlenote.global.data.response.GlobalResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -13,12 +13,11 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/distilleries") class AdminDistilleryController( - private val distilleryRepository: DistilleryRepository + private val alcoholReferenceService: AlcoholReferenceService ) { @GetMapping fun getAllDistilleries(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - val page = distilleryRepository.findAllDistilleries(request.keyword(), request.toPageable()) - return ResponseEntity.ok(GlobalResponse.fromPage(page)) + return ResponseEntity.ok(alcoholReferenceService.findAllDistilleries(request)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminRegionController.kt similarity index 67% rename from bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt rename to bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminRegionController.kt index e06a9c7c2..b67ce25c2 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminRegionController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminRegionController.kt @@ -1,7 +1,7 @@ -package app.bottlenote.alcohols.persentaton +package app.bottlenote.alcohols.presentation -import app.bottlenote.alcohols.domain.RegionRepository import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.service.AlcoholReferenceService import app.bottlenote.global.data.response.GlobalResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -13,12 +13,11 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/regions") class AdminRegionController( - private val regionRepository: RegionRepository + private val alcoholReferenceService: AlcoholReferenceService ) { @GetMapping fun getAllRegions(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - val page = regionRepository.findAllRegions(request.keyword(), request.toPageable()) - return ResponseEntity.ok(GlobalResponse.fromPage(page)) + return ResponseEntity.ok(alcoholReferenceService.findAllRegionsForAdmin(request)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt similarity index 66% rename from bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt rename to bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt index 928aa8dae..a6077d061 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/persentaton/AdminTastingTagController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt @@ -1,7 +1,7 @@ -package app.bottlenote.alcohols.persentaton +package app.bottlenote.alcohols.presentation -import app.bottlenote.alcohols.domain.TastingTagRepository import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.service.AlcoholReferenceService import app.bottlenote.global.data.response.GlobalResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -13,12 +13,11 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/tasting-tags") class AdminTastingTagController( - private val tastingTagRepository: TastingTagRepository + private val alcoholReferenceService: AlcoholReferenceService ) { @GetMapping fun getAllTastingTags(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - val page = tastingTagRepository.findAllTastingTags(request.keyword(), request.toPageable()) - return ResponseEntity.ok(GlobalResponse.fromPage(page)) + return ResponseEntity.ok(alcoholReferenceService.findAllTastingTags(request)) } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index a75ae00bb..f67ae168d 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -3,8 +3,7 @@ package app.docs.alcohols import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest -import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse -import app.bottlenote.alcohols.persentaton.AdminAlcoholsController +import app.bottlenote.alcohols.presentation.AdminAlcoholsController import app.bottlenote.alcohols.service.AlcoholQueryService import app.bottlenote.global.service.cursor.SortOrder import app.helper.alcohols.AlcoholsHelper @@ -23,9 +22,7 @@ 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.pathParameters -import org.springframework.restdocs.request.RequestDocumentation.queryParameters +import org.springframework.restdocs.request.RequestDocumentation.* import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt index 1fdf024df..410ee27a1 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminDistilleryControllerDocsTest.kt @@ -1,20 +1,20 @@ package app.docs.alcohols -import app.bottlenote.alcohols.domain.DistilleryRepository -import app.bottlenote.alcohols.persentaton.AdminDistilleryController +import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.presentation.AdminDistilleryController +import app.bottlenote.alcohols.service.AlcoholReferenceService +import app.bottlenote.global.data.response.GlobalResponse import app.helper.alcohols.AlcoholsHelper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.anyString 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.data.domain.Pageable import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document import org.springframework.restdocs.operation.preprocess.Preprocessors.* import org.springframework.restdocs.payload.JsonFieldType @@ -37,7 +37,7 @@ class AdminDistilleryControllerDocsTest { private lateinit var mvc: MockMvcTester @MockitoBean - private lateinit var distilleryRepository: DistilleryRepository + private lateinit var alcoholReferenceService: AlcoholReferenceService @Test @DisplayName("증류소 목록을 조회할 수 있다") @@ -45,9 +45,10 @@ class AdminDistilleryControllerDocsTest { // given val items = AlcoholsHelper.createAdminDistilleryItems(3) val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) - given(distilleryRepository.findAllDistilleries(anyString(), any(Pageable::class.java))) - .willReturn(page) + given(alcoholReferenceService.findAllDistilleries(any(AdminReferenceSearchRequest::class.java))) + .willReturn(response) // when & then assertThat( diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt index 51d3a1071..442ff0d0d 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt @@ -1,20 +1,20 @@ package app.docs.alcohols -import app.bottlenote.alcohols.domain.RegionRepository -import app.bottlenote.alcohols.persentaton.AdminRegionController +import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.presentation.AdminRegionController +import app.bottlenote.alcohols.service.AlcoholReferenceService +import app.bottlenote.global.data.response.GlobalResponse import app.helper.alcohols.AlcoholsHelper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.anyString 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.data.domain.Pageable import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document import org.springframework.restdocs.operation.preprocess.Preprocessors.* import org.springframework.restdocs.payload.JsonFieldType @@ -37,7 +37,7 @@ class AdminRegionControllerDocsTest { private lateinit var mvc: MockMvcTester @MockitoBean - private lateinit var regionRepository: RegionRepository + private lateinit var alcoholReferenceService: AlcoholReferenceService @Test @DisplayName("지역 목록을 조회할 수 있다") @@ -45,9 +45,10 @@ class AdminRegionControllerDocsTest { // given val items = AlcoholsHelper.createAdminRegionItems(3) val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) - given(regionRepository.findAllRegions(anyString(), any(Pageable::class.java))) - .willReturn(page) + given(alcoholReferenceService.findAllRegionsForAdmin(any(AdminReferenceSearchRequest::class.java))) + .willReturn(response) // when & then assertThat( 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 98a4a4c74..351dbff82 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,20 +1,20 @@ package app.docs.alcohols -import app.bottlenote.alcohols.domain.TastingTagRepository -import app.bottlenote.alcohols.persentaton.AdminTastingTagController +import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest +import app.bottlenote.alcohols.presentation.AdminTastingTagController +import app.bottlenote.alcohols.service.AlcoholReferenceService +import app.bottlenote.global.data.response.GlobalResponse import app.helper.alcohols.AlcoholsHelper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.anyString 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.data.domain.Pageable import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document import org.springframework.restdocs.operation.preprocess.Preprocessors.* import org.springframework.restdocs.payload.JsonFieldType @@ -37,7 +37,7 @@ class AdminTastingTagControllerDocsTest { private lateinit var mvc: MockMvcTester @MockitoBean - private lateinit var tastingTagRepository: TastingTagRepository + private lateinit var alcoholReferenceService: AlcoholReferenceService @Test @DisplayName("테이스팅 태그 목록을 조회할 수 있다") @@ -45,9 +45,10 @@ class AdminTastingTagControllerDocsTest { // given val items = AlcoholsHelper.createAdminTastingTagItems(3) val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) - given(tastingTagRepository.findAllTastingTags(anyString(), any(Pageable::class.java))) - .willReturn(page) + given(alcoholReferenceService.findAllTastingTags(any(AdminReferenceSearchRequest::class.java))) + .willReturn(response) // when & then assertThat( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java index 5ac9cb3cc..855760ed8 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholReferenceService.java @@ -5,12 +5,16 @@ import app.bottlenote.alcohols.constant.AlcoholType; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; import app.bottlenote.alcohols.domain.CurationKeywordRepository; +import app.bottlenote.alcohols.domain.DistilleryRepository; import app.bottlenote.alcohols.domain.RegionRepository; +import app.bottlenote.alcohols.domain.TastingTagRepository; +import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest; import app.bottlenote.alcohols.dto.request.CurationKeywordSearchRequest; import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem; import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.dto.response.CurationKeywordResponse; import app.bottlenote.alcohols.dto.response.RegionsItem; +import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.service.cursor.CursorResponse; import java.util.List; import java.util.Optional; @@ -28,6 +32,8 @@ public class AlcoholReferenceService { private final RegionRepository regionQueryRepository; private final AlcoholQueryRepository alcoholQueryRepository; private final CurationKeywordRepository curationKeywordRepository; + private final DistilleryRepository distilleryRepository; + private final TastingTagRepository tastingTagRepository; @Cacheable(value = "local_cache_alcohol_region_information") @Transactional(readOnly = true) @@ -64,4 +70,22 @@ public Optional> getCurationAlcoholIds(String keyword) { public Optional> getCurationAlcoholIds(Long curationId) { return curationKeywordRepository.findById(curationId).map(curation -> curation.getAlcoholIds()); } + + @Transactional(readOnly = true) + public GlobalResponse findAllRegionsForAdmin(AdminReferenceSearchRequest request) { + return GlobalResponse.fromPage( + regionQueryRepository.findAllRegions(request.keyword(), request.toPageable())); + } + + @Transactional(readOnly = true) + public GlobalResponse findAllDistilleries(AdminReferenceSearchRequest request) { + return GlobalResponse.fromPage( + distilleryRepository.findAllDistilleries(request.keyword(), request.toPageable())); + } + + @Transactional(readOnly = true) + public GlobalResponse findAllTastingTags(AdminReferenceSearchRequest request) { + return GlobalResponse.fromPage( + tastingTagRepository.findAllTastingTags(request.keyword(), request.toPageable())); + } } diff --git a/git.environment-variables b/git.environment-variables index 416ba9202..daab1fbf4 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 416ba92027f741481ff86cbece1c3b6b397e13a2 +Subproject commit daab1fbf413663320cd37ed183abdb80b7efe0a5 From 2cf048ebcd84f9fe6e188bcc61fba4ba3f496c63 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 20 Jan 2026 02:38:30 +0900 Subject: [PATCH 79/95] =?UTF-8?q?fix:=20InMemoryAlcoholQueryRepository?= =?UTF-8?q?=EC=97=90=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .../alcohols/fixture/InMemoryAlcoholQueryRepository.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index 62b62f096..ede921a9f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -10,6 +10,7 @@ import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; import app.bottlenote.alcohols.dto.response.CategoryItem; import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; +import app.bottlenote.alcohols.repository.CustomAlcoholQueryRepository.AdminAlcoholDetailProjection; import app.bottlenote.global.service.cursor.CursorResponse; import app.bottlenote.global.service.cursor.PageResponse; import java.util.HashMap; @@ -78,4 +79,9 @@ public Pair> getStandardExplore( public Page searchAdminAlcohols(AdminAlcoholSearchRequest request) { return Page.empty(); } + + @Override + public Optional findAdminAlcoholDetailById(Long alcoholId) { + return Optional.empty(); + } } From 09fbc3b42b8c42ddffd8cb6731480900e3a71445 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 20 Jan 2026 02:49:33 +0900 Subject: [PATCH 80/95] =?UTF-8?q?docs:=20=EC=9C=84=EC=8A=A4=ED=82=A4=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=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 - 현재 병목 지점 분석 (LIKE 패턴, EXISTS 서브쿼리) - K6 성능 테스트 계획 수립 - 집계 테이블 + 검색 키워드 비정규화 방안 설계 - 배치 Job 설계 및 구현 단계 정리 Co-Authored-By: Claude Opus 4.5 --- .../alcohol-search-performance-improvement.md | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/plans/alcohol-search-performance-improvement.md diff --git a/docs/plans/alcohol-search-performance-improvement.md b/docs/plans/alcohol-search-performance-improvement.md new file mode 100644 index 000000000..f735c4059 --- /dev/null +++ b/docs/plans/alcohol-search-performance-improvement.md @@ -0,0 +1,232 @@ +# 위스키 검색 API 성능 개선 계획 + +## 1. 개요 + +### 대상 API +- **Endpoint**: `GET /api/v1/alcohols/search` +- **문제**: 키워드 검색 시 느린 응답 속도 + +### 현재 병목 지점 분석 + +| 병목 | 심각도 | 원인 | +|------|--------|------| +| `LIKE '%keyword%'` | ⭐⭐⭐⭐⭐ | Full Table Scan, 인덱스 무용 | +| 유연한 패턴 `'%맥%캘%란%'` | ⭐⭐⭐⭐⭐ | 최악의 LIKE 패턴 | +| 테이스팅 태그 EXISTS 서브쿼리 | ⭐⭐⭐⭐ | 매 row마다 서브쿼리 실행 | +| JOIN 3개 (rating, picks, review) | ⭐⭐ | 집계 함수 실시간 계산 | + +--- + +## 2. 성능 측정 계획 (K6) + +### 2.1 테스트 환경 + +``` +k6-tests/ +├── scripts/ +│ └── alcohol-search-test.js +├── data/ +│ └── search-keywords.json +└── reports/ + ├── baseline/ # 개선 전 + └── improved/ # 개선 후 +``` + +### 2.2 테스트 시나리오 + +| 시나리오 | 설명 | 예시 | +|----------|------|------| +| 단일 키워드 | 한 단어 검색 | `?keyword=맥캘란` | +| 다중 키워드 | 여러 단어 검색 | `?keyword=스모키 위스키` | +| 복합 필터 | 키워드 + 필터 조합 | `?keyword=맥캘란&category=SINGLE_MALT&sortType=POPULAR` | +| 페이징 | 커서 기반 페이징 | `?keyword=위스키&cursor=20&pageSize=10` | +| 유연한 패턴 | 띄어쓰기 포함 | `?keyword=맥 캘 란` | + +### 2.3 부하 단계 + +| 단계 | VUs | Duration | 목적 | +|------|-----|----------|------| +| Smoke | 1 | 30s | 기본 동작 확인 | +| Load | 10 | 2m | 일반 부하 | +| Stress | 50 | 3m | 스트레스 | +| Spike | 100 | 1m | 급격한 부하 | + +### 2.4 측정 지표 + +| 지표 | 목표 | +|------|------| +| p95 응답 시간 | < 500ms | +| p99 응답 시간 | < 1000ms | +| 실패율 | < 1% | +| RPS | > 100 | + +### 2.5 테스트 키워드 목록 + +```json +{ + "single_keywords": ["맥캘란", "글렌피딕", "발베니", "라프로익", "Macallan"], + "multi_keywords": ["스모키 위스키", "셰리 캐스크", "싱글몰트 스코틀랜드"], + "flexible_patterns": ["맥 캘 란", "글렌 피딕"] +} +``` + +### 2.6 실행 명령어 + +```bash +# Baseline 측정 +k6 run --env BASE_URL=http://localhost:8080 scripts/alcohol-search-test.js + +# 결과 저장 +k6 run --out json=reports/baseline/result.json scripts/alcohol-search-test.js +``` + +--- + +## 3. 개선 방안: 집계 테이블 + 검색 키워드 비정규화 + +### 3.1 핵심 아이디어 + +1. **집계 데이터 비정규화**: rating, review, pick count를 미리 계산하여 저장 +2. **검색 키워드 통합**: 모든 검색 대상 필드를 하나의 컬럼에 통합 + +### 3.2 테이블 설계 + +```sql +CREATE TABLE alcohol_statistics ( + alcohol_id BIGINT PRIMARY KEY, + + -- 집계 데이터 + avg_rating DECIMAL(3,2) DEFAULT 0.00, + rating_count BIGINT DEFAULT 0, + review_count BIGINT DEFAULT 0, + pick_count BIGINT DEFAULT 0, + popular_score DECIMAL(10,2) DEFAULT 0.00, + + -- 검색용 비정규화 컬럼 + search_keywords TEXT NOT NULL, + + -- 메타 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 인덱스 + INDEX idx_popular_score (popular_score DESC), + INDEX idx_avg_rating (avg_rating DESC), + FULLTEXT INDEX ft_search_keywords (search_keywords) WITH PARSER ngram +); +``` + +### 3.3 search_keywords 컬럼 구성 + +``` +{한글이름}|{영문이름}|{한글카테고리}|{영문카테고리}|{지역}|{증류소}|{테이스팅태그들} +``` + +**예시**: +``` +맥캘란 12년|Macallan 12 Years|싱글몰트|Single Malt|스페이사이드|Speyside|셰리|바닐라|꿀 +``` + +### 3.4 쿼리 변화 + +**Before**: +```sql +SELECT a.*, AVG(r.rating), COUNT(...) +FROM alcohols a +LEFT JOIN ratings r ON ... +LEFT JOIN picks p ON ... +LEFT JOIN reviews rv ON ... +WHERE a.kor_name LIKE '%맥캘란%' + OR a.eng_name LIKE '%...' + OR EXISTS (SELECT 1 FROM tasting_tags ...) +GROUP BY a.id; +``` + +**After**: +```sql +SELECT a.*, s.avg_rating, s.rating_count, s.pick_count, s.review_count +FROM alcohols a +JOIN alcohol_statistics s ON a.id = s.alcohol_id +WHERE MATCH(s.search_keywords) AGAINST('맥캘란' IN BOOLEAN MODE) +ORDER BY s.popular_score DESC; +``` + +### 3.5 예상 개선 효과 + +| 항목 | Before | After | +|------|--------|-------| +| JOIN 수 | 3개 | 1개 | +| WHERE 조건 | LIKE 다중 + EXISTS | FULLTEXT 1개 | +| GROUP BY | 필요 | 불필요 | +| 인덱스 활용 | 불가 | FULLTEXT | + +--- + +## 4. 배치 Job 설계 + +### 4.1 위치 + +``` +bottlenote-batch/ +└── job/statistics/ + └── AlcoholStatisticsJobConfig.java +``` + +### 4.2 실행 주기 + +- **권장**: 1시간마다 (`0 0 * * * ?`) + +### 4.3 처리 흐름 + +``` +[Reader] 모든 alcohol 조회 + ↓ +[Processor] 각 alcohol별 통계 계산 + 검색 키워드 생성 + ↓ +[Writer] alcohol_statistics 테이블 UPSERT +``` + +--- + +## 5. 구현 단계 + +### Phase 1: 성능 측정 (Baseline) +- [ ] k6 테스트 스크립트 작성 +- [ ] 현재 성능 측정 및 기록 + +### Phase 2: 테이블 및 엔티티 +- [ ] DDL 작성 및 테이블 생성 +- [ ] `AlcoholStatistics` 엔티티 (mono 모듈) +- [ ] Repository 생성 + +### Phase 3: 배치 Job +- [ ] `AlcoholStatisticsJobConfig` (batch 모듈) +- [ ] Quartz 스케줄 등록 +- [ ] 배치 테스트 + +### Phase 4: 검색 쿼리 수정 +- [ ] `CustomAlcoholQueryRepositoryImpl` 수정 +- [ ] 기존 테스트 통과 확인 + +### Phase 5: 성능 측정 (After) +- [ ] k6 테스트 재실행 +- [ ] Before/After 비교 + +--- + +## 6. 롤백 계획 + +Feature Flag로 기존 로직과 신규 로직 전환 가능: + +```yaml +feature: + alcohol-search: + use-statistics-table: false # true로 변경 시 새 로직 적용 +``` + +--- + +## 7. 참고 자료 + +- [MySQL FULLTEXT ngram parser](https://dev.mysql.com/doc/refman/8.0/en/fulltext-search-ngram.html) +- [K6 Documentation](https://k6.io/docs/) From fa70b26b8885b0cbee677ede02edc12e90eec24e Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 20 Jan 2026 10:43:55 +0900 Subject: [PATCH 81/95] =?UTF-8?q?docs:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B4=80=EB=A0=A8=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...plication.java => ProductApplication.java} | 4 +-- git.environment-variables | 2 +- .../complete/facade-coverage.md | 22 ++++++++++++++++ .../integration-test-refactor.md | 0 .../complete}/migration.v1.0.1.md | 16 ++++++++++++ .../complete}/migration.v1.0.2.md | 16 ++++++++++++ .../complete}/migration.v1.0.3.md | 16 ++++++++++++ .../complete}/migration.v1.0.4.md | 16 ++++++++++++ .../complete}/migration.v1.0.5.md | 16 ++++++++++++ .../complete}/migration.v2.0.0.md | 23 ++++++++++++++++ .../complete}/migration.v2.0.1.md | 26 +++++++++++++++++++ .../production-gitops-migration.md | 0 .../{ => complete}/testfactory-improvement.md | 0 plan/{stamp-template.md => stamp-template.st} | 0 .../test-core-service.md | 0 .../test-coverage-overview.md | 0 .../test-security-auth.md | 0 17 files changed, 154 insertions(+), 3 deletions(-) rename bottlenote-product-api/src/main/java/app/{ProdcutApplication.java => ProductApplication.java} (81%) rename "plan/coverage/1-\355\215\274\354\202\254\353\223\234-\354\273\244\353\262\204\353\246\254\354\247\200.md" => plan/complete/facade-coverage.md (94%) rename plan/{ => complete}/integration-test-refactor.md (100%) rename {migration => plan/complete}/migration.v1.0.1.md (96%) rename {migration => plan/complete}/migration.v1.0.2.md (92%) rename {migration => plan/complete}/migration.v1.0.3.md (92%) rename {migration => plan/complete}/migration.v1.0.4.md (88%) rename {migration => plan/complete}/migration.v1.0.5.md (94%) rename {migration => plan/complete}/migration.v2.0.0.md (87%) rename {migration => plan/complete}/migration.v2.0.1.md (92%) rename plan/{ => complete}/production-gitops-migration.md (100%) rename plan/{ => complete}/testfactory-improvement.md (100%) rename plan/{stamp-template.md => stamp-template.st} (100%) rename "plan/coverage/3-\355\225\265\354\213\254-Service-\355\205\214\354\212\244\355\212\270-\354\266\224\352\260\200.md" => plan/test-core-service.md (100%) rename "plan/coverage/0-\352\260\234\354\232\224.md" => plan/test-coverage-overview.md (100%) rename "plan/coverage/2-\353\263\264\354\225\210\354\235\270\354\246\235-\355\205\214\354\212\244\355\212\270-\354\266\224\352\260\200.md" => plan/test-security-auth.md (100%) diff --git a/bottlenote-product-api/src/main/java/app/ProdcutApplication.java b/bottlenote-product-api/src/main/java/app/ProductApplication.java similarity index 81% rename from bottlenote-product-api/src/main/java/app/ProdcutApplication.java rename to bottlenote-product-api/src/main/java/app/ProductApplication.java index c6f37b230..4d75d1198 100644 --- a/bottlenote-product-api/src/main/java/app/ProdcutApplication.java +++ b/bottlenote-product-api/src/main/java/app/ProductApplication.java @@ -7,9 +7,9 @@ @EntityScan(basePackages = "app") @SpringBootApplication(scanBasePackages = "app") -public class ProdcutApplication { +public class ProductApplication { public static void main(String[] args) { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); - SpringApplication.run(ProdcutApplication.class, args); + SpringApplication.run(ProductApplication.class, args); } } diff --git a/git.environment-variables b/git.environment-variables index daab1fbf4..162e3ce0f 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit daab1fbf413663320cd37ed183abdb80b7efe0a5 +Subproject commit 162e3ce0f30e7ccfcbdb348c50549272d0c92917 diff --git "a/plan/coverage/1-\355\215\274\354\202\254\353\223\234-\354\273\244\353\262\204\353\246\254\354\247\200.md" b/plan/complete/facade-coverage.md similarity index 94% rename from "plan/coverage/1-\355\215\274\354\202\254\353\223\234-\354\273\244\353\262\204\353\246\254\354\247\200.md" rename to plan/complete/facade-coverage.md index aff024879..b0d16066d 100644 --- "a/plan/coverage/1-\355\215\274\354\202\254\353\223\234-\354\273\244\353\262\204\353\246\254\354\247\200.md" +++ b/plan/complete/facade-coverage.md @@ -378,3 +378,25 @@ Facade 계층은 프로젝트에서 비교적 최근에 도입된 패턴입니 - [x] DefaultReviewFacadeTest.java 생성 완료 (8개 테스트) - [x] DefaultAlcoholFacadeTest.java 생성 완료 (7개 테스트) - [x] FollowServiceTest.java 생성 완료 (7개 테스트) + +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-01-20 + +** Core Achievements ** +- Facade 계층 테스트 커버리지 0% → 100% 달성 (목표 85% 초과) +- 4개 Facade 전체 단위 테스트 완료 (30개 테스트) +- InMemory Repository 패턴 정립 + +** Key Components ** +- DefaultUserFacadeTest.java (8개 테스트) +- DefaultReviewFacadeTest.java (8개 테스트) +- DefaultAlcoholFacadeTest.java (7개 테스트) +- FollowServiceTest.java (7개 테스트) +- InMemoryFollowRepository 신규 생성 + +** Deferred Items ** +- 없음 (모든 체크리스트 완료) +================================================================================ diff --git a/plan/integration-test-refactor.md b/plan/complete/integration-test-refactor.md similarity index 100% rename from plan/integration-test-refactor.md rename to plan/complete/integration-test-refactor.md diff --git a/migration/migration.v1.0.1.md b/plan/complete/migration.v1.0.1.md similarity index 96% rename from migration/migration.v1.0.1.md rename to plan/complete/migration.v1.0.1.md index af62e142e..ffc4acfb6 100644 --- a/migration/migration.v1.0.1.md +++ b/plan/complete/migration.v1.0.1.md @@ -1,3 +1,19 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **DEPRECATED** +Deprecated Date: 2025-09-28 + +** Reason ** +- v2.0.0에서 전략 변경: Bottom-up → Top-down 최소 분리 전략 +- 순환 의존성 문제로 클린 아키텍처 기반 분리 방식 포기 + +** Superseded By ** +- migration.v2.0.0.md (최소 웹 계층 분리 전략) +================================================================================ +``` + # 멀티모듈 마이그레이션 가이드 ## 🎯 목표 아키텍처 diff --git a/migration/migration.v1.0.2.md b/plan/complete/migration.v1.0.2.md similarity index 92% rename from migration/migration.v1.0.2.md rename to plan/complete/migration.v1.0.2.md index a8216319d..785886344 100644 --- a/migration/migration.v1.0.2.md +++ b/plan/complete/migration.v1.0.2.md @@ -1,3 +1,19 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **DEPRECATED** +Deprecated Date: 2025-09-28 + +** Reason ** +- v2.0.0에서 전략 변경: Bottom-up → Top-down 최소 분리 전략 +- 순환 의존성 문제로 6모듈 구조(legacy, shared, domain, infrastructure, api, admin) 포기 + +** Superseded By ** +- migration.v2.0.0.md (최소 웹 계층 분리 전략) +================================================================================ +``` + # 멀티모듈 마이그레이션 가이드 v2 ## 🎯 목표 diff --git a/migration/migration.v1.0.3.md b/plan/complete/migration.v1.0.3.md similarity index 92% rename from migration/migration.v1.0.3.md rename to plan/complete/migration.v1.0.3.md index 64ac6e79e..503d284c4 100644 --- a/migration/migration.v1.0.3.md +++ b/plan/complete/migration.v1.0.3.md @@ -1,3 +1,19 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **DEPRECATED** +Deprecated Date: 2025-09-28 + +** Reason ** +- v2.0.0에서 전략 변경: Bottom-up → Top-down 최소 분리 전략 +- 순환 의존성 문제로 shared/core/infrastructure 분리 방식 포기 + +** Superseded By ** +- migration.v2.0.0.md (최소 웹 계층 분리 전략) +================================================================================ +``` + # 멀티모듈 마이그레이션 가이드 v3 ## 🎯 목표 diff --git a/migration/migration.v1.0.4.md b/plan/complete/migration.v1.0.4.md similarity index 88% rename from migration/migration.v1.0.4.md rename to plan/complete/migration.v1.0.4.md index 8236f7f65..a7421e1c1 100644 --- a/migration/migration.v1.0.4.md +++ b/plan/complete/migration.v1.0.4.md @@ -1,3 +1,19 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **DEPRECATED** +Deprecated Date: 2025-09-28 + +** Reason ** +- v2.0.0에서 전략 변경으로 bottlenote-shared 모듈 계획 폐기 +- DTO를 mono에 유지하는 방식으로 순환 의존성 해결 + +** Superseded By ** +- migration.v2.0.0.md (최소 웹 계층 분리 전략) +================================================================================ +``` + # Shared 모듈 마이그레이션 현황 ## 🎯 Shared 모듈 원칙 diff --git a/migration/migration.v1.0.5.md b/plan/complete/migration.v1.0.5.md similarity index 94% rename from migration/migration.v1.0.5.md rename to plan/complete/migration.v1.0.5.md index 15c4bf2a9..8fbac0e54 100644 --- a/migration/migration.v1.0.5.md +++ b/plan/complete/migration.v1.0.5.md @@ -1,3 +1,19 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **DEPRECATED** +Deprecated Date: 2025-09-28 + +** Reason ** +- v2.0.0에서 전략 변경으로 bottlenote-core 모듈 계획 폐기 +- 비즈니스 로직을 mono에 유지하는 방식 채택 + +** Superseded By ** +- migration.v2.0.0.md (최소 웹 계층 분리 전략) +================================================================================ +``` + # Core 모듈 마이그레이션 현황 ## 🎯 Core 모듈 원칙 diff --git a/migration/migration.v2.0.0.md b/plan/complete/migration.v2.0.0.md similarity index 87% rename from migration/migration.v2.0.0.md rename to plan/complete/migration.v2.0.0.md index 124b7cbdb..0cf97cf6b 100644 --- a/migration/migration.v2.0.0.md +++ b/plan/complete/migration.v2.0.0.md @@ -1,3 +1,26 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2025-10-09 + +** Core Achievements ** +- Top-down 최소 분리 전략으로 순환 의존성 문제 해결 +- bottlenote-product-api 모듈 생성 (웹 진입점) +- bottlenote-mono 모듈 생성 (비즈니스 로직) +- Dockerfile, docker-compose, CI/CD 파이프라인 구축 + +** Key Components ** +- bottlenote-product-api: SecurityConfig, 웹 필터 +- bottlenote-mono: DTO, Service, Facade, Repository, Exception + +** Key Principle ** +- 단방향 의존성 확보: product-api → mono +- DTO는 mono에 유지 (순환 의존 방지) +================================================================================ +``` + # 모듈 마이그레이션 v2 - 최소 웹 계층 분리 전략 ## 📋 배경 및 문제점 diff --git a/migration/migration.v2.0.1.md b/plan/complete/migration.v2.0.1.md similarity index 92% rename from migration/migration.v2.0.1.md rename to plan/complete/migration.v2.0.1.md index 2b697ed93..6ac3155de 100644 --- a/migration/migration.v2.0.1.md +++ b/plan/complete/migration.v2.0.1.md @@ -1,3 +1,29 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-01-20 + +** Core Achievements ** +- 컨트롤러 26개 product-api로 이동 완료 +- mono 모듈에서 컨트롤러 완전 제거 +- ProductApplication 오타 수정 완료 +- Admin API 개발 완료 (Kotlin 기반, 11개+ 컨트롤러) + +** Key Components ** +- bottlenote-product-api/src/main/java/app/bottlenote/*/controller/: 26개 컨트롤러 +- bottlenote-admin-api/src/main/kotlin/app/bottlenote/*/presentation/: 11개+ 컨트롤러 + +** Final Module Structure ** +- bottlenote-product-api: 사용자 API (컨트롤러 26개) +- bottlenote-admin-api: 관리자 API (Kotlin, 컨트롤러 11개+) +- bottlenote-mono: 비즈니스 로직 (컨트롤러 없음) +- bottlenote-batch: 배치 작업 +- bottlenote-observability: 모니터링 +================================================================================ +``` + # 모듈 마이그레이션 v2.0.1 - 컨트롤러 분리 ## 배경 diff --git a/plan/production-gitops-migration.md b/plan/complete/production-gitops-migration.md similarity index 100% rename from plan/production-gitops-migration.md rename to plan/complete/production-gitops-migration.md diff --git a/plan/testfactory-improvement.md b/plan/complete/testfactory-improvement.md similarity index 100% rename from plan/testfactory-improvement.md rename to plan/complete/testfactory-improvement.md diff --git a/plan/stamp-template.md b/plan/stamp-template.st similarity index 100% rename from plan/stamp-template.md rename to plan/stamp-template.st diff --git "a/plan/coverage/3-\355\225\265\354\213\254-Service-\355\205\214\354\212\244\355\212\270-\354\266\224\352\260\200.md" b/plan/test-core-service.md similarity index 100% rename from "plan/coverage/3-\355\225\265\354\213\254-Service-\355\205\214\354\212\244\355\212\270-\354\266\224\352\260\200.md" rename to plan/test-core-service.md diff --git "a/plan/coverage/0-\352\260\234\354\232\224.md" b/plan/test-coverage-overview.md similarity index 100% rename from "plan/coverage/0-\352\260\234\354\232\224.md" rename to plan/test-coverage-overview.md diff --git "a/plan/coverage/2-\353\263\264\354\225\210\354\235\270\354\246\235-\355\205\214\354\212\244\355\212\270-\354\266\224\352\260\200.md" b/plan/test-security-auth.md similarity index 100% rename from "plan/coverage/2-\353\263\264\354\225\210\354\235\270\354\246\235-\355\205\214\354\212\244\355\212\270-\354\266\224\352\260\200.md" rename to plan/test-security-auth.md From 4f66c99795bbb3769986da503a1a06f087d29735 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 20 Jan 2026 12:25:35 +0900 Subject: [PATCH 82/95] =?UTF-8?q?docs:=20Admin=20API=20HTTP=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=20=EB=AC=B8=EC=84=9C=20=EC=B4=88=EC=95=88=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 - 고객 지원 문의 관리 API 스펙 정의 - 위스키 및 제조사/지역/태그 관리 API 스펙 정의 - S3 PreSigned URL 발급 API 스펙 정의 --- ...0\354\235\230\352\264\200\353\246\254.http" | 18 ++++++++++++++++++ .../\354\234\204\354\212\244\355\202\244.http" | 8 ++++++++ ...\354\227\255_\355\203\234\352\267\270.http" | 11 +++++++++++ ...4\354\227\205\353\241\234\353\223\234.http" | 3 +++ 4 files changed, 40 insertions(+) create mode 100644 "http/admin/02_\352\263\240\352\260\235\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254.http" create mode 100644 "http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" create mode 100644 "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" create mode 100644 "http/admin/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" diff --git "a/http/admin/02_\352\263\240\352\260\235\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254.http" "b/http/admin/02_\352\263\240\352\260\235\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254.http" new file mode 100644 index 000000000..0af07adca --- /dev/null +++ "b/http/admin/02_\352\263\240\352\260\235\354\247\200\354\233\220/\353\254\270\354\235\230\352\264\200\353\246\254.http" @@ -0,0 +1,18 @@ +### 문의 목록 조회 +GET {{host}}/helps?cursor=0&pageSize=10 +Authorization: Bearer {{accessToken}} + +### 문의 상세 조회 +@helpId = 1 +GET {{host}}/helps/{{helpId}} +Authorization: Bearer {{accessToken}} + +### 문의 답변 등록 +@helpId = 1 +POST {{host}}/helps/{{helpId}}/answer +Authorization: Bearer {{accessToken}} +Content-Type: application/json + +{ + "content": "문의 주신 내용에 대해 답변 드립니다." +} diff --git "a/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" new file mode 100644 index 000000000..18ba5f086 --- /dev/null +++ "b/http/admin/03_\354\225\214\354\275\224\354\230\254\352\264\200\353\246\254/\354\234\204\354\212\244\355\202\244.http" @@ -0,0 +1,8 @@ +### 위스키 목록 검색 +GET {{host}}/alcohols?keyword=&cursor=0&pageSize=10 +Authorization: Bearer {{accessToken}} + +### 위스키 상세 조회 +@alcoholId = 1 +GET {{host}}/alcohols/{{alcoholId}} +Authorization: Bearer {{accessToken}} 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_\355\203\234\352\267\270.http" new file mode 100644 index 000000000..8852a55cb --- /dev/null +++ "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_\355\203\234\352\267\270.http" @@ -0,0 +1,11 @@ +### 제조사 목록 조회 +GET {{host}}/distilleries?keyword=&cursor=0&pageSize=10 +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/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" "b/http/admin/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" new file mode 100644 index 000000000..8f2d51f3e --- /dev/null +++ "b/http/admin/99_\352\263\265\355\206\265/\355\214\214\354\235\274\354\227\205\353\241\234\353\223\234.http" @@ -0,0 +1,3 @@ +### S3 PreSigned URL 발급 +GET {{host}}/s3/presign-url?rootPath=admin&uploadSize=1 +Authorization: Bearer {{accessToken}} From e768e969959ee9c1255e99b94375bbda00b9f83e Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 20 Jan 2026 22:34:19 +0900 Subject: [PATCH 83/95] =?UTF-8?q?feat:=20Admin=20Alcohol=20CUD=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=A0=88?= =?UTF-8?q?=ED=8D=BC=EB=9F=B0=EC=8A=A4=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어드민용 위스키 생성/수정/삭제(소프트) API 추가 - 카테고리 레퍼런스 조회 API 추가 (kor/eng 카테고리 페어 목록) - Alcohol 엔티티에 deletedAt 필드 및 update/delete 메서드 추가 - AdminAlcoholCommandService, AdminAlcoholUpsertRequest DTO 신규 생성 - AdminResultResponse 공통 응답 DTO 추가 - 삭제 시 리뷰/평점 존재 여부 검증 로직 포함 - REST Docs 테스트 및 통합 테스트 추가 Co-Authored-By: Claude Opus 4.5 --- .../asciidoc/api/admin-alcohols/alcohols.adoc | 110 +++++++ .../presentation/AdminAlcoholsController.kt | 35 ++- .../AdminAlcoholsControllerDocsTest.kt | 225 +++++++++++++- .../app/helper/alcohols/AlcoholsHelper.kt | 40 +++ .../alcohols/AdminAlcoholsIntegrationTest.kt | 286 ++++++++++++++++++ .../bottlenote/alcohols/domain/Alcohol.java | 44 +++ .../domain/AlcoholQueryRepository.java | 2 + .../alcohols/domain/DistilleryRepository.java | 3 + .../alcohols/domain/RegionRepository.java | 3 + .../request/AdminAlcoholUpsertRequest.java | 22 ++ .../exception/AlcoholExceptionCode.java | 7 +- .../CustomAlcoholQueryRepository.java | 2 + .../CustomAlcoholQueryRepositoryImpl.java | 14 + .../service/AdminAlcoholCommandService.java | 172 +++++++++++ .../alcohols/service/AlcoholQueryService.java | 5 + .../dto/response/AdminResultResponse.java | 27 ++ .../rating/domain/RatingRepository.java | 2 + .../repository/JpaRatingRepository.java | 5 + .../review/domain/ReviewRepository.java | 2 + .../repository/JpaReviewRepository.java | 5 + .../alcohols/fixture/AlcoholTestFactory.java | 27 ++ .../InMemoryAlcoholQueryRepository.java | 8 + .../fixture/InMemoryReviewRepository.java | 5 + .../fixture/InMemoryRatingRepository.java | 6 + .../fixture/InMemoryReviewRepository.java | 5 + 25 files changed, 1058 insertions(+), 4 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc index 933bf581c..37b6feb38 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc @@ -49,3 +49,113 @@ include::{snippets}/admin/alcohols/detail/http-request.adoc[] [discrete] include::{snippets}/admin/alcohols/detail/response-fields.adoc[] include::{snippets}/admin/alcohols/detail/http-response.adoc[] + +''' + +=== 술(Alcohol) 생성 === + +- 관리자용 술 생성 API입니다. +- 모든 필드는 필수값입니다. + +[source] +---- +POST /admin/api/v1/alcohols +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/create/request-fields.adoc[] +include::{snippets}/admin/alcohols/create/curl-request.adoc[] +include::{snippets}/admin/alcohols/create/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/create/response-fields.adoc[] +include::{snippets}/admin/alcohols/create/http-response.adoc[] + +''' + +=== 술(Alcohol) 수정 === + +- 관리자용 술 수정 API입니다. +- 전체 수정(PUT)이므로 모든 필드를 전달해야 합니다. +- 이미 삭제된 술은 수정할 수 없습니다. + +[source] +---- +PUT /admin/api/v1/alcohols/{alcoholId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/update/path-parameters.adoc[] + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/update/request-fields.adoc[] +include::{snippets}/admin/alcohols/update/curl-request.adoc[] +include::{snippets}/admin/alcohols/update/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/update/response-fields.adoc[] +include::{snippets}/admin/alcohols/update/http-response.adoc[] + +''' + +=== 술(Alcohol) 삭제 === + +- 관리자용 술 삭제 API입니다. +- 소프트 삭제로 처리되며, 실제 데이터는 삭제되지 않습니다. +- 리뷰 또는 평점이 존재하는 술은 삭제할 수 없습니다. +- 이미 삭제된 술은 재삭제할 수 없습니다. + +[source] +---- +DELETE /admin/api/v1/alcohols/{alcoholId} +---- + +[discrete] +==== 경로 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/delete/path-parameters.adoc[] +include::{snippets}/admin/alcohols/delete/curl-request.adoc[] +include::{snippets}/admin/alcohols/delete/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/delete/response-fields.adoc[] +include::{snippets}/admin/alcohols/delete/http-response.adoc[] + +''' + +=== 카테고리 레퍼런스 조회 === + +- 현재 DB에 등록된 모든 카테고리 페어(한글/영문) 목록을 조회합니다. +- 술 생성/수정 시 기존 카테고리를 참조하기 위한 API입니다. +- 동일 한글 카테고리라도 영문 카테고리가 다르면 별도 항목으로 조회됩니다. + +[source] +---- +GET /admin/api/v1/alcohols/categories/reference +---- + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/alcohols/category-reference/response-fields.adoc[] +include::{snippets}/admin/alcohols/category-reference/http-response.adoc[] diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt index b6bb2a87c..b890f5a22 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt @@ -1,12 +1,19 @@ package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest +import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest +import app.bottlenote.alcohols.service.AdminAlcoholCommandService import app.bottlenote.alcohols.service.AlcoholQueryService 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 @@ -14,7 +21,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/alcohols") class AdminAlcoholsController( - private val alcoholQueryService: AlcoholQueryService + private val alcoholQueryService: AlcoholQueryService, + private val adminAlcoholCommandService: AdminAlcoholCommandService ) { @GetMapping @@ -26,4 +34,29 @@ class AdminAlcoholsController( fun getAlcoholDetail(@PathVariable alcoholId: Long): ResponseEntity<*> { return GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId)) } + + @GetMapping("/categories/reference") + fun getCategoryReference(): ResponseEntity<*> { + val pairs = alcoholQueryService.findAllCategoryPairs() + val response = pairs.map { mapOf("korCategory" to it.left, "engCategory" to it.right) } + return GlobalResponse.ok(response) + } + + @PostMapping + fun createAlcohol(@RequestBody @Valid request: AdminAlcoholUpsertRequest): ResponseEntity<*> { + return GlobalResponse.ok(adminAlcoholCommandService.createAlcohol(request)) + } + + @PutMapping("/{alcoholId}") + fun updateAlcohol( + @PathVariable alcoholId: Long, + @RequestBody @Valid request: AdminAlcoholUpsertRequest + ): ResponseEntity<*> { + return GlobalResponse.ok(adminAlcoholCommandService.updateAlcohol(alcoholId, request)) + } + + @DeleteMapping("/{alcoholId}") + fun deleteAlcohol(@PathVariable alcoholId: Long): ResponseEntity<*> { + return GlobalResponse.ok(adminAlcoholCommandService.deleteAlcohol(alcoholId)) + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index f67ae168d..868a46f43 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -3,13 +3,18 @@ package app.docs.alcohols import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest +import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest import app.bottlenote.alcohols.presentation.AdminAlcoholsController +import app.bottlenote.alcohols.service.AdminAlcoholCommandService import app.bottlenote.alcohols.service.AlcoholQueryService +import app.bottlenote.global.dto.response.AdminResultResponse import app.bottlenote.global.service.cursor.SortOrder 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.Test +import org.apache.commons.lang3.tuple.Pair import org.mockito.BDDMockito.given import org.mockito.Mockito.any import org.mockito.Mockito.anyLong @@ -17,11 +22,11 @@ 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.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.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 @@ -37,9 +42,15 @@ class AdminAlcoholsControllerDocsTest { @Autowired private lateinit var mvc: MockMvcTester + @Autowired + private lateinit var mapper: ObjectMapper + @MockitoBean private lateinit var alcoholQueryService: AlcoholQueryService + @MockitoBean + private lateinit var adminAlcoholCommandService: AdminAlcoholCommandService + @Test @DisplayName("관리자용 술 목록을 조회할 수 있다") fun searchAdminAlcohols() { @@ -183,4 +194,214 @@ class AdminAlcoholsControllerDocsTest { ) ) } + + @Test + @DisplayName("관리자용 술을 생성할 수 있다") + fun createAlcohol() { + // given + val response = AlcoholsHelper.createAdminResultResponse( + code = AdminResultResponse.ResultCode.ALCOHOL_CREATED, + targetId = 1L + ) + val request = AlcoholsHelper.createAlcoholUpsertRequestMap() + + given(adminAlcoholCommandService.createAlcohol(any(AdminAlcoholUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/alcohols") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("한글 이름"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("영문 이름"), + fieldWithPath("abv").type(JsonFieldType.STRING).description("도수"), + fieldWithPath("type").type(JsonFieldType.STRING).description("술 타입 (WHISKY 등)"), + fieldWithPath("korCategory").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("engCategory").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("categoryGroup").type(JsonFieldType.STRING).description("카테고리 그룹 (SINGLE_MALT 등)"), + fieldWithPath("regionId").type(JsonFieldType.NUMBER).description("지역 ID"), + fieldWithPath("distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID"), + fieldWithPath("age").type(JsonFieldType.STRING).description("숙성년도"), + fieldWithPath("cask").type(JsonFieldType.STRING).description("캐스크 타입"), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("volume").type(JsonFieldType.STRING).description("용량") + ), + 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("결과 코드 (ALCOHOL_CREATED)"), + 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).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() + ) + ) + ) + } + + @Test + @DisplayName("관리자용 술을 수정할 수 있다") + fun updateAlcohol() { + // given + val response = AlcoholsHelper.createAdminResultResponse( + code = AdminResultResponse.ResultCode.ALCOHOL_UPDATED, + targetId = 1L + ) + val request = AlcoholsHelper.createAlcoholUpsertRequestMap( + korName = "수정된 위스키", + engName = "Updated Whisky" + ) + + given(adminAlcoholCommandService.updateAlcohol(anyLong(), any(AdminAlcoholUpsertRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/alcohols/{alcoholId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("alcoholId").description("수정할 술 ID") + ), + requestFields( + fieldWithPath("korName").type(JsonFieldType.STRING).description("한글 이름"), + fieldWithPath("engName").type(JsonFieldType.STRING).description("영문 이름"), + fieldWithPath("abv").type(JsonFieldType.STRING).description("도수"), + fieldWithPath("type").type(JsonFieldType.STRING).description("술 타입 (WHISKY 등)"), + fieldWithPath("korCategory").type(JsonFieldType.STRING).description("카테고리 한글명"), + fieldWithPath("engCategory").type(JsonFieldType.STRING).description("카테고리 영문명"), + fieldWithPath("categoryGroup").type(JsonFieldType.STRING).description("카테고리 그룹 (SINGLE_MALT 등)"), + fieldWithPath("regionId").type(JsonFieldType.NUMBER).description("지역 ID"), + fieldWithPath("distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID"), + fieldWithPath("age").type(JsonFieldType.STRING).description("숙성년도"), + fieldWithPath("cask").type(JsonFieldType.STRING).description("캐스크 타입"), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("volume").type(JsonFieldType.STRING).description("용량") + ), + 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("결과 코드 (ALCOHOL_UPDATED)"), + 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).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() + ) + ) + ) + } + + @Test + @DisplayName("카테고리 레퍼런스를 조회할 수 있다") + fun getCategoryReference() { + // given + val categoryPairs = listOf( + Pair.of("싱글 몰트", "Single Malt"), + Pair.of("블렌디드", "Blended"), + Pair.of("버번", "Bourbon") + ) + + given(alcoholQueryService.findAllCategoryPairs()) + .willReturn(categoryPairs) + + // when & then + assertThat( + mvc.get().uri("/alcohols/categories/reference") + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/category-reference", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.ARRAY).description("카테고리 페어 목록"), + fieldWithPath("data[].korCategory").type(JsonFieldType.STRING).description("한글 카테고리"), + fieldWithPath("data[].engCategory").type(JsonFieldType.STRING).description("영문 카테고리"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).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() + ) + ) + ) + } + + @Test + @DisplayName("관리자용 술을 삭제할 수 있다") + fun deleteAlcohol() { + // given + val response = AlcoholsHelper.createAdminResultResponse( + code = AdminResultResponse.ResultCode.ALCOHOL_DELETED, + targetId = 1L + ) + + given(adminAlcoholCommandService.deleteAlcohol(anyLong())) + .willReturn(response) + + // when & then + assertThat( + mvc.delete().uri("/alcohols/{alcoholId}", 1L) + ) + .hasStatusOk() + .apply( + document( + "admin/alcohols/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("alcoholId").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("결과 코드 (ALCOHOL_DELETED)"), + 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).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() + ) + ) + ) + } } 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 a549b93df..1ec30f963 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 @@ -6,7 +6,10 @@ import app.bottlenote.alcohols.dto.response.AdminAlcoholItem import app.bottlenote.alcohols.dto.response.AdminDistilleryItem import app.bottlenote.alcohols.dto.response.AdminRegionItem import app.bottlenote.alcohols.dto.response.AdminTastingTagItem +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup +import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.global.dto.response.AdminResultResponse import java.time.LocalDateTime object AlcoholsHelper { @@ -154,4 +157,41 @@ object AlcoholsHelper { ) ) .build() + + fun createAdminResultResponse( + code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.ALCOHOL_CREATED, + targetId: Long = 1L + ): AdminResultResponse = AdminResultResponse.of(code, targetId) + + fun createAlcoholUpsertRequestMap( + korName: String = "테스트 위스키", + engName: String = "Test Whisky", + abv: String = "40%", + type: AlcoholType = AlcoholType.WHISKY, + korCategory: String = "싱글 몰트", + engCategory: String = "Single Malt", + categoryGroup: AlcoholCategoryGroup = AlcoholCategoryGroup.SINGLE_MALT, + regionId: Long = 1L, + distilleryId: Long = 1L, + age: String = "12", + cask: String = "American Oak", + imageUrl: String = "https://example.com/test.jpg", + description: String = "테스트 설명", + volume: String = "700ml" + ): Map = mapOf( + "korName" to korName, + "engName" to engName, + "abv" to abv, + "type" to type.name, + "korCategory" to korCategory, + "engCategory" to engCategory, + "categoryGroup" to categoryGroup.name, + "regionId" to regionId, + "distilleryId" to distilleryId, + "age" to age, + "cask" to cask, + "imageUrl" to imageUrl, + "description" to description, + "volume" to volume + ) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index 064488f8b..f3458f787 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -3,8 +3,12 @@ package app.integration.alcohols import app.IntegrationTestSupport import app.bottlenote.alcohols.constant.AdminAlcoholSortType import app.bottlenote.alcohols.constant.AlcoholCategoryGroup +import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.alcohols.fixture.AlcoholTestFactory import app.bottlenote.global.service.cursor.SortOrder +import app.bottlenote.rating.fixture.RatingTestFactory +import app.bottlenote.review.fixture.ReviewTestFactory +import app.bottlenote.user.fixture.UserTestFactory import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -17,6 +21,7 @@ import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType import java.util.stream.Stream @Tag("admin_integration") @@ -26,6 +31,15 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var alcoholTestFactory: AlcoholTestFactory + @Autowired + private lateinit var userTestFactory: UserTestFactory + + @Autowired + private lateinit var reviewTestFactory: ReviewTestFactory + + @Autowired + private lateinit var ratingTestFactory: RatingTestFactory + private lateinit var accessToken: String @BeforeEach @@ -150,6 +164,46 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .extractingPath("$.meta.size").isEqualTo(size) } + @Nested + @DisplayName("카테고리 레퍼런스 조회 API") + inner class GetCategoryReference { + + @Test + @DisplayName("기존 카테고리 페어 목록을 조회할 수 있다") + fun getCategoryReferenceSuccess() { + // given - 다양한 카테고리를 가진 술 생성 + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + alcoholTestFactory.persistAlcoholWithCategory("블렌디드", "Blended") + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") // 중복 + + // when & then + assertThat( + mockMvcTester.get().uri("/alcohols/categories/reference") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.success").isEqualTo(true) + } + + @Test + @DisplayName("동일 한글 카테고리에 다른 영문 카테고리가 별도로 조회된다") + fun differentEngCategoriesAreSeparate() { + // given - 같은 한글 카테고리, 다른 영문 카테고리 + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "Single Malt") + alcoholTestFactory.persistAlcoholWithCategory("싱글 몰트", "-") + + // when & then - 2개의 다른 페어가 반환됨 + assertThat( + mockMvcTester.get().uri("/alcohols/categories/reference") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.length()").isEqualTo(2) + } + } + @Nested @DisplayName("술 단건 상세 조회 API") inner class GetAlcoholDetail { @@ -202,4 +256,236 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { .hasStatus4xxClientError() } } + + @Nested + @DisplayName("술 생성 API") + inner class CreateAlcohol { + + @Test + @DisplayName("위스키를 생성할 수 있다") + fun createAlcoholSuccess() { + // given + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_CREATED") + } + + @Test + @DisplayName("필수 필드 누락 시 실패한다") + fun createAlcoholWithMissingFields() { + // given + val request = mapOf( + "korName" to "테스트 위스키" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("존재하지 않는 regionId로 생성 시 실패한다") + fun createAlcoholWithInvalidRegion() { + // given + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to 999999L, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml" + ) + + // when & then + assertThat( + mockMvcTester.post().uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("술 수정 API") + inner class UpdateAlcohol { + + @Test + @DisplayName("위스키를 수정할 수 있다") + fun updateAlcoholSuccess() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 alcoholId로 수정 시 실패한다") + fun updateAlcoholNotFound() { + // given + val region = alcoholTestFactory.persistRegion() + val distillery = alcoholTestFactory.persistDistillery() + + val request = mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) + + // when & then + assertThat( + mockMvcTester.put().uri("/alcohols/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("술 삭제 API") + inner class DeleteAlcohol { + + @Test + @DisplayName("위스키를 삭제할 수 있다") + fun deleteAlcoholSuccess() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatusOk() + .bodyJson() + .extractingPath("$.data.code").isEqualTo("ALCOHOL_DELETED") + } + + @Test + @DisplayName("존재하지 않는 alcoholId로 삭제 시 실패한다") + fun deleteAlcoholNotFound() { + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/999999") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("리뷰가 존재하는 위스키는 삭제할 수 없다") + fun deleteAlcoholWithReviews() { + // given + val user = userTestFactory.persistUser() + val alcohol = alcoholTestFactory.persistAlcohol() + reviewTestFactory.persistReview(user.id, alcohol.id) + + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + + @Test + @DisplayName("평점이 존재하는 위스키는 삭제할 수 없다") + fun deleteAlcoholWithRatings() { + // given + val user = userTestFactory.persistUser() + val alcohol = alcoholTestFactory.persistAlcohol() + ratingTestFactory.persistRating(user.id, alcohol.id, 5) + + // when & then + assertThat( + mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ) + .hasStatus4xxClientError() + } + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java index 042e52d2f..d18f2ca5a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Alcohol.java @@ -15,6 +15,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; import lombok.AccessLevel; @@ -95,7 +96,50 @@ public class Alcohol extends BaseEntity { @Column(name = "volume") private String volume; + @Comment("삭제일시") + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder.Default @OneToMany(mappedBy = "alcohol", fetch = FetchType.LAZY) private Set alcoholsTastingTags = new HashSet<>(); + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } + + public void update( + String korName, + String engName, + String abv, + AlcoholType type, + String korCategory, + String engCategory, + AlcoholCategoryGroup categoryGroup, + Region region, + Distillery distillery, + String age, + String cask, + String imageUrl, + String description, + String volume) { + if (korName != null && !korName.isBlank()) this.korName = korName; + if (engName != null && !engName.isBlank()) this.engName = engName; + if (abv != null && !abv.isBlank()) this.abv = abv; + if (type != null) this.type = type; + if (korCategory != null && !korCategory.isBlank()) this.korCategory = korCategory; + if (engCategory != null && !engCategory.isBlank()) this.engCategory = engCategory; + if (categoryGroup != null) this.categoryGroup = categoryGroup; + if (region != null) this.region = region; + if (distillery != null) this.distillery = distillery; + if (age != null && !age.isBlank()) this.age = age; + if (cask != null && !cask.isBlank()) this.cask = cask; + if (imageUrl != null && !imageUrl.isBlank()) this.imageUrl = imageUrl; + if (description != null && !description.isBlank()) this.description = description; + if (volume != null && !volume.isBlank()) this.volume = volume; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java index 43aaab210..46db2b9a8 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/AlcoholQueryRepository.java @@ -36,6 +36,8 @@ public interface AlcoholQueryRepository { List findAllCategories(AlcoholType type); + List> findAllCategoryPairs(); + Boolean existsByAlcoholId(Long alcoholId); Pair> getStandardExplore( diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java index e43d2cd38..22e3df4c4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/DistilleryRepository.java @@ -1,10 +1,13 @@ package app.bottlenote.alcohols.domain; import app.bottlenote.alcohols.dto.response.AdminDistilleryItem; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface DistilleryRepository { + Optional findById(Long id); + Page findAllDistilleries(String keyword, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java index 276f14b0a..c147bf11f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java @@ -3,11 +3,14 @@ import app.bottlenote.alcohols.dto.response.AdminRegionItem; import app.bottlenote.alcohols.dto.response.RegionsItem; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface RegionRepository { + Optional findById(Long id); + List findAllRegionsResponse(); Page findAllRegions(String keyword, Pageable pageable); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java new file mode 100644 index 000000000..0bdc9cced --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/request/AdminAlcoholUpsertRequest.java @@ -0,0 +1,22 @@ +package app.bottlenote.alcohols.dto.request; + +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup; +import app.bottlenote.alcohols.constant.AlcoholType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AdminAlcoholUpsertRequest( + @NotBlank(message = "한글 이름은 필수입니다.") String korName, + @NotBlank(message = "영문 이름은 필수입니다.") String engName, + @NotBlank(message = "도수는 필수입니다.") String abv, + @NotNull(message = "주류 타입은 필수입니다.") AlcoholType type, + @NotBlank(message = "한글 카테고리는 필수입니다.") String korCategory, + @NotBlank(message = "영문 카테고리는 필수입니다.") String engCategory, + @NotNull(message = "카테고리 그룹은 필수입니다.") AlcoholCategoryGroup categoryGroup, + @NotNull(message = "지역 ID는 필수입니다.") Long regionId, + @NotNull(message = "증류소 ID는 필수입니다.") Long distilleryId, + @NotBlank(message = "숙성년도는 필수입니다.") String age, + @NotBlank(message = "캐스크 타입은 필수입니다.") String cask, + @NotBlank(message = "이미지 URL은 필수입니다.") String imageUrl, + @NotBlank(message = "설명은 필수입니다.") String description, + @NotBlank(message = "용량은 필수입니다.") String volume) {} 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 7764aec58..5101c39ab 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 @@ -4,7 +4,12 @@ import org.springframework.http.HttpStatus; public enum AlcoholExceptionCode implements ExceptionCode { - ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "위스키를 찾을 수 없습니다."); + ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "위스키를 찾을 수 없습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "지역을 찾을 수 없습니다."), + DISTILLERY_NOT_FOUND(HttpStatus.NOT_FOUND, "증류소를 찾을 수 없습니다."), + ALCOHOL_HAS_REVIEWS(HttpStatus.CONFLICT, "리뷰가 존재하는 위스키는 삭제할 수 없습니다."), + ALCOHOL_HAS_RATINGS(HttpStatus.CONFLICT, "평점이 존재하는 위스키는 삭제할 수 없습니다."), + ALCOHOL_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 위스키입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java index 2178a09db..7a61c8818 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepository.java @@ -16,6 +16,8 @@ public interface CustomAlcoholQueryRepository { + List> findAllCategoryPairs(); + PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto); AlcoholDetailItem findAlcoholDetailById(Long alcoholId, Long userId); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java index c390c610f..3999bea5c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/CustomAlcoholQueryRepositoryImpl.java @@ -36,6 +36,20 @@ public class CustomAlcoholQueryRepositoryImpl implements CustomAlcoholQueryRepos private final JPAQueryFactory queryFactory; private final AlcoholQuerySupporter supporter; + /** 모든 카테고리 페어(한글, 영문) 조회 */ + @Override + public List> findAllCategoryPairs() { + return queryFactory + .select(alcohol.korCategory, alcohol.engCategory) + .from(alcohol) + .groupBy(alcohol.korCategory, alcohol.engCategory) + .orderBy(alcohol.korCategory.asc()) + .fetch() + .stream() + .map(tuple -> Pair.of(tuple.get(alcohol.korCategory), tuple.get(alcohol.engCategory))) + .toList(); + } + /** queryDSL 알코올 검색 */ @Override public PageResponse searchAlcohols(AlcoholSearchCriteria criteriaDto) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java new file mode 100644 index 000000000..f6547aaf4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AdminAlcoholCommandService.java @@ -0,0 +1,172 @@ +package app.bottlenote.alcohols.service; + +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_ALREADY_DELETED; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_HAS_RATINGS; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_HAS_REVIEWS; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.ALCOHOL_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.DISTILLERY_NOT_FOUND; +import static app.bottlenote.alcohols.exception.AlcoholExceptionCode.REGION_NOT_FOUND; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_CREATED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_DELETED; +import static app.bottlenote.global.dto.response.AdminResultResponse.ResultCode.ALCOHOL_UPDATED; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.DistilleryRepository; +import app.bottlenote.alcohols.domain.Region; +import app.bottlenote.alcohols.domain.RegionRepository; +import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest; +import app.bottlenote.alcohols.exception.AlcoholException; +import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; +import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; +import app.bottlenote.common.image.ImageUtil; +import app.bottlenote.global.dto.response.AdminResultResponse; +import app.bottlenote.rating.domain.RatingRepository; +import app.bottlenote.review.domain.ReviewRepository; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminAlcoholCommandService { + + private static final String REFERENCE_TYPE_ALCOHOL = "ALCOHOL"; + + private final AlcoholQueryRepository alcoholQueryRepository; + private final RegionRepository regionRepository; + private final DistilleryRepository distilleryRepository; + private final ReviewRepository reviewRepository; + private final RatingRepository ratingRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public AdminResultResponse createAlcohol(AdminAlcoholUpsertRequest request) { + Region region = + regionRepository + .findById(request.regionId()) + .orElseThrow(() -> new AlcoholException(REGION_NOT_FOUND)); + Distillery distillery = + distilleryRepository + .findById(request.distilleryId()) + .orElseThrow(() -> new AlcoholException(DISTILLERY_NOT_FOUND)); + + Alcohol alcohol = + Alcohol.builder() + .korName(request.korName()) + .engName(request.engName()) + .abv(request.abv()) + .type(request.type()) + .korCategory(request.korCategory()) + .engCategory(request.engCategory()) + .categoryGroup(request.categoryGroup()) + .region(region) + .distillery(distillery) + .age(request.age()) + .cask(request.cask()) + .imageUrl(request.imageUrl()) + .description(request.description()) + .volume(request.volume()) + .build(); + + Alcohol saved = alcoholQueryRepository.save(alcohol); + publishImageActivatedEvent(request.imageUrl(), saved.getId()); + + return AdminResultResponse.of(ALCOHOL_CREATED, saved.getId()); + } + + @Transactional + public AdminResultResponse updateAlcohol(Long alcoholId, AdminAlcoholUpsertRequest request) { + Alcohol alcohol = + alcoholQueryRepository + .findById(alcoholId) + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); + + if (alcohol.isDeleted()) { + throw new AlcoholException(ALCOHOL_ALREADY_DELETED); + } + + Region region = + regionRepository + .findById(request.regionId()) + .orElseThrow(() -> new AlcoholException(REGION_NOT_FOUND)); + Distillery distillery = + distilleryRepository + .findById(request.distilleryId()) + .orElseThrow(() -> new AlcoholException(DISTILLERY_NOT_FOUND)); + + String oldImageUrl = alcohol.getImageUrl(); + + alcohol.update( + request.korName(), + request.engName(), + request.abv(), + request.type(), + request.korCategory(), + request.engCategory(), + request.categoryGroup(), + region, + distillery, + request.age(), + request.cask(), + request.imageUrl(), + request.description(), + request.volume()); + + handleImageChange(oldImageUrl, request.imageUrl(), alcoholId); + + return AdminResultResponse.of(ALCOHOL_UPDATED, alcoholId); + } + + @Transactional + public AdminResultResponse deleteAlcohol(Long alcoholId) { + Alcohol alcohol = + alcoholQueryRepository + .findById(alcoholId) + .orElseThrow(() -> new AlcoholException(ALCOHOL_NOT_FOUND)); + + if (alcohol.isDeleted()) { + throw new AlcoholException(ALCOHOL_ALREADY_DELETED); + } + + if (reviewRepository.existsByAlcoholId(alcoholId)) { + throw new AlcoholException(ALCOHOL_HAS_REVIEWS); + } + + if (ratingRepository.existsByAlcoholId(alcoholId)) { + throw new AlcoholException(ALCOHOL_HAS_RATINGS); + } + + alcohol.delete(); + return AdminResultResponse.of(ALCOHOL_DELETED, alcoholId); + } + + private void publishImageActivatedEvent(String imageUrl, Long alcoholId) { + if (imageUrl == null || imageUrl.isBlank()) return; + + String resourceKey = ImageUtil.extractResourceKey(imageUrl); + if (resourceKey != null) { + eventPublisher.publishEvent( + ImageResourceActivatedEvent.of(List.of(resourceKey), alcoholId, REFERENCE_TYPE_ALCOHOL)); + } + } + + private void handleImageChange(String oldImageUrl, String newImageUrl, Long alcoholId) { + if (Objects.equals(oldImageUrl, newImageUrl)) return; + + if (oldImageUrl != null && !oldImageUrl.isBlank()) { + String oldResourceKey = ImageUtil.extractResourceKey(oldImageUrl); + if (oldResourceKey != null) { + eventPublisher.publishEvent( + ImageResourceInvalidatedEvent.of( + List.of(oldResourceKey), alcoholId, REFERENCE_TYPE_ALCOHOL)); + } + } + + publishImageActivatedEvent(newImageUrl, alcoholId); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java index 4f6394a97..cb0edc9d6 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/service/AlcoholQueryService.java @@ -103,6 +103,11 @@ public GlobalResponse searchAdminAlcohols(AdminAlcoholSearchRequest request) { return GlobalResponse.fromPage(alcoholQueryRepository.searchAdminAlcohols(request)); } + @Transactional(readOnly = true) + public List> findAllCategoryPairs() { + return alcoholQueryRepository.findAllCategoryPairs(); + } + @Transactional(readOnly = true) public AdminAlcoholDetailResponse findAdminAlcoholDetailById(Long alcoholId) { AdminAlcoholDetailProjection projection = 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 new file mode 100644 index 000000000..398bebcbe --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/dto/response/AdminResultResponse.java @@ -0,0 +1,27 @@ +package app.bottlenote.global.dto.response; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +public record AdminResultResponse(String code, String message, Long targetId, String responseAt) { + public static AdminResultResponse of(ResultCode code, Long targetId) { + return new AdminResultResponse( + code.name(), + code.getMessage(), + targetId, + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + @Getter + @RequiredArgsConstructor + public enum ResultCode { + ALCOHOL_CREATED("위스키가 등록되었습니다."), + ALCOHOL_UPDATED("위스키가 수정되었습니다."), + ALCOHOL_DELETED("위스키가 삭제되었습니다."), + ; + + private final String message; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java index 0c92c6b08..d3639db6d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/domain/RatingRepository.java @@ -22,4 +22,6 @@ public interface RatingRepository { PageResponse fetchRatingList(RatingListFetchCriteria criteria); Optional fetchUserRating(Long alcoholId, Long userId); + + boolean existsByAlcoholId(Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java index 180adf762..fe8cf809f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/JpaRatingRepository.java @@ -31,4 +31,9 @@ Optional findByAlcoholIdAndUserId( """) Optional fetchUserRating( @Param("alcoholId") Long alcoholId, @Param("userId") Long userId); + + @Override + @Query( + "select case when count(r) > 0 then true else false end from rating r where r.id.alcoholId = :alcoholId") + boolean existsByAlcoholId(@Param("alcoholId") Long alcoholId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java index 4b58fbab0..436725c1c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/domain/ReviewRepository.java @@ -32,6 +32,8 @@ PageResponse getReviewsByMe( boolean existsById(Long reviewId); + boolean existsByAlcoholId(Long alcoholId); + Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java index 8a05c9dd7..b39f64f23 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/repository/JpaReviewRepository.java @@ -22,4 +22,9 @@ public interface JpaReviewRepository @Override @Query("select r from review r where r.userId = :userId") List findByUserId(@Param("userId") Long userId); + + @Override + @Query( + "select case when count(r) > 0 then true else false end from review r where r.alcoholId = :alcoholId") + boolean existsByAlcoholId(@Param("alcoholId") Long alcoholId); } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java index f2399b803..8cba806d2 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/AlcoholTestFactory.java @@ -226,6 +226,33 @@ public Alcohol persistAlcoholWithName(@NotNull String korName, @NotNull String e return alcohol; } + /** 특정 카테고리로 Alcohol 생성 - 연관 엔티티 자동 생성 */ + @Transactional + @NotNull + public Alcohol persistAlcoholWithCategory( + @NotNull String korCategory, @NotNull String engCategory) { + Region region = persistRegionInternal(); + Distillery distillery = persistDistilleryInternal(); + + Alcohol alcohol = + Alcohol.builder() + .korName("테스트 위스키-" + generateRandomSuffix()) + .engName("Test Whisky-" + generateRandomSuffix()) + .abv("40%") + .type(AlcoholType.WHISKY) + .korCategory(korCategory) + .engCategory(engCategory) + .categoryGroup(AlcoholCategoryGroup.SINGLE_MALT) + .region(region) + .distillery(distillery) + .cask("Oak") + .imageUrl("https://example.com/custom.jpg") + .build(); + em.persist(alcohol); + em.flush(); + return alcohol; + } + /** 연관 엔티티와 함께 Alcohol 생성 */ @Transactional @NotNull diff --git a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index 53532ec6b..f47848ce8 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -73,6 +73,14 @@ public List findAllCategories(AlcoholType type) { return List.of(); } + @Override + public List> findAllCategoryPairs() { + return alcohols.values().stream() + .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .distinct() + .toList(); + } + @Override public Boolean existsByAlcoholId(Long alcoholId) { return alcohols.containsKey(alcoholId); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java index 6661a0b07..99642e7a0 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java @@ -84,4 +84,9 @@ public Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size) { return null; } + + @Override + public boolean existsByAlcoholId(Long alcoholId) { + return database.values().stream().anyMatch(review -> review.getAlcoholId().equals(alcoholId)); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java index 56049d327..d5c56166f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/rating/fixture/InMemoryRatingRepository.java @@ -56,4 +56,10 @@ public PageResponse fetchRatingList(RatingListFetchCrit public Optional fetchUserRating(Long alcoholId, Long userId) { return Optional.empty(); } + + @Override + public boolean existsByAlcoholId(Long alcoholId) { + return ratings.values().stream() + .anyMatch(rating -> rating.getId().getAlcoholId().equals(alcoholId)); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java index d27d6379f..980e2538f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/InMemoryReviewRepository.java @@ -86,4 +86,9 @@ public Pair> getStandardExplore( Long userId, List keywords, Long cursor, Integer size) { return null; } + + @Override + public boolean existsByAlcoholId(Long alcoholId) { + return database.values().stream().anyMatch(review -> review.getAlcoholId().equals(alcoholId)); + } } From cf8f5ba791178d98bc11a780b58acb0408bf92a5 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 20 Jan 2026 22:47:25 +0900 Subject: [PATCH 84/95] =?UTF-8?q?fix:=20product-api=20InMemoryAlcoholQuery?= =?UTF-8?q?Repository=EC=97=90=20findAllCategoryPairs=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .../alcohols/fixture/InMemoryAlcoholQueryRepository.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java index ede921a9f..0b2a8bb8f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/fixture/InMemoryAlcoholQueryRepository.java @@ -64,6 +64,14 @@ public List findAllCategories(AlcoholType type) { return List.of(); } + @Override + public List> findAllCategoryPairs() { + return alcohols.values().stream() + .map(a -> Pair.of(a.getKorCategory(), a.getEngCategory())) + .distinct() + .toList(); + } + @Override public Boolean existsByAlcoholId(Long alcoholId) { return null; From cdcd163f149083f4f8555449433fd9fe9ce2d022 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 20 Jan 2026 23:08:18 +0900 Subject: [PATCH 85/95] =?UTF-8?q?chore:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20(deleted=5Fat=20=EC=BB=AC=EB=9F=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 Co-Authored-By: Claude Opus 4.5 --- git.environment-variables | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index 162e3ce0f..5317a5e1d 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 162e3ce0f30e7ccfcbdb348c50549272d0c92917 +Subproject commit 5317a5e1d26d83a49e869f3b895aca8ca356cc38 From c3faa16e1da3e49c1862d6d3ede78aead9593ec4 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 21 Jan 2026 12:26:02 +0900 Subject: [PATCH 86/95] docs: Add popular whiskey API plan and admin API enum documentation Implemented a detailed plan for a view-based popular whiskey API. Added documentation files for administrative enums (e.g., roles, categories) and updated API reference files to include relevant enum references. --- .../src/docs/asciidoc/admin-api.adoc | 2 + .../asciidoc/api/admin-alcohols/alcohols.adoc | 7 + .../docs/asciidoc/api/admin-auth/auth.adoc | 1 + .../docs/asciidoc/api/admin-help/help.adoc | 3 + .../asciidoc/api/common/enums/admin-role.adoc | 12 + .../common/enums/alcohol-category-group.adoc | 13 + .../api/common/enums/alcohol-type.adoc | 16 + .../asciidoc/api/common/enums/help-type.adoc | 11 + .../api/common/enums/status-type.adoc | 11 + git.environment-variables | 2 +- plan/popular-view-api.md | 320 ++++++++++++++++++ 11 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/common/enums/admin-role.adoc create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-category-group.adoc create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-type.adoc create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/common/enums/help-type.adoc create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/common/enums/status-type.adoc create mode 100644 plan/popular-view-api.md diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 8f22e5675..39adcc475 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -16,6 +16,8 @@ endif::[] == 개요 (overview) +NOTE: 로그인 API를 제외한 모든 API는 `Authorization` 헤더에 유효한 액세스 토큰이 필요합니다. + include::api/overview/overview.adoc[] ''' diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc index 37b6feb38..c702686de 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-alcohols/alcohols.adoc @@ -13,6 +13,7 @@ GET /admin/api/v1/alcohols [discrete] include::{snippets}/admin/alcohols/search/query-parameters.adoc[] +include::../common/enums/alcohol-category-group.adoc[] include::{snippets}/admin/alcohols/search/curl-request.adoc[] include::{snippets}/admin/alcohols/search/http-request.adoc[] @@ -57,6 +58,8 @@ include::{snippets}/admin/alcohols/detail/http-response.adoc[] - 관리자용 술 생성 API입니다. - 모든 필드는 필수값입니다. + + [source] ---- POST /admin/api/v1/alcohols @@ -67,6 +70,8 @@ POST /admin/api/v1/alcohols [discrete] include::{snippets}/admin/alcohols/create/request-fields.adoc[] +include::../common/enums/alcohol-type.adoc[] +include::../common/enums/alcohol-category-group.adoc[] include::{snippets}/admin/alcohols/create/curl-request.adoc[] include::{snippets}/admin/alcohols/create/http-request.adoc[] @@ -101,6 +106,8 @@ include::{snippets}/admin/alcohols/update/path-parameters.adoc[] [discrete] include::{snippets}/admin/alcohols/update/request-fields.adoc[] +include::../common/enums/alcohol-type.adoc[] +include::../common/enums/alcohol-category-group.adoc[] include::{snippets}/admin/alcohols/update/curl-request.adoc[] include::{snippets}/admin/alcohols/update/http-request.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc index f542373fb..1ca3fbc5f 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-auth/auth.adoc @@ -70,6 +70,7 @@ include::{snippets}/admin/auth/signup/request-headers.adoc[] [discrete] include::{snippets}/admin/auth/signup/request-fields.adoc[] +include::../common/enums/admin-role.adoc[] include::{snippets}/admin/auth/signup/http-request.adoc[] [discrete] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc index a6cd1072e..098a4f6e7 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-help/help.adoc @@ -20,6 +20,8 @@ include::{snippets}/admin/help/list/request-headers.adoc[] [discrete] include::{snippets}/admin/help/list/query-parameters.adoc[] +include::../common/enums/help-type.adoc[] +include::../common/enums/status-type.adoc[] include::{snippets}/admin/help/list/http-request.adoc[] [discrete] @@ -90,6 +92,7 @@ include::{snippets}/admin/help/answer/path-parameters.adoc[] [discrete] include::{snippets}/admin/help/answer/request-fields.adoc[] +include::../common/enums/status-type.adoc[] include::{snippets}/admin/help/answer/http-request.adoc[] [discrete] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/admin-role.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/admin-role.adoc new file mode 100644 index 000000000..5cf62dd4a --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/admin-role.adoc @@ -0,0 +1,12 @@ +[discrete] +==== AdminRole (관리자 역할) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`ROOT_ADMIN` |최고 관리자 +|`PARTNER` |파트너사 +|`CLIENT` |고객사 +|`BAR_OWNER` |바/매장 사장님 +|`COMMUNITY_MANAGER` |커뮤니티 매니저 +|=== diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-category-group.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-category-group.adoc new file mode 100644 index 000000000..11db7beb9 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-category-group.adoc @@ -0,0 +1,13 @@ +[discrete] +==== AlcoholCategoryGroup (카테고리 그룹) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`SINGLE_MALT` |싱글몰트 위스키 +|`BLEND` |블렌디드 위스키 +|`BLENDED_MALT` |블렌디드 몰트 위스키 +|`BOURBON` |버번 위스키 +|`RYE` |라이 위스키 +|`OTHER` |기타 위스키 +|=== diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-type.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-type.adoc new file mode 100644 index 000000000..2619bef43 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/alcohol-type.adoc @@ -0,0 +1,16 @@ +[discrete] +==== AlcoholType (술 타입) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`WHISKY` |위스키 +|`RUM` |럼 +|`VODKA` |보드카 +|`GIN` |진 +|`TEQUILA` |데킬라 +|`BRANDY` |브랜디 +|`BEER` |맥주 +|`WINE` |와인 +|`ETC` |기타 +|=== diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/help-type.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/help-type.adoc new file mode 100644 index 000000000..6a531eaee --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/help-type.adoc @@ -0,0 +1,11 @@ +[discrete] +==== HelpType (문의 유형) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`WHISKEY` |위스키 관련 문의 +|`REVIEW` |리뷰 관련 문의 +|`USER` |회원 관련 문의 +|`ETC` |그 외 모든 문의 +|=== diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/status-type.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/status-type.adoc new file mode 100644 index 000000000..93521f11e --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/common/enums/status-type.adoc @@ -0,0 +1,11 @@ +[discrete] +==== StatusType (처리 상태) ==== + +[cols="2,3", options="header"] +|=== +|값 |설명 +|`WAITING` |대기중 +|`SUCCESS` |처리 완료 +|`REJECT` |반려 +|`DELETED` |삭제 +|=== diff --git a/git.environment-variables b/git.environment-variables index 5317a5e1d..a465b5afc 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 5317a5e1d26d83a49e869f3b895aca8ca356cc38 +Subproject commit a465b5afc1bff5d4dffb9512368038427a818f86 diff --git a/plan/popular-view-api.md b/plan/popular-view-api.md new file mode 100644 index 000000000..490528ec8 --- /dev/null +++ b/plan/popular-view-api.md @@ -0,0 +1,320 @@ +# 조회수 기반 인기 위스키 API 구현 계획 + +## 개요 +이번 주/월 조회수 기반 인기 위스키 조회 API 추가. 조회 기록이 부족하면 평점 높은 주류로 채워서 항상 20개를 반환. + +## 엔드포인트 +- `GET /api/v1/popular/view/week` - 주간 인기 위스키 +- `GET /api/v1/popular/view/monthly` - 월간 인기 위스키 + +## 쿼리 파라미터 +| 파라미터 | 타입 | 기본값 | 설명 | +|---------|------|--------|------| +| `top` | Integer | 20 | 조회할 개수 | + +## 응답 형식 +기존 `PopularItem` 재사용: +```java +public record PopularItem( + Long alcoholId, + String korName, + String engName, + Double rating, + Long ratingCount, + String korCategory, + String engCategory, + String imageUrl, + Boolean isPicked, + Double popularScore // 조회수 기반이므로 viewCount로 대체 고려 +) +``` + +## 비즈니스 로직 +1. 기간 내 `alcohols_view_histories` 조회수 집계 +2. 조회수 높은 순 정렬 +3. 결과가 `top`개 미만이면 평점 높은 주류로 채움 (중복 제외) +4. 최종 `top`개 반환 + +## 레포지토리 구조 + +``` +PopularQueryRepository (도메인 인터페이스) - 메서드 시그니처 추가 + ↑ 구현 +JpaPopularQueryRepository (JPA + Custom 상속) + ↑ 상속 +CustomPopularQueryRepository (QueryDSL 인터페이스) - 신규 + ↑ 구현 +CustomPopularQueryRepositoryImpl (QueryDSL 구현체) - 신규 +``` + +## 파일 구조 + +### bottlenote-mono (핵심 로직) +``` +app.bottlenote.alcohols/ +├── domain/ +│ └── PopularQueryRepository.java # 메서드 추가 (기존) +├── repository/ +│ ├── CustomPopularQueryRepository.java # 신규 - QueryDSL 인터페이스 +│ ├── CustomPopularQueryRepositoryImpl.java # 신규 - QueryDSL 구현 +│ └── JpaPopularQueryRepository.java # Custom 상속 추가 (기존) +└── service/ + └── AlcoholPopularService.java # 메서드 추가 (기존) +``` + +### bottlenote-product-api (컨트롤러 + 테스트) +``` +# 메인 +app.bottlenote.alcohols/ +└── controller/ + └── AlcoholPopularQueryController.java # 엔드포인트 추가 (기존) + +# 테스트 +app.bottlenote.alcohols/ +├── integration/ +│ └── PopularViewIntegrationTest.java # 신규 - 통합 테스트 +└── fixture/ + └── PopularsObjectFixture.java # 필요시 수정 (기존) + +app.docs.alcohols/ +└── RestPopularViewControllerTest.java # 신규 - REST Docs 테스트 +``` + +## 구현 순서 (Bottom-Up) + +> 컴파일 에러 없이 단계별로 진행. 각 Phase 완료 후 `./gradlew compileJava` 확인. + +### Phase 1: QueryDSL Custom Repository 인터페이스 (신규) +**파일**: `bottlenote-mono/.../repository/CustomPopularQueryRepository.java` +```java +public interface CustomPopularQueryRepository { + List getPopularByViewsWeekly(Long userId, int limit); + List getPopularByViewsMonthly(Long userId, int limit); +} +``` +- 컴파일 확인: `./gradlew :bottlenote-mono:compileJava` + +### Phase 2: QueryDSL Custom Repository 구현체 (신규) +**파일**: `bottlenote-mono/.../repository/CustomPopularQueryRepositoryImpl.java` +- `JPAQueryFactory` 주입 +- 주간/월간 조회수 집계 쿼리 구현 +- 부족분 평점 기반 채우기 로직 구현 +- 컴파일 확인: `./gradlew :bottlenote-mono:compileJava` + +### Phase 3: JPA Repository 수정 (기존) +**파일**: `bottlenote-mono/.../repository/JpaPopularQueryRepository.java` +- `CustomPopularQueryRepository` 상속 추가 +```java +public interface JpaPopularQueryRepository + extends PopularQueryRepository, + CustomPopularQueryRepository, // 추가 + JpaRepository { ... } +``` +- 컴파일 확인: `./gradlew :bottlenote-mono:compileJava` + +### Phase 4: 도메인 Repository 수정 (기존) +**파일**: `bottlenote-mono/.../domain/PopularQueryRepository.java` +- 메서드 시그니처 추가 +```java +List getPopularByViewsWeekly(Long userId, int limit); +List getPopularByViewsMonthly(Long userId, int limit); +``` +- 컴파일 확인: `./gradlew :bottlenote-mono:compileJava` + +### Phase 5: Service 계층 수정 (기존) +**파일**: `bottlenote-mono/.../service/AlcoholPopularService.java` +- 메서드 추가 +```java +public List getPopularByViewsWeekly(Integer top, Long userId) { ... } +public List getPopularByViewsMonthly(Integer top, Long userId) { ... } +``` +- 컴파일 확인: `./gradlew :bottlenote-mono:compileJava` + +### Phase 6: Controller 계층 수정 (기존) +**파일**: `bottlenote-product-api/.../controller/AlcoholPopularQueryController.java` +- 엔드포인트 추가 +```java +@GetMapping("/popular/view/week") +public ResponseEntity getPopularViewWeek(@RequestParam(defaultValue = "20") Integer top) { ... } + +@GetMapping("/popular/view/monthly") +public ResponseEntity getPopularViewMonthly(@RequestParam(defaultValue = "20") Integer top) { ... } +``` +- 컴파일 확인: `./gradlew :bottlenote-product-api:compileJava` + +### Phase 7: 테스트 데이터 준비 +**파일**: `bottlenote-product-api/src/test/resources/init-script/init-alcohols_view_history.sql` +- 테스트용 조회 기록 데이터 INSERT + +### Phase 8: 통합 테스트 작성 +**파일**: `bottlenote-product-api/.../integration/PopularViewIntegrationTest.java` +- `IntegrationTestSupport` 상속 +- `@Tag("integration")` +- 테스트 실행: `./gradlew :bottlenote-product-api:integration_test --tests "PopularViewIntegrationTest"` + +### Phase 9: REST Docs 테스트 작성 +**파일**: `bottlenote-product-api/.../docs/alcohols/RestPopularViewControllerTest.java` +- `AbstractRestDocs` 상속 +- API 문서 생성 확인: `./gradlew :bottlenote-product-api:asciidoctor` + +## 테스트 계획 + +### 1. 통합 테스트 (`PopularViewIntegrationTest.java`) +- 위치: `bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/` +- 베이스 클래스: `IntegrationTestSupport` 상속 +- 태그: `@Tag("integration")` + +**테스트 케이스:** +```java +@Tag("integration") +@DisplayName("[integration] [controller] Popular View") +class PopularViewIntegrationTest extends IntegrationTestSupport { + + @Test + @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") + @Sql(scripts = { + "/init-script/init-alcohol.sql", + "/init-script/init-user.sql", + "/init-script/init-alcohols_view_history.sql", // 신규 필요 + "/init-script/init-rating.sql" + }) + void test_getPopularViewWeekly() { ... } + + @Test + @DisplayName("조회 기록이 부족하면 평점 높은 주류로 채워서 반환한다") + void test_getPopularViewWeekly_fillWithRating() { ... } + + @Test + @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") + void test_getPopularViewMonthly() { ... } +} +``` + +**필요한 테스트 데이터:** +- `init-alcohols_view_history.sql` - 조회 기록 테스트 데이터 (신규 작성) + +### 2. REST Docs 테스트 (`RestPopularViewControllerTest.java`) +- 위치: `bottlenote-product-api/src/test/java/app/docs/alcohols/` +- 베이스 클래스: `AbstractRestDocs` 상속 +- Mock 사용: `AlcoholPopularService` mock + +**테스트 케이스:** +```java +@DisplayName("조회수 기반 인기 위스키 RestDocs 테스트") +class RestPopularViewControllerTest extends AbstractRestDocs { + + @Test + @DisplayName("주간 조회수 기반 인기 위스키를 조회할 수 있다") + void docs_getPopularViewWeekly() { + // document: "alcohols/populars/view/week" + } + + @Test + @DisplayName("월간 조회수 기반 인기 위스키를 조회할 수 있다") + void docs_getPopularViewMonthly() { + // document: "alcohols/populars/view/monthly" + } +} +``` + +### 3. 단위 테스트 (선택) +- `CustomPopularQueryRepositoryImpl` QueryDSL 로직 검증 +- InMemory Repository 패턴 또는 `@DataJpaTest` 사용 + +## QueryDSL 쿼리 설계 + +### 주간 조회수 기반 인기 주류 +```java +// 1. 이번 주 조회수 집계 +QAlcoholsViewHistory h = QAlcoholsViewHistory.alcoholsViewHistory; +QAlcohol a = QAlcohol.alcohol; +QRating r = QRating.rating; +QPicks p = QPicks.picks; + +LocalDateTime weekStart = LocalDate.now() + .with(DayOfWeek.MONDAY) + .atStartOfDay(); + +// 조회수 기반 결과 +List viewBasedResults = queryFactory + .select(new QPopularItem(...)) + .from(h) + .join(a).on(h.id.alcoholId.eq(a.id)) + .leftJoin(r).on(a.id.eq(r.id.alcoholId)) + .where(h.viewAt.goe(weekStart)) + .groupBy(h.id.alcoholId, a.korName, ...) + .orderBy(h.id.alcoholId.count().desc()) + .limit(top) + .fetch(); + +// 2. 부족분 평점 기반 채우기 +if (viewBasedResults.size() < top) { + List excludeIds = viewBasedResults.stream() + .map(PopularItem::alcoholId) + .toList(); + + int remaining = top - viewBasedResults.size(); + + List ratingBasedResults = queryFactory + .select(new QPopularItem(...)) + .from(a) + .join(r).on(a.id.eq(r.id.alcoholId)) + .where(a.id.notIn(excludeIds)) + .groupBy(a.id, ...) + .orderBy(r.ratingPoint.rating.avg().desc()) + .limit(remaining) + .fetch(); + + viewBasedResults.addAll(ratingBasedResults); +} +``` + +## 참고 테이블 + +### alcohols_view_histories +| 컬럼 | 타입 | 설명 | +|------|------|------| +| user_id | BIGINT | 사용자 ID (PK) | +| alcohol_id | BIGINT | 주류 ID (PK) | +| view_at | DATETIME | 조회 시점 | + +### ratings +| 컬럼 | 타입 | 설명 | +|------|------|------| +| user_id | BIGINT | 사용자 ID (PK) | +| alcohol_id | BIGINT | 주류 ID (PK) | +| rating | DOUBLE | 평점 (0.0~5.0) | + +--- + +## 구현 시 주의사항 (CLAUDE.md 기반) + +### 아키텍처 패턴 +- **계층 구조 준수**: Controller → Service → Repository → Domain +- **도메인 레포지토리**: `PopularQueryRepository`는 Spring/JPA에 의존하지 않는 순수 인터페이스 +- **서비스 계층**: 도메인 레포지토리 인터페이스에만 의존 + +### 네이밍 컨벤션 +- Custom 인터페이스: `Custom{도메인명}Repository` → `CustomPopularQueryRepository` +- 구현체: `Custom{도메인명}RepositoryImpl` → `CustomPopularQueryRepositoryImpl` +- 메서드: 조회는 `get/find/search` 사용 + +### QueryDSL 레포지토리 규칙 +- **Custom 인터페이스**: 어노테이션 불필요 (순수 인터페이스) +- **구현체**: 어노테이션 불필요 (Spring Data JPA가 `Impl` 접미사로 자동 감지) +- **위치**: `app.bottlenote.alcohols.repository` 패키지 + +### 코드 스타일 +- Lombok: `@Getter`, `@Builder`, `@RequiredArgsConstructor` 사용 +- DTO: `record` 사용 (불변성) +- 주석: 한 줄로 간략하게만 + +### 테스트 규칙 +- `@Tag("integration")`: 통합 테스트 태그 필수 +- `@DisplayName`: 한글로 테스트 목적 명시 +- 베이스 클래스: `IntegrationTestSupport` (통합), `AbstractRestDocs` (문서) +- 테스트 데이터: `src/test/resources/init-script/` 디렉토리 + +### 응답 형식 +- `GlobalResponse` 사용하여 응답 통일 +- 기존 `PopularsOfWeekResponse` 패턴 따르기 From 2de65eb6a0a6badbfc655e3ee365289faa5b3c27 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 13:29:20 +0900 Subject: [PATCH 87/95] =?UTF-8?q?refactor:=20batch=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=8F=85=EB=A6=BD=20=EC=8B=A4=ED=96=89=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- git.environment-variables | 2 +- .../{ => complete}/batch-module-separation.md | 26 + .../image-resource-management.md | 26 + plan/{ => complete}/popular-view-api.md | 23 + plan/sql-to-code-migration.md | 1175 ++++++++++------- 5 files changed, 771 insertions(+), 481 deletions(-) rename plan/{ => complete}/batch-module-separation.md (91%) rename plan/{ => complete}/image-resource-management.md (90%) rename plan/{ => complete}/popular-view-api.md (91%) diff --git a/git.environment-variables b/git.environment-variables index a465b5afc..0ae2274c4 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit a465b5afc1bff5d4dffb9512368038427a818f86 +Subproject commit 0ae2274c4fd6c7323dce877ac70b446993c4fccb diff --git a/plan/batch-module-separation.md b/plan/complete/batch-module-separation.md similarity index 91% rename from plan/batch-module-separation.md rename to plan/complete/batch-module-separation.md index 3d0539825..cd4544416 100644 --- a/plan/batch-module-separation.md +++ b/plan/complete/batch-module-separation.md @@ -1,3 +1,29 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **IN PROGRESS** +Start Date: 2024-11-21 +Last Updated: 2026-01-22 + +** Completed Work ** +- Product-API에서 batch 의존성 제거 +- BatchApplication.java 진입점 추가 (독립 실행 가능) +- Dockerfile-batch 생성 +- K3s 배포 리소스 추가 (batch-module.yaml, patch, secret) +- BestReviewReader 무한 루프 버그 수정 + +** Key Components ** +- BatchApplication.java: 배치 모듈 진입점 +- Dockerfile-batch: 배치 컨테이너 이미지 빌드 +- git.environment-variables/deploy/base/batch-module.yaml: K3s Deployment + +** Remaining Work ** +- GitHub Actions 워크플로우 추가 (batch 빌드/배포 자동화) +- 로컬/개발/운영 환경 배포 테스트 +================================================================================ +``` + # Batch 모듈 분리 계획 ## 개요 diff --git a/plan/image-resource-management.md b/plan/complete/image-resource-management.md similarity index 90% rename from plan/image-resource-management.md rename to plan/complete/image-resource-management.md index 1be9bdb1e..2b07329e8 100644 --- a/plan/image-resource-management.md +++ b/plan/complete/image-resource-management.md @@ -1,3 +1,29 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **IN PROGRESS** +Start Date: 2026-01-05 +Last Updated: 2026-01-22 + +** Completed Work ** +- Phase 1: 이벤트 클래스 추가 (ImageResourceInvalidatedEvent, ImageResourceDeletedEvent) +- Phase 2: ResourceCommandService에 invalidate/delete 메서드 추가 +- Phase 3: ResourceEventListener에 핸들러 추가 +- Phase 4-5: Review 도메인 수정/삭제 이벤트 발행 및 테스트 완료 + +** Key Components ** +- ImageResourceInvalidatedEvent.java: 이미지 교체 시 무효화 이벤트 +- ImageResourceDeletedEvent.java: 이미지 삭제 시 삭제 이벤트 +- ResourceEventListener.java: INVALIDATED/DELETED 핸들러 + +** Remaining Work ** +- User 도메인: 프로필 이미지 변경 시 INVALIDATED 이벤트 발행 +- Help 도메인: 수정/삭제 시 이벤트 발행 +- Business 도메인: 수정/삭제 시 이벤트 발행 +================================================================================ +``` + # 이미지 리소스 관리 분석 ## 1. 현재 아키텍처 개요 diff --git a/plan/popular-view-api.md b/plan/complete/popular-view-api.md similarity index 91% rename from plan/popular-view-api.md rename to plan/complete/popular-view-api.md index 490528ec8..ae24433be 100644 --- a/plan/popular-view-api.md +++ b/plan/complete/popular-view-api.md @@ -1,3 +1,26 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **IN PROGRESS** +Start Date: 2025-01-22 +Last Updated: 2026-01-22 + +** Completed Work ** +- Phase 1-6: 핵심 구현 완료 (QueryDSL Repository, Service, Controller) +- Phase 9: REST Docs 테스트 완료 (RestPopularControllerTest.java에 통합) + +** Key Components ** +- CustomPopularQueryRepository.java: QueryDSL 인터페이스 +- CustomPopularQueryRepositoryImpl.java: 조회수 집계 + 평점 fallback 로직 +- AlcoholPopularQueryController.java: /popular/view/week, /popular/view/monthly 엔드포인트 + +** Remaining Work ** +- Phase 7: init-alcohols_view_history.sql 테스트 데이터 작성 +- Phase 8: PopularViewIntegrationTest.java 통합 테스트 작성 +================================================================================ +``` + # 조회수 기반 인기 위스키 API 구현 계획 ## 개요 diff --git a/plan/sql-to-code-migration.md b/plan/sql-to-code-migration.md index 588432628..564330ff0 100644 --- a/plan/sql-to-code-migration.md +++ b/plan/sql-to-code-migration.md @@ -1,95 +1,127 @@ # @Sql 어노테이션을 코드 베이스 방식으로 마이그레이션 계획 -## 1. 현재 상황 분석 +## 목표 + +1. **@Sql 어노테이션 완전 제거** (45개 → 0개) +2. **init-script 폴더 삭제** (`bottlenote-product-api/src/test/resources/init-script/`) + +## 1. 현재 상황 분석 (2026-01-22 갱신) ### 1.1 @Sql 사용 현황 **통계:** -- **총 12개 테스트 파일**에서 **34개 @Sql 어노테이션** 사용 중 -- **9개의 SQL 스크립트 파일** 참조 -- 대부분 **메서드 레벨**에서 사용 (약 32개) -- 일부 **클래스/Nested 레벨**에서 사용 (약 3개) - -**사용 중인 SQL 스크립트 파일:** - -| 파일명 | 데이터 개수 | 주요 내용 | 사용 빈도 | -|--------|------------|----------|----------| -| `init-user.sql` | 8명 | 테스트 사용자 데이터 | 매우 높음 | -| `init-alcohol.sql` | 227개 | 지역(27), 증류소(179), 주류(21) | 매우 높음 | -| `init-review.sql` | 8개 | 리뷰 데이터 | 높음 | -| `init-review-reply.sql` | 7개 | 리뷰 댓글/대댓글 | 중간 | -| `init-help.sql` | 1개 | 도움말/문의 | 중간 | -| `init-user-mypage-query.sql` | 복합 | 마이페이지용 전체 데이터 | 중간 | -| `init-user-mybottle-query.sql` | 복합 | 마이보틀용 전체 데이터 | 중간 | -| `init-user-history.sql` | 5개 | 사용자 히스토리 | 낮음 | -| `init-popular_alcohol.sql` | 26개 | 인기 주류 통계 | 매우 낮음 | + +- **총 9개 테스트 파일**에서 **45개 @Sql 어노테이션** 사용 중 +- **11개의 SQL 스크립트 파일** 존재 + +**파일별 @Sql 사용 현황:** + +| 테스트 파일 | @Sql 개수 | 참조 SQL | +|------------|----------|---------| +| `ReviewIntegrationTest.java` | 10 | user, alcohol, review | +| `UserQueryIntegrationTest.java` | 9 | user, mypage-query, mybottle-query | +| `UserCommandIntegrationTest.java` | 7 | user | +| `UserHistoryIntegrationTest.java` | 6 | user-history | +| `ReviewReplyIntegrationTest.java` | 5 | user, alcohol, review, review-reply | +| `RatingIntegrationTest.java` | 3 | user, alcohol | +| `PicksIntegrationTest.java` | 2 | user, alcohol | +| `LikesIntegrationTest.java` | 2 | user, alcohol, review | +| `JpaAuditingIntegrationTest.java` | 1 | user | + +**SQL 스크립트 파일 (11개):** + +| 파일명 | 크기 | 주요 내용 | 사용 빈도 | +|--------------------------------|--------|--------------------------|-------| +| `init-user.sql` | 1.4KB | 테스트 사용자 데이터 | 매우 높음 | +| `init-alcohol.sql` | 15KB | 지역, 증류소, 주류 | 매우 높음 | +| `init-review.sql` | 3.5KB | 리뷰 데이터 | 높음 | +| `init-review-reply.sql` | 891B | 리뷰 댓글/대댓글 | 중간 | +| `init-help.sql` | 269B | 도움말/문의 | 미사용 | +| `init-user-mypage-query.sql` | 35KB | 마이페이지용 복합 데이터 | 중간 | +| `init-user-mybottle-query.sql` | 35KB | 마이보틀용 복합 데이터 | 중간 | +| `init-user-history.sql` | 35KB | 사용자 히스토리 | 중간 | +| `init-popular_alcohol.sql` | 1.4KB | 인기 주류 통계 | 미사용 | +| `schema.sql.bak` | 24KB | 스키마 백업 | 미사용 | ### 1.2 @Sql 사용 패턴 **패턴 1: 단일 SQL 파일 (간단한 테스트)** + ```java + @Sql(scripts = {"/init-script/init-user.sql"}) @Test -void 회원탈퇴에_성공한다() { ... } +void 회원탈퇴에_성공한다() { ...} ``` **패턴 2: 다중 SQL 파일 (복합 테스트)** + ```java + @Sql(scripts = { - "/init-script/init-user.sql", - "/init-script/init-alcohol.sql", - "/init-script/init-review.sql" + "/init-script/init-user.sql", + "/init-script/init-alcohol.sql", + "/init-script/init-review.sql" }) @Test -void 리뷰_목록을_조회할_수_있다() { ... } +void 리뷰_목록을_조회할_수_있다() { ...} ``` **패턴 3: ExecutionPhase 명시** + ```java + @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD + scripts = {"/init-script/init-user.sql"}, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD ) @Test -void test_1() { ... } +void test_1() { ...} ``` **패턴 4: Nested 클래스 레벨 적용** + ```java + @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-help.sql"}) @Nested -class HelpReadIntegrationTest { ... } +class HelpReadIntegrationTest { ... +} ``` ### 1.3 현재 프로젝트의 Fixture 인프라 **이미 구축된 인프라:** + - ✅ **Fixture 클래스**: `UserObjectFixture`, `ReviewObjectFixture`, `AlcoholQueryFixture` 등 - ✅ **TestFactory 클래스**: `UserTestFactory`, `AlcoholTestFactory`, `RatingTestFactory` 등 - ✅ **고급 기능**: 빌더 패턴, 누락 필드 자동 채우기, 연관 엔티티 자동 생성 **TestFactory 특징:** + ```java + @Component public class UserTestFactory { - @Autowired private EntityManager em; - - // 1. 기본 생성 - @Transactional - public User persistUser() { ... } - - // 2. 커스텀 생성 - @Transactional - public User persistUser(String email, String nickname) { ... } - - // 3. 빌더 패턴 (누락 필드 자동 채우기) - @Transactional - public User persistUser(User.UserBuilder builder) { - User.UserBuilder filledBuilder = fillMissingUserFields(builder); - User user = filledBuilder.build(); - em.persist(user); - return user; - } + @Autowired + private EntityManager em; + + // 1. 기본 생성 + @Transactional + public User persistUser() { ...} + + // 2. 커스텀 생성 + @Transactional + public User persistUser(String email, String nickname) { ...} + + // 3. 빌더 패턴 (누락 필드 자동 채우기) + @Transactional + public User persistUser(User.UserBuilder builder) { + User.UserBuilder filledBuilder = fillMissingUserFields(builder); + User user = filledBuilder.build(); + em.persist(user); + return user; + } } ``` @@ -100,69 +132,80 @@ public class UserTestFactory { ### 2.1 @Sql 방식의 문제점 #### 문제 1: 데이터 의존성 불명확 + ```java + @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-review.sql"}) @Test void 리뷰_수정_테스트() { - // SQL 파일을 열어보기 전까지 어떤 데이터가 있는지 알 수 없음 - // User ID가 1인지 2인지? Review ID는 무엇인지? - mockMvc.perform(patch("/api/v1/reviews/1") // ← 매직 넘버 - .contentType(MediaType.APPLICATION_JSON) - .content(...)) + // SQL 파일을 열어보기 전까지 어떤 데이터가 있는지 알 수 없음 + // User ID가 1인지 2인지? Review ID는 무엇인지? + mockMvc.perform(patch("/api/v1/reviews/1") // ← 매직 넘버 + .contentType(MediaType.APPLICATION_JSON) + .content(...)) } ``` **문제:** + - 테스트 코드만 보고 데이터 구조를 파악할 수 없음 - SQL 파일을 열어봐야 ID, 관계, 상태를 확인 가능 - 매직 넘버(1, 2, 3) 남발로 가독성 저하 #### 문제 2: 데이터 변경 영향 범위 파악 어려움 + ```sql -- init-user.sql 수정 -- 기존: 8명 → 변경: 7명 (한 명 삭제) ``` **영향:** + - 어떤 테스트가 영향을 받는지 파악하기 어려움 - SQL 파일 하나 수정 시 여러 테스트가 동시에 깨질 수 있음 - 의존하는 모든 테스트를 일일이 찾아야 함 #### 문제 3: 테스트 격리 및 독립성 위반 + ```java // TestA.java @Sql(scripts = {"/init-script/init-user.sql"}) @Test void testA() { - // User ID 1번 사용 + // User ID 1번 사용 } // TestB.java @Sql(scripts = {"/init-script/init-user.sql"}) @Test void testB() { - // User ID 1번 사용 (같은 데이터) + // User ID 1번 사용 (같은 데이터) } ``` **문제:** + - 여러 테스트가 동일한 SQL 스크립트에 의존 - SQL 스크립트 수정 시 모든 테스트에 영향 - 각 테스트가 필요한 데이터를 명시적으로 선언하지 않음 #### 문제 4: 복잡한 테스트 시나리오 표현 제한 + ```sql -- init-user.sql -- 단순 INSERT 문만 가능 -INSERT INTO users (email, nick_name, ...) VALUES (...); +INSERT INTO users (email, nick_name, ...) +VALUES (...); ``` **한계:** + - 조건부 데이터 생성 불가능 - 동적 데이터 생성 불가능 (예: 현재 시간, 랜덤 값) - 복잡한 관계 설정 어려움 #### 문제 5: 유지보수 비용 증가 + ``` bottlenote-product-api/src/test/resources/init-script/ ├── init-user.sql # 41줄 @@ -177,6 +220,7 @@ bottlenote-product-api/src/test/resources/init-script/ ``` **문제:** + - SQL 파일과 테스트 코드가 물리적으로 분리 - SQL 스크립트 중복 관리 (유사한 데이터를 여러 파일에서 관리) - 버전 관리 어려움 (엔티티 변경 시 SQL도 함께 수정 필요) @@ -184,6 +228,7 @@ bottlenote-product-api/src/test/resources/init-script/ ### 2.2 개선 필요성 **핵심 요구사항:** + 1. ✅ **명확성**: 테스트 코드만 보고 어떤 데이터가 사용되는지 명확히 파악 2. ✅ **독립성**: 각 테스트가 필요한 데이터를 스스로 생성 3. ✅ **유지보수성**: 엔티티 변경 시 컴파일 에러로 즉시 감지 @@ -197,45 +242,48 @@ bottlenote-product-api/src/test/resources/init-script/ ### 3.1 마이그레이션 목표 **주요 목표:** + - @Sql 어노테이션을 제거하고 TestFactory/Fixture 패턴으로 전환 - 테스트 데이터를 코드로 명시적으로 관리 - 기존 Fixture 인프라를 최대한 활용 **마이그레이션 후 모습:** + ```java // Before (AS-IS) @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-review.sql"}) @Test void 리뷰_수정_테스트() { - // 데이터 불명확 - mockMvc.perform(patch("/api/v1/reviews/1") - .contentType(MediaType.APPLICATION_JSON) - .content(...)) + // 데이터 불명확 + mockMvc.perform(patch("/api/v1/reviews/1") + .contentType(MediaType.APPLICATION_JSON) + .content(...)) } // After (TO-BE) @Test void 리뷰_수정_테스트() { - // Given: 명확한 데이터 준비 - User user = userTestFactory.persistUser("test@example.com", "테스터"); - Alcohol alcohol = alcoholTestFactory.persistAlcohol(); - Review review = reviewTestFactory.persistReview( - Review.builder() - .user(user) - .alcohol(alcohol) - .content("테스트 리뷰 내용") - ); - - // When & Then - mockMvc.perform(patch("/api/v1/reviews/" + review.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(...)) + // Given: 명확한 데이터 준비 + User user = userTestFactory.persistUser("test@example.com", "테스터"); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview( + Review.builder() + .user(user) + .alcohol(alcohol) + .content("테스트 리뷰 내용") + ); + + // When & Then + mockMvc.perform(patch("/api/v1/reviews/" + review.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(...)) } ``` ### 3.2 기대 효과 #### 효과 1: 가독성 및 명확성 향상 + ```java // 테스트 코드만 보고 즉시 이해 가능 User reviewer = userTestFactory.persistUser("reviewer@test.com", "리뷰어"); @@ -244,44 +292,48 @@ Review review = reviewTestFactory.persistReview(reviewer, whisky, "좋은 위스 ``` #### 효과 2: 테스트 독립성 보장 + ```java // 각 테스트가 자신만의 데이터를 생성 @Test void testA() { - User userA = userTestFactory.persistUser(); // 독립적인 사용자 + User userA = userTestFactory.persistUser(); // 독립적인 사용자 } @Test void testB() { - User userB = userTestFactory.persistUser(); // 다른 사용자 + User userB = userTestFactory.persistUser(); // 다른 사용자 } ``` #### 효과 3: 컴파일 타임 안정성 + ```java // 엔티티 필드 변경 시 컴파일 에러로 즉시 감지 User user = userTestFactory.persistUser( - User.builder() - .email("test@test.com") - .nickName("테스터") - .newField("새로운 필드") // ← 엔티티에 필드 추가 시 컴파일 에러 -); + User.builder() + .email("test@test.com") + .nickName("테스터") + .newField("새로운 필드") // ← 엔티티에 필드 추가 시 컴파일 에러 + ); ``` #### 효과 4: 유연한 시나리오 표현 + ```java // 조건부, 동적 데이터 생성 가능 List users = IntStream.range(0, 5) - .mapToObj(i -> userTestFactory.persistUser()) - .toList(); + .mapToObj(i -> userTestFactory.persistUser()) + .toList(); User inactiveUser = userTestFactory.persistUser( - User.builder() - .status(UserStatus.INACTIVE) // 특정 상태 설정 + User.builder() + .status(UserStatus.INACTIVE) // 특정 상태 설정 ); ``` #### 효과 5: 유지보수 비용 절감 + - SQL 파일 별도 관리 불필요 - 엔티티와 테스트 데이터가 같은 코드베이스에 존재 - IDE 리팩토링 도구 활용 가능 (Rename, Find Usages 등) @@ -322,16 +374,19 @@ Phase 5: 정리 및 검증 ### 4.2 점진적 마이그레이션 원칙 **1) 기존 테스트 깨지지 않기** + - 한 번에 하나의 테스트만 마이그레이션 - 각 테스트 마이그레이션 후 즉시 실행 및 검증 - 문제 발생 시 즉시 롤백 가능한 구조 **2) 하위 호환성 유지** + - SQL 파일은 마지막에 삭제 - 미처 마이그레이션하지 못한 테스트는 계속 @Sql 사용 가능 - 점진적 전환 지원 **3) 공통 패턴 우선 처리** + - 가장 많이 사용되는 SQL 파일부터 마이그레이션 - 재사용 가능한 Loader 패턴 먼저 구축 - 반복 작업 최소화 @@ -339,11 +394,13 @@ Phase 5: 정리 및 검증 ### 4.3 마이그레이션 우선순위 **우선순위 기준:** + 1. **사용 빈도**: 가장 많이 참조되는 SQL 파일 우선 2. **복잡도**: 단순한 파일부터 시작 3. **영향 범위**: 영향받는 테스트 수 고려 **마이그레이션 순서:** + ``` 1순위: init-user.sql (빈도: 매우 높음, 복잡도: 낮음) 2순위: init-review.sql (빈도: 높음, 복잡도: 낮음) @@ -363,6 +420,7 @@ Phase 5: 정리 및 검증 ### 5.1 설계 철학 **핵심 원칙:** + - ✅ **기존 TestFactory는 이동하지 않음** (bottlenote-product-api에 유지) - ✅ **기존 테스트를 깨뜨리지 않음** (import 경로 유지) - ✅ **점진적 마이그레이션** (한 번에 하나씩) @@ -370,16 +428,19 @@ Phase 5: 정리 및 검증 **채택하지 않은 방안과 이유:** **❌ 방안 1: bottlenote-mono/testFixtures에 TestFactory 배치** + - `java-test-fixtures`는 **순수 Java 라이브러리** - `@Component`, `@Autowired`, `@Transactional` **사용 불가** - EntityManager 주입 불가 → 실현 불가능 **❌ 방안 2: bottlenote-test-support 모듈 생성** + - 새로운 Spring Boot 모듈 추가 - 구조 복잡도 증가 - 유지보수 비용 증가 **✅ 채택한 방안: 기존 TestFactory 확장 (bottlenote-product-api 유지)** + - 기존 위치 그대로 유지 - SQL 대체 메서드만 추가 - 기존 테스트 호환성 100% 유지 @@ -387,6 +448,7 @@ Phase 5: 정리 및 검증 ### 5.2 디렉토리 구조 (변경 없음) **기존 구조 유지:** + ``` bottlenote-product-api/ └── src/test/java/ @@ -400,6 +462,7 @@ bottlenote-product-api/ ``` **변경사항: 없음** + - TestFactory를 이동하지 않음 - import 경로 변경 없음 - 모든 기존 테스트가 그대로 작동 @@ -420,11 +483,13 @@ bottlenote-mono/ ``` **⚠️ 주의: testFixtures는 Spring Bean 사용 불가** + - `@Component`, `@Autowired`, `@Transactional` 사용 불가 - EntityManager 주입 불가 - **순수 빌더 패턴만 가능** **현재 계획: testFixtures 사용 안 함** + - Phase 0~5에서 testFixtures 구성 작업 제외 - 기존 TestFactory만 확장 @@ -435,109 +500,117 @@ bottlenote-mono/ **기존 파일 수정 (이동 없음)** ```java + @Component @RequiredArgsConstructor public class UserTestFactory { - private final Random random = new SecureRandom(); - @Autowired private EntityManager em; - - // ========== 기존 메서드들 (유지) ========== - - /** 기본 User 생성 */ - @Transactional - public User persistUser() { /* 기존 코드 */ } - - /** 이메일과 닉네임으로 User 생성 */ - @Transactional - public User persistUser(String email, String nickName) { /* 기존 코드 */ } - - /** 빌더를 통한 User 생성 */ - @Transactional - public User persistUser(User.UserBuilder builder) { /* 기존 코드 */ } - - // ========== 새로 추가: SQL 대체 메서드들 ========== - - /** - * init-user.sql을 대체하는 표준 사용자 8명 생성 - * 기존 SQL과 동일한 데이터 구조 제공 - */ - @Transactional - public StandardUsers persistStandardUsers() { - User user1 = persistUser(User.builder() - .email("hyejj19@naver.com") - .nickName("WOzU6J8541") - .gender(GenderType.FEMALE) - .socialType(List.of(SocialType.KAKAO))); - - User user2 = persistUser(User.builder() - .email("chadongmin@naver.com") - .nickName("xIFo6J8726") - .gender(GenderType.MALE) - .socialType(List.of(SocialType.KAKAO))); - - User user3 = persistUser(User.builder() - .email("dev.bottle-note@gmail.com") - .nickName("PARC6J8814") - .age(25) - .gender(GenderType.MALE) - .socialType(List.of(SocialType.GOOGLE))); - - // ... 나머지 5명 - - return new StandardUsers(user1, user2, user3, user4, user5, user6, user7, user8); - } - - /** - * 사용자 N명 일괄 생성 - */ - @Transactional - public List persistUsers(int count) { - return IntStream.range(0, count) - .mapToObj(i -> persistUser()) - .toList(); - } + private final Random random = new SecureRandom(); + @Autowired + private EntityManager em; + + // ========== 기존 메서드들 (유지) ========== + + /** 기본 User 생성 */ + @Transactional + public User persistUser() { /* 기존 코드 */ } + + /** 이메일과 닉네임으로 User 생성 */ + @Transactional + public User persistUser(String email, String nickName) { /* 기존 코드 */ } + + /** 빌더를 통한 User 생성 */ + @Transactional + public User persistUser(User.UserBuilder builder) { /* 기존 코드 */ } + + // ========== 새로 추가: SQL 대체 메서드들 ========== + + /** + * init-user.sql을 대체하는 표준 사용자 8명 생성 + * 기존 SQL과 동일한 데이터 구조 제공 + */ + @Transactional + public StandardUsers persistStandardUsers() { + User user1 = persistUser(User.builder() + .email("hyejj19@naver.com") + .nickName("WOzU6J8541") + .gender(GenderType.FEMALE) + .socialType(List.of(SocialType.KAKAO))); + + User user2 = persistUser(User.builder() + .email("chadongmin@naver.com") + .nickName("xIFo6J8726") + .gender(GenderType.MALE) + .socialType(List.of(SocialType.KAKAO))); + + User user3 = persistUser(User.builder() + .email("dev.bottle-note@gmail.com") + .nickName("PARC6J8814") + .age(25) + .gender(GenderType.MALE) + .socialType(List.of(SocialType.GOOGLE))); + + // ... 나머지 5명 + + return new StandardUsers(user1, user2, user3, user4, user5, user6, user7, user8); + } + + /** + * 사용자 N명 일괄 생성 + */ + @Transactional + public List persistUsers(int count) { + return IntStream.range(0, count) + .mapToObj(i -> persistUser()) + .toList(); + } } ``` **핵심 변경:** + - ✅ TestFactory 제거 → UserTestFactory에 통합 - ✅ `persistStandardUsers()` → `persistStandardUsers()`로 일관성 유지 - ✅ 기존 메서드와 신규 메서드가 한 곳에 공존 #### StandardUsers DTO + ```java /** * init-user.sql의 8명 사용자를 표현하는 DTO * 기존 SQL 의존 코드를 쉽게 마이그레이션하기 위한 구조 */ public record StandardUsers( - User user1, // hyejj19@naver.com - User user2, // chadongmin@naver.com - User user3, // dev.bottle-note@gmail.com - User user4, // eva.park@oysterable.com - User user5, // rlagusrl928@gmail.com - User user6, // ytest@gmail.com - User user7, // juye@gmail.com - User user8 // rkdtkfma@naver.com -) { - public User getFirst() { return user1; } - public User getById(int index) { - return switch(index) { - case 1 -> user1; - case 2 -> user2; - case 3 -> user3; - case 4 -> user4; - case 5 -> user5; - case 6 -> user6; - case 7 -> user7; - case 8 -> user8; - default -> throw new IllegalArgumentException("Invalid index: " + index); - }; - } - public List toList() { - return List.of(user1, user2, user3, user4, user5, user6, user7, user8); - } + User user1, // hyejj19@naver.com + User user2, // chadongmin@naver.com + User user3, // dev.bottle-note@gmail.com + User user4, // eva.park@oysterable.com + User user5, // rlagusrl928@gmail.com + User user6, // ytest@gmail.com + User user7, // juye@gmail.com + User user8 // rkdtkfma@naver.com + ) { + public User getFirst() { + return user1; + } + + public User getById(int index) { + return switch (index) { + case 1 -> user1; + case 2 -> user2; + case 3 -> user3; + case 4 -> user4; + case 5 -> user5; + case 6 -> user6; + case 7 -> user7; + case 8 -> user8; + default -> throw new IllegalArgumentException("Invalid index: " + index); + }; + } + + public List toList() { + return List.of(user1, user2, user3, user4, user5, user6, user7, user8); + } } ``` @@ -548,123 +621,136 @@ public record StandardUsers( **기존 파일 수정 (이동 없음)** **특수성:** + - init-alcohol.sql은 231줄, 227개 데이터 (지역 27 + 증류소 179 + 주류 21) - 대용량이므로 **배치 처리** 및 **선택적 로딩** 필요 ```java + @Component @RequiredArgsConstructor public class AlcoholTestFactory { - private final Random random = new SecureRandom(); - @Autowired private EntityManager em; - - // ========== 기존 메서드들 (유지) ========== - - /** 기본 Alcohol 생성 */ - @Transactional - public Alcohol persistAlcohol() { /* 기존 코드 */ } - - // ========== 새로 추가: SQL 대체 메서드들 ========== - - /** - * init-alcohol.sql 전체 대체: 표준 데이터 생성 - * ⚠️ 성능 고려: 필요한 경우에만 사용 - */ - @Transactional - public StandardAlcohols persistStandardAlcohols() { - // 1. 지역 27개 생성 - List regions = persistStandardRegions(); - - // 2. 증류소 179개 생성 (배치 처리) - List distilleries = persistStandardDistilleries(); - - // 3. 주류 21개 생성 - List alcohols = persistStandardAlcoholList(regions, distilleries); - - return new StandardAlcohols(regions, distilleries, alcohols); - } - - /** - * 경량 데이터: 주요 지역 + 증류소 + 주류 각 3개씩만 생성 - * 대부분의 테스트에서 사용 권장 - */ - @Transactional - public LightweightAlcohols persistLightweightAlcohols() { - Region region1 = persistRegion("스코틀랜드", "Scotland"); - Region region2 = persistRegion("일본", "Japan"); - Region region3 = persistRegion("미국", "United States"); - - Distillery distillery1 = persistDistillery("맥캘란", "Macallan"); - Distillery distillery2 = persistDistillery("글렌피딕", "Glenfiddich"); - Distillery distillery3 = persistDistillery("야마자키", "Yamazaki"); - - Alcohol alcohol1 = persistAlcohol(AlcoholType.WHISKY, region1, distillery1); - Alcohol alcohol2 = persistAlcohol(AlcoholType.WHISKY, region2, distillery2); - Alcohol alcohol3 = persistAlcohol(AlcoholType.WHISKY, region3, distillery3); - - return new LightweightAlcohols( - List.of(region1, region2, region3), - List.of(distillery1, distillery2, distillery3), - List.of(alcohol1, alcohol2, alcohol3) - ); - } - - /** - * 표준 지역 27개 생성 (init-alcohol.sql 기준) - */ - private List persistStandardRegions() { - return List.of( - persistRegion("호주", "Australia"), - persistRegion("핀란드", "Finland"), - persistRegion("프랑스", "France"), - // ... 나머지 24개 - ); - } - - /** - * 표준 증류소 179개 생성 (배치 처리) - * ⚠️ 성능 최적화: JDBC Batch Insert 고려 - */ - private List persistStandardDistilleries() { - // TODO: JDBC Batch Insert로 성능 최적화 가능 - return List.of( - persistDistillery("글래스고", "The Glasgow Distillery Co."), - persistDistillery("글렌 그란트", "Glen Grant"), - // ... 나머지 177개 - ); - } + private final Random random = new SecureRandom(); + @Autowired + private EntityManager em; + + // ========== 기존 메서드들 (유지) ========== + + /** 기본 Alcohol 생성 */ + @Transactional + public Alcohol persistAlcohol() { /* 기존 코드 */ } + + // ========== 새로 추가: SQL 대체 메서드들 ========== + + /** + * init-alcohol.sql 전체 대체: 표준 데이터 생성 + * ⚠️ 성능 고려: 필요한 경우에만 사용 + */ + @Transactional + public StandardAlcohols persistStandardAlcohols() { + // 1. 지역 27개 생성 + List regions = persistStandardRegions(); + + // 2. 증류소 179개 생성 (배치 처리) + List distilleries = persistStandardDistilleries(); + + // 3. 주류 21개 생성 + List alcohols = persistStandardAlcoholList(regions, distilleries); + + return new StandardAlcohols(regions, distilleries, alcohols); + } + + /** + * 경량 데이터: 주요 지역 + 증류소 + 주류 각 3개씩만 생성 + * 대부분의 테스트에서 사용 권장 + */ + @Transactional + public LightweightAlcohols persistLightweightAlcohols() { + Region region1 = persistRegion("스코틀랜드", "Scotland"); + Region region2 = persistRegion("일본", "Japan"); + Region region3 = persistRegion("미국", "United States"); + + Distillery distillery1 = persistDistillery("맥캘란", "Macallan"); + Distillery distillery2 = persistDistillery("글렌피딕", "Glenfiddich"); + Distillery distillery3 = persistDistillery("야마자키", "Yamazaki"); + + Alcohol alcohol1 = persistAlcohol(AlcoholType.WHISKY, region1, distillery1); + Alcohol alcohol2 = persistAlcohol(AlcoholType.WHISKY, region2, distillery2); + Alcohol alcohol3 = persistAlcohol(AlcoholType.WHISKY, region3, distillery3); + + return new LightweightAlcohols( + List.of(region1, region2, region3), + List.of(distillery1, distillery2, distillery3), + List.of(alcohol1, alcohol2, alcohol3) + ); + } + + /** + * 표준 지역 27개 생성 (init-alcohol.sql 기준) + */ + private List persistStandardRegions() { + return List.of( + persistRegion("호주", "Australia"), + persistRegion("핀란드", "Finland"), + persistRegion("프랑스", "France"), + // ... 나머지 24개 + ); + } + + /** + * 표준 증류소 179개 생성 (배치 처리) + * ⚠️ 성능 최적화: JDBC Batch Insert 고려 + */ + private List persistStandardDistilleries() { + // TODO: JDBC Batch Insert로 성능 최적화 가능 + return List.of( + persistDistillery("글래스고", "The Glasgow Distillery Co."), + persistDistillery("글렌 그란트", "Glen Grant"), + // ... 나머지 177개 + ); + } } ``` **핵심 변경:** + - ✅ 기존 위치 유지 (bottlenote-product-api) - ✅ SQL 대체 메서드 추가 (persistStandardAlcohols, persistLightweightAlcohols) - ✅ 기존 메서드 재사용 (persistRegion, persistDistillery, persistAlcohol) #### StandardAlcohols / LightweightAlcohols DTO + ```java public record StandardAlcohols( - List regions, // 27개 - List distilleries, // 179개 - List alcohols // 21개 + List regions, // 27개 + List distilleries, // 179개 + List alcohols // 21개 ) { - public Region getRegionByName(String korName) { - return regions.stream() - .filter(r -> r.getKorName().contains(korName)) - .findFirst() - .orElseThrow(); - } + public Region getRegionByName(String korName) { + return regions.stream() + .filter(r -> r.getKorName().contains(korName)) + .findFirst() + .orElseThrow(); + } } public record LightweightAlcohols( - List regions, // 3개 - List distilleries, // 3개 - List alcohols // 3개 + List regions, // 3개 + List distilleries, // 3개 + List alcohols // 3개 ) { - public Region getFirstRegion() { return regions.get(0); } - public Distillery getFirstDistillery() { return distilleries.get(0); } - public Alcohol getFirstAlcohol() { return alcohols.get(0); } + public Region getFirstRegion() { + return regions.get(0); + } + + public Distillery getFirstDistillery() { + return distilleries.get(0); + } + + public Alcohol getFirstAlcohol() { + return alcohols.get(0); + } } ``` @@ -675,96 +761,111 @@ public record LightweightAlcohols( 복합 SQL 파일(init-user-mypage-query.sql 등)은 별도의 Composite 클래스 없이 각 TestFactory를 테스트 메서드에서 직접 조합하여 사용합니다. **장점:** + - ✅ 추가 계층 없이 명확하고 간단 - ✅ 테스트 코드에서 필요한 데이터만 정확히 생성 - ✅ 불필요한 중간 DTO(MyPageTestData 등) 제거 **예시:** + ```java + @Test void 마이페이지_조회_테스트() { - // Given: 각 TestFactory를 직접 조합 - StandardUsers users = userTestFactory.persistStandardUsers(); - LightweightAlcohols alcohols = alcoholTestFactory.persistLightweightAlcohols(); - Review review1 = reviewTestFactory.persistReview(users.user1(), alcohols.getFirstAlcohol()); - Review review2 = reviewTestFactory.persistReview(users.user1(), alcohols.alcohols().get(1)); - - // When & Then - mockMvc.perform(get("/api/v1/users/" + users.user1().getId() + "/mypage")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.reviewCount").value(2)); + // Given: 각 TestFactory를 직접 조합 + StandardUsers users = userTestFactory.persistStandardUsers(); + LightweightAlcohols alcohols = alcoholTestFactory.persistLightweightAlcohols(); + Review review1 = reviewTestFactory.persistReview(users.user1(), alcohols.getFirstAlcohol()); + Review review2 = reviewTestFactory.persistReview(users.user1(), alcohols.alcohols().get(1)); + + // When & Then + mockMvc.perform(get("/api/v1/users/" + users.user1().getId() + "/mypage")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reviewCount").value(2)); } ``` ### 5.6 TestFactory 사용 예시 #### Before (AS-IS) + ```java + @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-alcohol.sql", "/init-script/init-review.sql"}) @Test void 리뷰_목록을_조회할_수_있다() throws Exception { - // 데이터 불명확, SQL 파일 열어봐야 함 - mockMvc.perform(get("/api/v1/reviews") - .param("alcoholId", "1")) // 매직 넘버 - .andExpect(status().isOk()); + // 데이터 불명확, SQL 파일 열어봐야 함 + mockMvc.perform(get("/api/v1/reviews") + .param("alcoholId", "1")) // 매직 넘버 + .andExpect(status().isOk()); } ``` #### After (TO-BE) - TestFactory 직접 사용 + ```java -@Autowired private UserTestFactory userTestFactory; -@Autowired private AlcoholTestFactory alcoholTestFactory; -@Autowired private ReviewTestFactory reviewTestFactory; + +@Autowired +private UserTestFactory userTestFactory; +@Autowired +private AlcoholTestFactory alcoholTestFactory; +@Autowired +private ReviewTestFactory reviewTestFactory; @Test void 리뷰_목록을_조회할_수_있다() throws Exception { - // Given: 명확한 데이터 준비 - User user = userTestFactory.persistUser(); - Alcohol alcohol = alcoholTestFactory.persistAlcohol(); - Review review = reviewTestFactory.persistReview(user, alcohol); - - // When & Then - mockMvc.perform(get("/api/v1/reviews") - .param("alcoholId", String.valueOf(alcohol.getId()))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content[0].userId").value(user.getId())); + // Given: 명확한 데이터 준비 + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(user, alcohol); + + // When & Then + mockMvc.perform(get("/api/v1/reviews") + .param("alcoholId", String.valueOf(alcohol.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].userId").value(user.getId())); } ``` #### After (TO-BE) - 옵션 2: 완전 커스텀 + ```java -@Autowired private UserTestFactory userTestFactory; -@Autowired private AlcoholTestFactory alcoholTestFactory; -@Autowired private ReviewTestFactory reviewTestFactory; + +@Autowired +private UserTestFactory userTestFactory; +@Autowired +private AlcoholTestFactory alcoholTestFactory; +@Autowired +private ReviewTestFactory reviewTestFactory; @Test void 특정_상태의_리뷰만_조회된다() throws Exception { - // Given: 필요한 데이터만 정확히 생성 - User user = userTestFactory.persistUser(); - Alcohol alcohol = alcoholTestFactory.persistAlcohol(); - - Review publicReview = reviewTestFactory.persistReview( - Review.builder() - .user(user) - .alcohol(alcohol) - .status(ReviewStatus.PUBLIC) // 명시적 상태 설정 - .content("공개 리뷰") - ); - - Review privateReview = reviewTestFactory.persistReview( - Review.builder() - .user(user) - .alcohol(alcohol) - .status(ReviewStatus.PRIVATE) // 명시적 상태 설정 - .content("비공개 리뷰") - ); - - // When & Then: PUBLIC 리뷰만 조회되어야 함 - mockMvc.perform(get("/api/v1/reviews") - .param("alcoholId", String.valueOf(alcohol.getId()))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content.length()").value(1)) - .andExpect(jsonPath("$.data.content[0].reviewId").value(publicReview.getId())); + // Given: 필요한 데이터만 정확히 생성 + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + + Review publicReview = reviewTestFactory.persistReview( + Review.builder() + .user(user) + .alcohol(alcohol) + .status(ReviewStatus.PUBLIC) // 명시적 상태 설정 + .content("공개 리뷰") + ); + + Review privateReview = reviewTestFactory.persistReview( + Review.builder() + .user(user) + .alcohol(alcohol) + .status(ReviewStatus.PRIVATE) // 명시적 상태 설정 + .content("비공개 리뷰") + ); + + // When & Then: PUBLIC 리뷰만 조회되어야 함 + mockMvc.perform(get("/api/v1/reviews") + .param("alcoholId", String.valueOf(alcohol.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].reviewId").value(publicReview.getId())); } ``` @@ -775,49 +876,57 @@ void 특정_상태의_리뷰만_조회된다() throws Exception { ### Phase 0: 준비 단계 #### 목표 + - 기존 TestFactory에 SQL 대체 메서드 추가 - 파일럿 테스트를 통한 마이그레이션 방식 검증 #### 작업 내용 **1) UserTestFactory 보강** (bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java) + - `persistStandardUsers()`: init-user.sql 대체 (표준 8명 사용자 생성) - `persistUsers(int count)`: N명 사용자 생성 - `StandardUsers` record 정의 (생성된 8명의 사용자 참조) **예시:** + ```java + @Component @RequiredArgsConstructor public class UserTestFactory { - @Autowired private EntityManager em; - - // 기존 메서드들... - - /** - * init-user.sql과 동일한 8명의 표준 사용자 생성 - */ - @Transactional - public StandardUsers persistStandardUsers() { - User user1 = persistUser("test1@test.com", "테스터1"); - User user2 = persistUser("test2@test.com", "테스터2"); - // ... 8명 생성 - return new StandardUsers(user1, user2, ...); - } - - public record StandardUsers( - User user1, User user2, User user3, User user4, - User user5, User user6, User user7, User user8 - ) {} + @Autowired + private EntityManager em; + + // 기존 메서드들... + + /** + * init-user.sql과 동일한 8명의 표준 사용자 생성 + */ + @Transactional + public StandardUsers persistStandardUsers() { + User user1 = persistUser("test1@test.com", "테스터1"); + User user2 = persistUser("test2@test.com", "테스터2"); + // ... 8명 생성 + return new StandardUsers(user1, user2, ...); + } + + public record StandardUsers( + User user1, User user2, User user3, User user4, + User user5, User user6, User user7, User user8 + ) { + } } ``` **2) 파일럿 테스트** + - UserCommandIntegrationTest 중 1개 메서드만 마이그레이션 - 기존 @Sql 방식과 새 방식 성능 비교 **검증 기준:** + - ✅ UserTestFactory로 생성한 데이터가 init-user.sql과 동일한 구조 - ✅ 파일럿 테스트가 성공적으로 통과 - ✅ 성능 차이 10% 이내 @@ -830,6 +939,7 @@ public class UserTestFactory { #### Phase 1-1: init-user.sql 마이그레이션 **영향받는 테스트 파일 (7개):** + 1. `UserCommandIntegrationTest` (7개 메서드) 2. `UserQueryIntegrationTest` (5개 메서드) 3. `ReviewIntegrationTest` (일부) @@ -839,6 +949,7 @@ public class UserTestFactory { 7. `UserHistoryIntegrationTest` (일부) **마이그레이션 순서:** + ``` 1. UserCommandIntegrationTest (7개 메서드) ├─ test_1: 회원탈퇴 @@ -860,31 +971,34 @@ public class UserTestFactory { ``` **작업 방법:** + ```java // 1단계: @Sql 제거 -- @Sql(scripts = {"/init-script/init-user.sql"}) +-@Sql(scripts = {"/init-script/init-user.sql"}) + // @Sql(scripts = {"/init-script/init-user.sql"}) // 주석 처리 (롤백 대비) // 2단계: TestFactory 주입 -@Autowired private UserTestFactory userTestFactory; +@Autowired +private UserTestFactory userTestFactory; // 3단계: Given 절에 데이터 로딩 추가 @Test void 회원탈퇴에_성공한다() throws Exception { - // Given -+ User user = userTestFactory.persistUser(); - - // When & Then - mockMvc.perform(delete("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) -- .header("Authorization", "Bearer " + getToken()) -+ .header("Authorization", "Bearer " + getToken(user)) // 명시적 사용자 지정 - .with(csrf())) + // Given + +User user = userTestFactory.persistUser(); + + // When & Then + mockMvc.perform(delete("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + -.header("Authorization", "Bearer " + getToken()) + + .header("Authorization", "Bearer " + getToken(user)) // 명시적 사용자 지정 + .with(csrf())) .andExpect(status().isOk()); } ``` **검증:** + - 각 메서드 변경 후 즉시 테스트 실행 - 실패 시 즉시 롤백 (주석 해제) @@ -893,33 +1007,37 @@ void 회원탈퇴에_성공한다() throws Exception { #### Phase 1-2: init-alcohol.sql 마이그레이션 ⚠️ **난이도: 높음** + - 227개 데이터 (지역 27 + 증류소 179 + 주류 21) - 대용량 데이터 처리 전략 필요 **작업 내용:** + 1. `AlcoholTestFactory` 구현 2. 경량 데이터 제공: `persistLightweightAlcohols()` (각 3개씩) 3. 전체 데이터 제공: `persistStandardAlcohols()` (227개 전체) 4. 성능 최적화: JDBC Batch Insert 적용 고려 **마이그레이션 전략:** + ```java // 대부분의 테스트: 경량 데이터 사용 @Test void 주류_목록_조회() { - LightweightAlcohols alcohols = alcoholTestFactory.persistLightweightAlcohols(); - // 3개 주류만 사용 + LightweightAlcohols alcohols = alcoholTestFactory.persistLightweightAlcohols(); + // 3개 주류만 사용 } // 대용량 데이터가 필요한 테스트만: 전체 데이터 @Test void 전체_주류_페이징_조회() { - StandardAlcohols alcohols = alcoholTestFactory.persistStandardAlcohols(); - // 227개 전체 사용 + StandardAlcohols alcohols = alcoholTestFactory.persistStandardAlcohols(); + // 227개 전체 사용 } ``` **성능 측정:** + - 경량 데이터 로딩 시간: 목표 < 100ms - 전체 데이터 로딩 시간: 목표 < 1000ms - 필요시 캐싱 전략 도입 @@ -929,35 +1047,40 @@ void 전체_주류_페이징_조회() { #### Phase 1-3: init-review.sql 마이그레이션 **영향받는 테스트 파일:** + - `ReviewIntegrationTest` (2개 메서드) - `ReviewReplyIntegrationTest` (2개 메서드) **작업 내용:** + 1. `ReviewTestFactory` 구현 2. `persistStandardReviews()` 메서드 (8개 리뷰) 3. `persistReview()` 메서드 **예시:** + ```java + @Component @RequiredArgsConstructor public class ReviewTestFactory { - @Autowired private EntityManager em; - - @Transactional - public Review persistReview(User user, Alcohol alcohol) { - Review review = Review.builder() - .user(user) - .alcohol(alcohol) - .content("테스트 리뷰 내용") - .status(ReviewStatus.PUBLIC) - .sizeType(SizeType.BOTTLE) - .price(65000L) - .build(); - em.persist(review); - return review; - } + @Autowired + private EntityManager em; + + @Transactional + public Review persistReview(User user, Alcohol alcohol) { + Review review = Review.builder() + .user(user) + .alcohol(alcohol) + .content("테스트 리뷰 내용") + .status(ReviewStatus.PUBLIC) + .sizeType(SizeType.BOTTLE) + .price(65000L) + .build(); + em.persist(review); + return review; + } } ``` @@ -966,14 +1089,17 @@ public class ReviewTestFactory { ### Phase 2: 중빈도 파일 마이그레이션 #### Phase 2-1: init-review-reply.sql + - `ReviewReplyTestFactory`에 `persistStandardReplies()` 메서드 추가 - 댓글/대댓글 계층 구조 지원 #### Phase 2-2: init-help.sql + - `HelpTestFactory`에 `persistStandardHelps()` 메서드 추가 - 단순 구조이므로 빠른 마이그레이션 가능 #### Phase 2-3: init-user-history.sql + - `UserHistoryTestFactory`에 `persistStandardHistories()` 메서드 추가 - 5개 히스토리 레코드 생성 @@ -982,11 +1108,13 @@ public class ReviewTestFactory { ### Phase 3: 복합 파일 마이그레이션 #### Phase 3-1: init-user-mypage-query.sql + - 여러 TestFactory를 조합하여 데이터 생성 - User + Alcohol + Review + Follow + Rating 조합 - 예: `userTestFactory`, `alcoholTestFactory`, `reviewTestFactory` 함께 사용 #### Phase 3-2: init-user-mybottle-query.sql + - 여러 TestFactory를 조합하여 데이터 생성 - User + Alcohol + Review + Pick 조합 - 예: `userTestFactory`, `alcoholTestFactory`, `pickTestFactory` 함께 사용 @@ -996,6 +1124,7 @@ public class ReviewTestFactory { ### Phase 4: 저빈도 파일 마이그레이션 #### init-popular_alcohol.sql + - `AlcoholTestFactory`에 `persistPopularAlcohols()` 메서드 추가 - 26개 인기 주류 통계 데이터 @@ -1004,23 +1133,24 @@ public class ReviewTestFactory { ### Phase 5: 정리 및 검증 #### 작업 내용: + 1. **SQL 파일 삭제** - - `/init-script/` 디렉토리 전체 삭제 - - 사용하지 않는 @Sql import 제거 + - `/init-script/` 디렉토리 전체 삭제 + - 사용하지 않는 @Sql import 제거 2. **문서화** - - TestFactory 사용 가이드 작성 - - 마이그레이션 전후 비교 문서 - - Best Practices 문서 + - TestFactory 사용 가이드 작성 + - 마이그레이션 전후 비교 문서 + - Best Practices 문서 3. **최종 검증** - - 전체 통합 테스트 실행 - - 성능 측정 및 비교 - - 커버리지 확인 + - 전체 통합 테스트 실행 + - 성능 측정 및 비교 + - 커버리지 확인 4. **팀 공유** - - 마이그레이션 결과 발표 - - Q&A 세션 + - 마이그레이션 결과 발표 + - Q&A 세션 --- @@ -1029,40 +1159,46 @@ public class ReviewTestFactory { ### 예시 1: 단순 사용자 테스트 #### Before + ```java + @Sql(scripts = {"/init-script/init-user.sql"}) @Test void 회원탈퇴에_성공한다() throws Exception { - mockMvc.perform(delete("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) // 어떤 사용자인지 불명확 - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)); + mockMvc.perform(delete("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) // 어떤 사용자인지 불명확 + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); } ``` #### After + ```java -@Autowired private UserTestFactory userTestFactory; + +@Autowired +private UserTestFactory userTestFactory; @Test void 회원탈퇴에_성공한다() throws Exception { - // Given: 명확한 사용자 생성 - User user = userTestFactory.persistUser(); - String token = authSupport.getToken(user); - - // When & Then - mockMvc.perform(delete("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(user.getId())); + // Given: 명확한 사용자 생성 + User user = userTestFactory.persistUser(); + String token = authSupport.getToken(user); + + // When & Then + mockMvc.perform(delete("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(user.getId())); } ``` **개선점:** + - ✅ 어떤 사용자가 탈퇴하는지 명확 - ✅ 사용자 ID를 검증에 활용 가능 - ✅ 필요한 데이터만 생성 (8명 → 1명) @@ -1072,58 +1208,66 @@ void 회원탈퇴에_성공한다() throws Exception { ### 예시 2: 복합 데이터 테스트 #### Before + ```java + @Sql(scripts = { - "/init-script/init-user.sql", - "/init-script/init-alcohol.sql", - "/init-script/init-review.sql" + "/init-script/init-user.sql", + "/init-script/init-alcohol.sql", + "/init-script/init-review.sql" }) @Test void 리뷰_수정에_성공한다() throws Exception { - // 어떤 리뷰를 수정하는지 불명확 - mockMvc.perform(patch("/api/v1/reviews/1") // 매직 넘버 - .contentType(MediaType.APPLICATION_JSON) - .content("{\"content\":\"수정된 내용\"}")) - .andExpect(status().isOk()); + // 어떤 리뷰를 수정하는지 불명확 + mockMvc.perform(patch("/api/v1/reviews/1") // 매직 넘버 + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"수정된 내용\"}")) + .andExpect(status().isOk()); } ``` #### After + ```java -@Autowired private UserTestFactory userTestFactory; -@Autowired private AlcoholTestFactory alcoholTestFactory; -@Autowired private ReviewTestFactory reviewTestFactory; + +@Autowired +private UserTestFactory userTestFactory; +@Autowired +private AlcoholTestFactory alcoholTestFactory; +@Autowired +private ReviewTestFactory reviewTestFactory; @Test void 리뷰_수정에_성공한다() throws Exception { - // Given: 정확히 필요한 데이터만 생성 - User reviewer = userTestFactory.persistUser("reviewer@test.com", "리뷰어"); - Alcohol whisky = alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY); - Review review = reviewTestFactory.persistReview( - Review.builder() - .user(reviewer) - .alcohol(whisky) - .content("원본 리뷰 내용") - .status(ReviewStatus.PUBLIC) - ); - String token = authSupport.getToken(reviewer); - - // When - String updatedContent = "수정된 내용"; - mockMvc.perform(patch("/api/v1/reviews/" + review.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .content("{\"content\":\"" + updatedContent + "\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content").value(updatedContent)); - - // Then: DB에서 확인 - Review updated = reviewRepository.findById(review.getId()).orElseThrow(); - assertEquals(updatedContent, updated.getContent()); + // Given: 정확히 필요한 데이터만 생성 + User reviewer = userTestFactory.persistUser("reviewer@test.com", "리뷰어"); + Alcohol whisky = alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY); + Review review = reviewTestFactory.persistReview( + Review.builder() + .user(reviewer) + .alcohol(whisky) + .content("원본 리뷰 내용") + .status(ReviewStatus.PUBLIC) + ); + String token = authSupport.getToken(reviewer); + + // When + String updatedContent = "수정된 내용"; + mockMvc.perform(patch("/api/v1/reviews/" + review.getId()) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .content("{\"content\":\"" + updatedContent + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").value(updatedContent)); + + // Then: DB에서 확인 + Review updated = reviewRepository.findById(review.getId()).orElseThrow(); + assertEquals(updatedContent, updated.getContent()); } ``` **개선점:** + - ✅ 테스트 의도가 명확 (어떤 리뷰를 수정하는지) - ✅ 필요한 데이터만 생성 (8+227+8 → 1+1+1) - ✅ 매직 넘버 제거 @@ -1134,39 +1278,45 @@ void 리뷰_수정에_성공한다() throws Exception { ### 예시 3: 특정 상태 테스트 #### Before (불가능) + ```java + @Sql(scripts = {"/init-script/init-user.sql"}) @Test void 비활성_사용자는_로그인_불가() throws Exception { - // ❌ init-user.sql에는 모두 ACTIVE 사용자만 존재 - // ❌ 비활성 사용자를 테스트할 수 없음 + // ❌ init-user.sql에는 모두 ACTIVE 사용자만 존재 + // ❌ 비활성 사용자를 테스트할 수 없음 } ``` #### After (가능) + ```java -@Autowired private UserTestFactory userTestFactory; + +@Autowired +private UserTestFactory userTestFactory; @Test void 비활성_사용자는_로그인_불가() throws Exception { - // Given: 명시적으로 비활성 사용자 생성 - User inactiveUser = userTestFactory.persistUser( - User.builder() - .email("inactive@test.com") - .nickName("비활성유저") - .status(UserStatus.INACTIVE) // ✅ 명시적 상태 설정 - ); - - // When & Then - mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"email\":\"" + inactiveUser.getEmail() + "\"}")) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.message").value("비활성 사용자입니다")); + // Given: 명시적으로 비활성 사용자 생성 + User inactiveUser = userTestFactory.persistUser( + User.builder() + .email("inactive@test.com") + .nickName("비활성유저") + .status(UserStatus.INACTIVE) // ✅ 명시적 상태 설정 + ); + + // When & Then + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + inactiveUser.getEmail() + "\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").value("비활성 사용자입니다")); } ``` **개선점:** + - ✅ SQL로 불가능했던 시나리오를 코드로 구현 가능 - ✅ 다양한 상태 조합 테스트 가능 - ✅ 경계값 테스트 용이 @@ -1176,36 +1326,42 @@ void 비활성_사용자는_로그인_불가() throws Exception { ### 예시 4: 대용량 데이터 테스트 #### Before + ```java + @Sql(scripts = {"/init-script/init-alcohol.sql"}) // 227개 전체 로드 @Test void 주류_3개만_조회_테스트() throws Exception { - mockMvc.perform(get("/api/v1/alcohols") - .param("size", "3")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content.length()").value(3)); + mockMvc.perform(get("/api/v1/alcohols") + .param("size", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(3)); } ``` #### After + ```java -@Autowired private AlcoholTestFactory alcoholTestFactory; + +@Autowired +private AlcoholTestFactory alcoholTestFactory; @Test void 주류_3개만_조회_테스트() throws Exception { - // Given: 필요한 만큼만 생성 (227개 → 3개) - LightweightAlcohols alcohols = alcoholTestFactory.persistLightweightAlcohols(); - - // When & Then - mockMvc.perform(get("/api/v1/alcohols") - .param("size", "3")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content.length()").value(3)) - .andExpect(jsonPath("$.data.content[0].alcoholId").value(alcohols.getFirstAlcohol().getId())); + // Given: 필요한 만큼만 생성 (227개 → 3개) + LightweightAlcohols alcohols = alcoholTestFactory.persistLightweightAlcohols(); + + // When & Then + mockMvc.perform(get("/api/v1/alcohols") + .param("size", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(3)) + .andExpect(jsonPath("$.data.content[0].alcoholId").value(alcohols.getFirstAlcohol().getId())); } ``` **개선점:** + - ✅ 불필요한 224개 데이터 로딩 제거 - ✅ 테스트 실행 속도 향상 - ✅ 메모리 사용량 감소 @@ -1217,113 +1373,149 @@ void 주류_3개만_조회_테스트() throws Exception { ### 8.1 마이그레이션 시 주의사항 #### 주의 1: 트랜잭션 경계 + ```java // ❌ 잘못된 예: 트랜잭션 외부에서 Lazy Loading @Test void test() { - User user = userTestFactory.persistUser(); // @Transactional + User user = userTestFactory.persistUser(); // @Transactional - // 트랜잭션 종료됨 + // 트랜잭션 종료됨 - List reviews = user.getReviews(); // ❌ LazyInitializationException + List reviews = user.getReviews(); // ❌ LazyInitializationException } // ✅ 올바른 예: 필요한 데이터는 즉시 로딩 @Test void test() { - User user = userTestFactory.persistUser(); - List reviews = reviewTestFactory.persistReviews(user, 5); // ✅ 명시적 로딩 + User user = userTestFactory.persistUser(); + List reviews = reviewTestFactory.persistReviews(user, 5); // ✅ 명시적 로딩 } ``` #### 주의 2: ID 의존성 + ```java // ❌ 잘못된 예: 하드코딩된 ID @Test void test() { - userTestFactory.persistStandardUsers(); + userTestFactory.persistStandardUsers(); - mockMvc.perform(get("/api/v1/users/1")) // ❌ ID=1이라는 보장 없음 + mockMvc.perform(get("/api/v1/users/1")) // ❌ ID=1이라는 보장 없음 } // ✅ 올바른 예: 생성된 객체의 ID 사용 @Test void test() { - User user = userTestFactory.persistUser(); + User user = userTestFactory.persistUser(); - mockMvc.perform(get("/api/v1/users/" + user.getId())) // ✅ 실제 ID 사용 + mockMvc.perform(get("/api/v1/users/" + user.getId())) // ✅ 실제 ID 사용 } ``` -#### 주의 3: 데이터 정리 +#### 주의 3: IntegrationTestSupport 트랜잭션 특성 + ```java -// DataInitializer.deleteAll()은 여전히 @AfterEach에서 실행 -// 코드로 생성한 데이터도 자동으로 정리됨 +// IntegrationTestSupport는 클래스 레벨에 @Transactional이 없음 +// 따라서 각 테스트 메서드는 독립적인 트랜잭션을 가지지 않음 + +// @Sql 방식: SQL 실행 후 테스트 메서드 종료 시 자동 롤백 +// TestFactory 방식: persist 호출 시 즉시 커밋됨 → @AfterEach에서 수동 삭제 필요 + @AfterEach void cleanUpAfterEach() { - dataCleaner.cleanAll(); // 모든 데이터 삭제 + dataInitializer.deleteAll(); // 모든 데이터 삭제 (IntegrationTestSupport에 구현됨) } ``` +**핵심 차이점:** +- `@Sql`: 테스트 전용 트랜잭션에서 실행, 테스트 후 롤백 +- `TestFactory.persist*()`: 즉시 커밋, `dataInitializer.deleteAll()`로 정리 + #### 주의 4: 성능 고려 + ```java // ❌ 불필요한 대용량 데이터 생성 @Test void 단순_조회_테스트() { - StandardAlcohols alcohols = alcoholTestFactory.persistStandardAlcohols(); // 227개 - // 실제로는 1개만 필요 + StandardAlcohols alcohols = alcoholTestFactory.persistStandardAlcohols(); // 227개 + // 실제로는 1개만 필요 } // ✅ 필요한 만큼만 생성 @Test void 단순_조회_테스트() { - Alcohol alcohol = alcoholTestFactory.persistAlcohol(); // 1개 + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); // 1개 } ``` ### 8.2 마이그레이션 체크리스트 **작업 전 준비** + - [ ] 제거할 @Sql 파일 위치 확인 및 데이터 구조 파악 - [ ] 필요한 TestFactory가 이미 존재하는지 확인 (없으면 생성 필요) - - 확인 경로: `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/` - - 또는: `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/` + - 확인 경로: `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/` + - 또는: `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/` - [ ] 기존 TestFactory들의 사용 패턴 확인 (특히 email, 필드 주입 방식) **Factory 생성/사용 시** + - [ ] EntityManager는 `@Autowired` 필드 주입 사용 (생성자 주입 X) - [ ] UserTestFactory.persistUser()는 로컬 파트만 전달 (`"user1"` ⭕ / `"user1@test.com"` ❌) - [ ] 5가지 팩토리 원칙 준수 확인 (단일책임, 격리, 순수성, 명시성, 응집성) **토큰 생성 시** + - [ ] 이미 생성한 User 객체가 있다면 `getToken(user)` 사용 - [ ] `getToken(OauthRequest)` 사용 금지 (유저 중복 생성 위험) - [ ] 파라미터 없는 `getToken()`은 기본 유저 사용 (역할이 명확하지 않을 때만) **ID 관리** + - [ ] 하드코딩된 userId 사용 금지 (1, 2, 3, 4 등) - [ ] 모든 유저는 Factory로 실제 생성 후 `.getId()` 사용 - [ ] @Sql의 `INSERT ... VALUES (1, ...)` 패턴을 Factory로 변환 시 특히 주의 **@Nested 클래스 처리** + - [ ] @Nested 클래스는 `extends` 없이 선언 (자동 상속됨) - [ ] @BeforeEach는 @Nested 클래스 내부에 작성 (필요 시) - [ ] 외부 클래스의 @Autowired 필드는 @Nested에서 자동 접근 가능 **작업 후 검증** + - [ ] `grep "@Sql" {파일명}` 으로 모든 @Sql 제거 확인 - [ ] `grep "getToken(oauthRequest)" {파일명}` 으로 잘못된 토큰 생성 확인 - [ ] `grep -E "userId.*\([0-9]+L?\)" {파일명}` 으로 하드코딩된 ID 확인 - [ ] 불필요한 import 제거 (OauthRequest, SocialType 등) - [ ] 로컬에서 해당 테스트 파일 실행하여 통과 확인 +**테스트 실행 명령어** + +```bash +# 특정 테스트 클래스 실행 +./gradlew :bottlenote-product-api:test --tests "ReviewIntegrationTest" + +# 특정 테스트 메서드 실행 +./gradlew :bottlenote-product-api:test --tests "ReviewIntegrationTest.리뷰_작성에_성공한다" + +# 통합 테스트 전체 실행 (@Tag("integration")) +./gradlew integration_test + +# 단위 테스트 전체 실행 (@Tag("unit")) +./gradlew unit_test +``` + **핵심 원칙** + - [ ] **기존 패턴 먼저 확인**: 프로젝트의 다른 테스트들이 어떻게 하는지 보기 - [ ] **한 곳 수정 시 전체 적용**: 동일한 실수가 여러 곳에 있을 수 있음 - [ ] **내부 구현 파악**: 사용하는 메서드가 무엇을 하는지 확인 - [ ] **@Sql vs Factory 차이 인식**: ID 관리 방식이 다름 **Phase별 체크리스트:** + - [ ] 모든 테스트 파일 마이그레이션 완료 - [ ] SQL 파일 삭제 완료 - [ ] 문서화 완료 @@ -1338,48 +1530,56 @@ void 단순_조회_테스트() { 이 섹션은 실제 마이그레이션 과정에서 발생한 버그와 학습 내용을 정리한 것입니다. #### 버그 1. HelpTestFactory EntityManager 주입 방식 오류 + - **문제**: `@RequiredArgsConstructor`로 생성자 주입을 시도했지만 EntityManager가 주입되지 않음 - **원인**: 기존 TestFactory 패턴을 확인하지 않고 일반적인 생성자 주입 방식 사용 - **해결**: `@Autowired private EntityManager em` 필드 주입으로 변경 - **교훈**: 새로운 Factory 생성 시 반드시 기존 패턴 확인 #### 버그 2. @Nested 클래스가 IntegrationTestSupport 중복 상속 + - **문제**: `@Nested class XXX extends IntegrationTestSupport` 로 작성하여 중복 상속 발생 - **원인**: JUnit5 @Nested 클래스는 외부 클래스의 상속을 자동으로 공유한다는 기본 동작 간과 - **해결**: @Nested 클래스에서 `extends` 제거 - **교훈**: @Nested 클래스는 상속 없이 선언 #### 버그 3. getToken(oauthRequest)로 인한 이메일 중복 문제 + - **문제**: 이미 testUser를 생성했는데 OauthRequest로 또 다른 유저를 생성하려고 시도하여 unique constraint 위반 - **원인**: `getToken(OauthRequest)`가 내부적으로 `oauthService.login()`을 호출하여 유저를 생성/조회한다는 점을 파악하지 못함 - **해결**: `getToken(testUser)` 사용으로 이미 생성한 User 객체 직접 전달 - **교훈**: 사용하는 메서드의 내부 구현을 반드시 확인 #### 버그 4. UserTestFactory 이메일 형식 불일치 + - **문제**: `persistUser("help-read@test.com", ...)`처럼 전체 이메일을 파라미터로 전달하여 "help-read@test.com-12345@example.com" 형식의 잘못된 이메일 생성 - **원인**: UserTestFactory는 로컬 파트만 받고 자동으로 `@example.com`을 붙이는 구현이라는 것을 확인하지 않음 - **해결**: `persistUser("help-read", ...)`처럼 로컬 파트만 전달 - **교훈**: Factory 메서드 사용 전 내부 구현 확인 필수 #### 버그 5. test_3에서 하드코딩된 userId로 인한 ID 충돌 + - **문제**: `IntStream.range(1, 5)`로 userId를 1,2,3,4로 하드코딩하여 fifthReporter.getId()와 충돌 발생 - **원인**: @Sql은 ID를 직접 지정하지만 Factory는 auto_increment로 자동 생성되어 충돌 가능하다는 점 간과 - **해결**: 1~4번째 신고자도 실제 User 객체를 userTestFactory로 생성 - **교훈**: @Sql과 Factory의 ID 관리 방식 차이 인식 중요 #### 버그 6. test_4에서 reporter 유저 미생성 + - **문제**: `getToken()` 파라미터 없이 호출하여 신고자가 명확하지 않음 - **원인**: 테스트에서 "신고자"와 "피신고자" 역할을 명확히 구분해야 한다는 점 간과 - **해결**: reporter 유저를 명시적으로 생성하고 `getToken(reporter)` 사용 - **교훈**: 테스트에서 역할이 명확해야 할 때는 각 유저를 명시적으로 생성 #### 버그 7. HelpIntegrationTest에서 getToken(oauthRequest) 잔존 + - **문제**: @Nested 내부는 수정했지만 외부 클래스와 일부 @Nested에 getToken(oauthRequest) 2곳 남음 - **원인**: 전체 파일을 grep으로 재확인하지 않고 일부만 수정 - **해결**: `grep "getToken(oauthRequest)" {파일명}` 으로 전체 파일 검증 후 모두 수정 - **교훈**: 수정 후 반드시 grep으로 전체 파일 재확인 #### 근본 원인 요약 + - **내부 구현 미파악**: 사용하는 메서드의 내부 동작을 확인하지 않음 - **패턴 불일치**: 기존 테스트 코드와 Factory들의 패턴을 충분히 분석하지 않음 - **@Sql과 Factory의 차이 간과**: ID 관리 방식의 근본적 차이를 간과 @@ -1387,6 +1587,7 @@ void 단순_조회_테스트() { - **검증 부족**: 수정 후 grep 등으로 전체 재확인하지 않음 #### 핵심 교훈 + 1. **프레임워크/라이브러리 메서드 사용 전 내부 구현 확인** 2. **기존 코드 패턴 철저히 분석 후 동일하게 적용** 3. **데이터 생성 방식 변경 시 ID 관리 전략 재검토** @@ -1400,25 +1601,30 @@ void 단순_조회_테스트() { ### 리스크 1: 성능 저하 **원인:** + - 코드로 데이터 생성 시 SQL INSERT보다 느릴 수 있음 - JPA 영속성 컨텍스트 오버헤드 **대응:** + - 경량 데이터 제공 (필요한 만큼만 생성) - JDBC Batch Insert 적용 (대용량 데이터) - 성능 측정 후 최적화 **기준:** + - 개별 테스트 실행 시간 10% 이내 증가 허용 - 전체 테스트 Suite 실행 시간 5% 이내 증가 허용 ### 리스크 2: 테스트 깨짐 **원인:** + - 데이터 생성 순서 변경 - ID 의존성 문제 **대응:** + - 한 번에 하나씩 마이그레이션 - 각 단계마다 즉시 검증 - 롤백 가능한 구조 유지 (주석 처리) @@ -1426,10 +1632,12 @@ void 단순_조회_테스트() { ### 리스크 3: 팀원 혼란 **원인:** + - 새로운 패턴에 익숙하지 않음 - 문서 부족 **대응:** + - 충분한 문서화 - 예제 코드 제공 - Q&A 세션 진행 @@ -1439,12 +1647,14 @@ void 단순_조회_테스트() { ## 10. 성공 지표 ### 정량적 지표 + 1. **마이그레이션 완료율**: 100% (34개 @Sql → 0개) 2. **테스트 통과율**: 100% 유지 3. **성능**: 개별 테스트 +10% 이내, 전체 Suite +5% 이내 4. **코드 라인**: SQL 라인 수 → 0줄 ### 정성적 지표 + 1. **가독성**: 테스트 코드만 보고 데이터 구조 파악 가능 2. **유지보수성**: 엔티티 변경 시 컴파일 에러로 즉시 감지 3. **확장성**: 새로운 테스트 시나리오 추가 용이 @@ -1455,17 +1665,20 @@ void 단순_조회_테스트() { ## 11. 다음 단계 ### 즉시 실행 + 1. **Phase 0 시작** - - TestFactory 인프라 구축 - - UserTestFactory 구현 - - 파일럿 테스트 (1개 메서드) + - TestFactory 인프라 구축 + - UserTestFactory 구현 + - 파일럿 테스트 (1개 메서드) ### 후속 작업 + 1. Phase 1-5 순차적 실행 2. 진행 상황 리뷰 3. 이슈 발생 시 즉시 대응 ### 장기 계획 + 1. 다른 모듈(batch 등)에도 적용 2. TestFactory 패턴을 팀 표준으로 확립 3. 신규 개발자 온보딩 자료에 포함 @@ -1477,6 +1690,7 @@ void 단순_조회_테스트() { 이 마이그레이션 계획은 **@Sql 방식의 한계**를 극복하고 **코드 베이스 방식의 장점**을 최대한 활용하는 것을 목표로 합니다. **핵심 개선사항:** + - ✅ 테스트 데이터를 명시적이고 명확하게 관리 - ✅ SQL 파일 의존성 제거로 유지보수성 향상 - ✅ 컴파일 타임 안정성 확보 @@ -1484,6 +1698,7 @@ void 단순_조회_테스트() { - ✅ 기존 TestFactory 인프라 최대한 활용 **기대 효과:** + - 테스트 코드 가독성 대폭 향상 - 유지보수 비용 절감 - 테스트 독립성 보장 From 0d674be33fe650b92986cc35180060f25f95e059 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 14:34:08 +0900 Subject: [PATCH 88/95] =?UTF-8?q?feat:=20TestDataSetupHelper=20=EB=B0=8F?= =?UTF-8?q?=20TestData=20record=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Sql 기반 테스트 데이터 설정을 TestFactory 패턴으로 대체하기 위한 헬퍼 클래스 추가: - TestDataSetupHelper: 복합 테스트 데이터 생성 헬퍼 - HistoryTestData: 히스토리 테스트용 데이터 record - MyPageTestData: 마이페이지 테스트용 데이터 record - MyBottleTestData: 마이보틀 테스트용 데이터 record - UserTestFactory: persistUserWithNickname 메서드 추가 Co-Authored-By: Claude Opus 4.5 --- .../common/fixture/HistoryTestData.java | 67 +++++ .../common/fixture/MyBottleTestData.java | 71 +++++ .../common/fixture/MyPageTestData.java | 71 +++++ .../common/fixture/TestDataSetupHelper.java | 249 ++++++++++++++++++ .../user/fixture/UserTestFactory.java | 18 ++ 5 files changed, 476 insertions(+) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/common/fixture/HistoryTestData.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyBottleTestData.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyPageTestData.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/common/fixture/TestDataSetupHelper.java diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/HistoryTestData.java b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/HistoryTestData.java new file mode 100644 index 000000000..e18fb7d34 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/HistoryTestData.java @@ -0,0 +1,67 @@ +package app.bottlenote.common.fixture; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.history.constant.EventCategory; +import app.bottlenote.history.constant.EventType; +import app.bottlenote.history.domain.UserHistory; +import app.bottlenote.user.domain.User; +import java.util.List; + +/** + * 히스토리 테스트용 복합 데이터 record + * + *

    기존 init-user-history.sql 대체용 + */ +public record HistoryTestData( + List users, List alcohols, List histories) { + + /** + * 특정 인덱스의 사용자 반환 + * + * @param index 0-based index + * @return User 객체 + */ + public User getUser(int index) { + return users.get(index); + } + + /** + * 특정 인덱스의 알코올 반환 + * + * @param index 0-based index + * @return Alcohol 객체 + */ + public Alcohol getAlcohol(int index) { + return alcohols.get(index); + } + + /** + * 특정 사용자의 히스토리 목록 반환 + * + * @param userId 사용자 ID + * @return 해당 사용자의 히스토리 목록 + */ + public List getHistoriesByUser(Long userId) { + return histories.stream().filter(h -> h.getUserId().equals(userId)).toList(); + } + + /** + * 특정 이벤트 카테고리의 히스토리 목록 반환 + * + * @param category 이벤트 카테고리 + * @return 해당 카테고리의 히스토리 목록 + */ + public List getHistoriesByCategory(EventCategory category) { + return histories.stream().filter(h -> h.getEventCategory() == category).toList(); + } + + /** + * 특정 이벤트 타입의 히스토리 목록 반환 + * + * @param eventType 이벤트 타입 + * @return 해당 타입의 히스토리 목록 + */ + public List getHistoriesByEventType(EventType eventType) { + return histories.stream().filter(h -> h.getEventType() == eventType).toList(); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyBottleTestData.java b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyBottleTestData.java new file mode 100644 index 000000000..78ba8a876 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyBottleTestData.java @@ -0,0 +1,71 @@ +package app.bottlenote.common.fixture; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.picks.domain.Picks; +import app.bottlenote.rating.domain.Rating; +import app.bottlenote.user.domain.Follow; +import app.bottlenote.user.domain.User; +import java.util.List; + +/** + * 마이보틀 테스트용 복합 데이터 record + * + *

    기존 init-user-mybottle-query.sql 대체용 + */ +public record MyBottleTestData( + List users, + List alcohols, + List follows, + List picks, + List ratings) { + + /** + * 특정 인덱스의 사용자 반환 + * + * @param index 0-based index + * @return User 객체 + */ + public User getUser(int index) { + return users.get(index); + } + + /** + * 특정 인덱스의 알코올 반환 + * + * @param index 0-based index + * @return Alcohol 객체 + */ + public Alcohol getAlcohol(int index) { + return alcohols.get(index); + } + + /** + * 특정 사용자의 찜 목록 반환 + * + * @param userId 사용자 ID + * @return 해당 사용자의 찜 목록 + */ + public List getPicksByUser(Long userId) { + return picks.stream().filter(p -> p.getUserId().equals(userId)).toList(); + } + + /** + * 특정 사용자의 별점 목록 반환 + * + * @param userId 사용자 ID + * @return 해당 사용자의 별점 목록 + */ + public List getRatingsByUser(Long userId) { + return ratings.stream().filter(r -> r.getId().getUserId().equals(userId)).toList(); + } + + /** + * 특정 사용자가 팔로우하는 목록 반환 + * + * @param userId 사용자 ID + * @return 해당 사용자가 팔로우하는 Follow 목록 + */ + public List getFollowingsByUser(Long userId) { + return follows.stream().filter(f -> f.getUserId().equals(userId)).toList(); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyPageTestData.java b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyPageTestData.java new file mode 100644 index 000000000..d91940ede --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/MyPageTestData.java @@ -0,0 +1,71 @@ +package app.bottlenote.common.fixture; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.rating.domain.Rating; +import app.bottlenote.review.domain.Review; +import app.bottlenote.user.domain.Follow; +import app.bottlenote.user.domain.User; +import java.util.List; + +/** + * 마이페이지 테스트용 복합 데이터 record + * + *

    기존 init-user-mypage-query.sql 대체용 + */ +public record MyPageTestData( + List users, + List alcohols, + List reviews, + List follows, + List ratings) { + + /** + * 특정 인덱스의 사용자 반환 + * + * @param index 0-based index + * @return User 객체 + */ + public User getUser(int index) { + return users.get(index); + } + + /** + * 특정 인덱스의 알코올 반환 + * + * @param index 0-based index + * @return Alcohol 객체 + */ + public Alcohol getAlcohol(int index) { + return alcohols.get(index); + } + + /** + * 특정 사용자의 리뷰 목록 반환 + * + * @param userId 사용자 ID + * @return 해당 사용자의 리뷰 목록 + */ + public List getReviewsByUser(Long userId) { + return reviews.stream().filter(r -> r.getUserId().equals(userId)).toList(); + } + + /** + * 특정 사용자가 팔로우하는 목록 반환 + * + * @param userId 사용자 ID + * @return 해당 사용자가 팔로우하는 Follow 목록 + */ + public List getFollowingsByUser(Long userId) { + return follows.stream().filter(f -> f.getUserId().equals(userId)).toList(); + } + + /** + * 특정 사용자의 팔로워 목록 반환 + * + * @param userId 사용자 ID + * @return 해당 사용자를 팔로우하는 Follow 목록 + */ + public List getFollowersByUser(Long userId) { + return follows.stream().filter(f -> f.getTargetUserId().equals(userId)).toList(); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/TestDataSetupHelper.java b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/TestDataSetupHelper.java new file mode 100644 index 000000000..b1f9150ec --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/fixture/TestDataSetupHelper.java @@ -0,0 +1,249 @@ +package app.bottlenote.common.fixture; + +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; +import app.bottlenote.history.constant.EventType; +import app.bottlenote.history.domain.UserHistory; +import app.bottlenote.history.fixture.HistoryTestFactory; +import app.bottlenote.picks.constant.PicksStatus; +import app.bottlenote.picks.domain.Picks; +import app.bottlenote.picks.fixture.PicksTestFactory; +import app.bottlenote.rating.domain.Rating; +import app.bottlenote.rating.fixture.RatingTestFactory; +import app.bottlenote.review.domain.Review; +import app.bottlenote.review.fixture.ReviewTestFactory; +import app.bottlenote.user.domain.Follow; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.fixture.UserTestFactory; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 복합 테스트 데이터 생성 헬퍼 + * + *

    기존 SQL 파일(init-user-mypage-query.sql, init-user-mybottle-query.sql, init-user-history.sql)을 + * 대체하는 TestFactory 기반 데이터 생성 헬퍼 + */ +@Component +@RequiredArgsConstructor +public class TestDataSetupHelper { + + private final UserTestFactory userTestFactory; + private final AlcoholTestFactory alcoholTestFactory; + private final ReviewTestFactory reviewTestFactory; + private final RatingTestFactory ratingTestFactory; + private final PicksTestFactory picksTestFactory; + private final HistoryTestFactory historyTestFactory; + + /** + * 마이페이지 테스트 데이터 생성 + * + *

    기존 init-user-mypage-query.sql 대체 + * + *

      + *
    • 사용자 8명 + *
    • 알코올 5개 + *
    • 리뷰 5개 (user[2], user[3], user[0], user[1], user[4] 각각 1개) + *
    • 팔로우 관계 3개 + *
    • 별점 3개 + *
    + */ + @Transactional + public MyPageTestData setupMyPageTestData() { + // 사용자 8명 생성 + List users = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + users.add(userTestFactory.persistUser()); + } + + // 알코올 5개 생성 + List alcohols = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + alcohols.add(alcoholTestFactory.persistAlcohol()); + } + + // 리뷰 5개 생성 (기존 SQL: user 3,4,1,2,5 순서 -> 0-indexed: 2,3,0,1,4) + List reviews = new ArrayList<>(); + reviews.add(reviewTestFactory.persistReview(users.get(2), alcohols.get(0))); + reviews.add(reviewTestFactory.persistReview(users.get(3), alcohols.get(0))); + reviews.add(reviewTestFactory.persistReview(users.get(0), alcohols.get(0))); + reviews.add(reviewTestFactory.persistReview(users.get(1), alcohols.get(1))); + reviews.add(reviewTestFactory.persistReview(users.get(4), alcohols.get(1))); + + // 팔로우 관계 3개 생성 (기존 SQL: 1->2, 2->3, 3->1) + List follows = new ArrayList<>(); + follows.add(userTestFactory.persistFollow(users.get(0), users.get(1))); + follows.add(userTestFactory.persistFollow(users.get(1), users.get(2))); + follows.add(userTestFactory.persistFollow(users.get(2), users.get(0))); + + // 별점 3개 생성 (기존 SQL: user1-alcohol1, user2-alcohol1, user3-alcohol2) + List ratings = new ArrayList<>(); + ratings.add(ratingTestFactory.persistRating(users.get(0), alcohols.get(0), 5)); + ratings.add(ratingTestFactory.persistRating(users.get(1), alcohols.get(0), 4)); + ratings.add(ratingTestFactory.persistRating(users.get(2), alcohols.get(1), 5)); + + return new MyPageTestData(users, alcohols, reviews, follows, ratings); + } + + /** + * 마이보틀 테스트 데이터 생성 + * + *

    기존 init-user-mybottle-query.sql 대체 + * + *

      + *
    • 사용자 8명 + *
    • 알코올 8개 + *
    • 팔로우 관계 4개 + *
    • 찜 8개 (PICK 4개, UNPICK 4개) + *
    • 별점 11개 + *
    + */ + @Transactional + public MyBottleTestData setupMyBottleTestData() { + // 사용자 8명 생성 + List users = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + users.add(userTestFactory.persistUser()); + } + + // 알코올 8개 생성 + List alcohols = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + alcohols.add(alcoholTestFactory.persistAlcohol()); + } + + // 팔로우 관계 4개 생성 (기존 SQL: 8->1, 8->2, 8->3, 3->1 / 0-indexed: 7->0, 7->1, 7->2, 2->0) + List follows = new ArrayList<>(); + follows.add(userTestFactory.persistFollow(users.get(7), users.get(0))); + follows.add(userTestFactory.persistFollow(users.get(7), users.get(1))); + follows.add(userTestFactory.persistFollow(users.get(7), users.get(2))); + follows.add(userTestFactory.persistFollow(users.get(2), users.get(0))); + + // 찜 8개 생성 (기존 SQL: PICK 4개, UNPICK 4개) + List picks = new ArrayList<>(); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(1).getId(), users.get(2).getId(), PicksStatus.PICK)); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(2).getId(), users.get(3).getId(), PicksStatus.UNPICK)); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(0).getId(), users.get(7).getId(), PicksStatus.PICK)); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(3).getId(), users.get(3).getId(), PicksStatus.UNPICK)); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(4).getId(), users.get(0).getId(), PicksStatus.UNPICK)); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(5).getId(), users.get(0).getId(), PicksStatus.UNPICK)); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(6).getId(), users.get(0).getId(), PicksStatus.PICK)); + picks.add( + picksTestFactory.persistPicks( + alcohols.get(7).getId(), users.get(0).getId(), PicksStatus.PICK)); + + // 별점 11개 생성 + List ratings = new ArrayList<>(); + ratings.add(ratingTestFactory.persistRating(users.get(2), alcohols.get(0), 4)); // 3.5 -> 4 + ratings.add(ratingTestFactory.persistRating(users.get(5), alcohols.get(0), 4)); // 3.5 -> 4 + ratings.add(ratingTestFactory.persistRating(users.get(2), alcohols.get(1), 4)); // 3.5 -> 4 + ratings.add(ratingTestFactory.persistRating(users.get(7), alcohols.get(1), 4)); // 3.5 -> 4 + ratings.add(ratingTestFactory.persistRating(users.get(5), alcohols.get(1), 4)); + ratings.add(ratingTestFactory.persistRating(users.get(0), alcohols.get(2), 5)); // 4.5 -> 5 + ratings.add(ratingTestFactory.persistRating(users.get(0), alcohols.get(3), 5)); // 4.5 -> 5 + ratings.add(ratingTestFactory.persistRating(users.get(0), alcohols.get(4), 4)); + ratings.add(ratingTestFactory.persistRating(users.get(3), alcohols.get(5), 5)); + ratings.add(ratingTestFactory.persistRating(users.get(0), alcohols.get(6), 5)); // 4.5 -> 5 + ratings.add(ratingTestFactory.persistRating(users.get(3), alcohols.get(0), 1)); // 0.5 -> 1 + + return new MyBottleTestData(users, alcohols, follows, picks, ratings); + } + + /** + * 히스토리 테스트 데이터 생성 + * + *

    기존 init-user-history.sql 대체 + * + *

      + *
    • 사용자 8명 + *
    • 알코올 5개 + *
    • 사용자 히스토리 5개 (RATING 1개, REVIEW 3개, PICK 1개) + *
    + */ + @Transactional + public HistoryTestData setupHistoryTestData() { + // 사용자 8명 생성 + List users = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + users.add(userTestFactory.persistUser()); + } + + // 알코올 5개 생성 + List alcohols = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + alcohols.add(alcoholTestFactory.persistAlcohol()); + } + + // 히스토리 5개 생성 (user[0] 기준) + List histories = new ArrayList<>(); + histories.add( + historyTestFactory.persistUserHistory( + users.get(0).getId(), EventType.START_RATING, alcohols.get(0).getId())); + histories.add( + historyTestFactory.persistUserHistory( + users.get(0).getId(), EventType.REVIEW_CREATE, alcohols.get(0).getId(), "blah blah")); + histories.add( + historyTestFactory.persistUserHistory( + users.get(0).getId(), EventType.REVIEW_CREATE, alcohols.get(1).getId(), "리뷰입니다.")); + histories.add( + historyTestFactory.persistUserHistory( + users.get(0).getId(), EventType.REVIEW_CREATE, alcohols.get(2).getId(), "리뷰 등록")); + histories.add( + historyTestFactory.persistUserHistory( + users.get(0).getId(), EventType.UNPICK, alcohols.get(0).getId())); + + return new HistoryTestData(users, alcohols, histories); + } + + /** + * 기본 사용자와 알코올만 생성 (단순 테스트용) + * + *

    기존 init-user.sql + init-alcohol.sql 대체 (간소화 버전) + * + * @param userCount 생성할 사용자 수 + * @param alcoholCount 생성할 알코올 수 + * @return 사용자 목록과 알코올 목록 + */ + @Transactional + public SimpleTestData setupSimpleTestData(int userCount, int alcoholCount) { + List users = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + users.add(userTestFactory.persistUser()); + } + + List alcohols = new ArrayList<>(); + for (int i = 0; i < alcoholCount; i++) { + alcohols.add(alcoholTestFactory.persistAlcohol()); + } + + return new SimpleTestData(users, alcohols); + } + + /** 기본 테스트 데이터 record */ + public record SimpleTestData(List users, List alcohols) { + public User getUser(int index) { + return users.get(index); + } + + public Alcohol getAlcohol(int index) { + return alcohols.get(index); + } + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java index 4b4ca913f..a1d2a4d6d 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/UserTestFactory.java @@ -62,6 +62,24 @@ public User persistUser(@NotNull String email, @NotNull String nickName) { return user; } + /** 특정 닉네임으로 User 생성 (닉네임 중복 테스트용) */ + @Transactional + @NotNull + public User persistUserWithNickname(@NotNull String nickName) { + User user = + User.builder() + .email("user" + generateRandomSuffix() + "@example.com") + .nickName(nickName) + .age(25) + .gender(GenderType.MALE) + .socialType(List.of(SocialType.KAKAO)) + .role(UserType.ROLE_USER) + .build(); + em.persist(user); + em.flush(); + return user; + } + /** 빌더를 통한 User 생성 - 누락 필드 자동 채우기 */ @Transactional @NotNull From 86364a6d73293850a5c6a44a132061035ab7240d Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 14:35:48 +0900 Subject: [PATCH 89/95] =?UTF-8?q?refactor:=20Picks/Rating/Likes=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20@Sql=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Sql 어노테이션을 TestFactory 패턴으로 대체: - PicksIntegrationTest: SQL 스크립트 → TestFactory 사용 - RatingIntegrationTest: SQL 스크립트 → TestFactory 사용 - LikesIntegrationTest: SQL 스크립트 → TestFactory 사용 Co-Authored-By: Claude Opus 4.5 --- .../integration/LikesIntegrationTest.java | 62 ++++++++----- .../integration/PicksIntegrationTest.java | 40 +++++--- .../integration/RatingIntegrationTest.java | 91 ++++++++++--------- 3 files changed, 115 insertions(+), 78 deletions(-) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java index 008a5502a..ac2df9390 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java @@ -11,19 +11,25 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.like.constant.LikeStatus; import app.bottlenote.like.domain.Likes; import app.bottlenote.like.domain.LikesRepository; import app.bottlenote.like.dto.request.LikesUpdateRequest; import app.bottlenote.like.dto.response.LikesUpdateResponse; +import app.bottlenote.review.domain.Review; +import app.bottlenote.review.fixture.ReviewTestFactory; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.dto.response.TokenItem; +import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @@ -31,26 +37,30 @@ class LikesIntegrationTest extends IntegrationTestSupport { @Autowired private LikesRepository likesRepository; + @Autowired private UserTestFactory userTestFactory; + @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private ReviewTestFactory reviewTestFactory; @DisplayName("좋아요를 등록할 수 있다.") @Test - @Sql( - scripts = { - "/init-script/init-user.sql", - "/init-script/init-alcohol.sql", - "/init-script/init-review.sql" - }) void test_1() throws Exception { + // Given + User reviewAuthor = userTestFactory.persistUser(); + User likeUser = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(reviewAuthor, alcohol); + TokenItem token = getToken(likeUser); - LikesUpdateRequest likesUpdateRequest = new LikesUpdateRequest(1L, LikeStatus.LIKE); + LikesUpdateRequest likesUpdateRequest = new LikesUpdateRequest(review.getId(), LikeStatus.LIKE); + // When & Then MvcResult result = mockMvc .perform( put("/api/v1/likes") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(likesUpdateRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -68,40 +78,44 @@ void test_1() throws Exception { @DisplayName("좋아요를 해제 할 수 있다.") @Test - @Sql( - scripts = { - "/init-script/init-user.sql", - "/init-script/init-alcohol.sql", - "/init-script/init-review.sql" - }) void test_2() throws Exception { - - LikesUpdateRequest likesUpdateRequest = new LikesUpdateRequest(1L, LikeStatus.LIKE); - LikesUpdateRequest dislikesUpdateRequest = new LikesUpdateRequest(1L, LikeStatus.DISLIKE); - + // Given + User reviewAuthor = userTestFactory.persistUser(); + User likeUser = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(reviewAuthor, alcohol); + TokenItem token = getToken(likeUser); + + LikesUpdateRequest likesUpdateRequest = new LikesUpdateRequest(review.getId(), LikeStatus.LIKE); + LikesUpdateRequest dislikesUpdateRequest = + new LikesUpdateRequest(review.getId(), LikeStatus.DISLIKE); + + // When - 좋아요 등록 mockMvc .perform( put("/api/v1/likes") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(likesUpdateRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()); - Likes likes = likesRepository.findByReviewIdAndUserId(1L, getTokenUserId()).orElse(null); + Likes likes = + likesRepository.findByReviewIdAndUserId(review.getId(), likeUser.getId()).orElse(null); assertNotNull(likes); assertEquals(LikeStatus.LIKE, likes.getStatus()); + // When - 좋아요 해제 MvcResult result = mockMvc .perform( put("/api/v1/likes") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(dislikesUpdateRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -109,13 +123,15 @@ void test_2() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); LikesUpdateResponse likesUpdateResponse = mapper.convertValue(response.getData(), LikesUpdateResponse.class); assertEquals(likesUpdateResponse.message(), DISLIKE.getMessage()); - Likes dislike = likesRepository.findByReviewIdAndUserId(1L, getTokenUserId()).orElse(null); + Likes dislike = + likesRepository.findByReviewIdAndUserId(review.getId(), likeUser.getId()).orElse(null); assertNotNull(dislike); assertEquals(LikeStatus.DISLIKE, dislike.getStatus()); } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java index bedf2fa68..8462af7bc 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java @@ -13,18 +13,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.picks.domain.Picks; import app.bottlenote.picks.domain.PicksRepository; import app.bottlenote.picks.dto.request.PicksUpdateRequest; import app.bottlenote.picks.dto.response.PicksUpdateResponse; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.dto.response.TokenItem; +import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @@ -32,21 +36,27 @@ class PicksIntegrationTest extends IntegrationTestSupport { @Autowired private PicksRepository picksRepository; + @Autowired private UserTestFactory userTestFactory; + @Autowired private AlcoholTestFactory alcoholTestFactory; @DisplayName("찜을 등록할 수 있다.") @Test - @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-alcohol.sql"}) void test_1() throws Exception { + // Given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); - PicksUpdateRequest picksUpdateRequest = new PicksUpdateRequest(1L, PICK); + PicksUpdateRequest picksUpdateRequest = new PicksUpdateRequest(alcohol.getId(), PICK); + // When & Then MvcResult result = mockMvc .perform( put("/api/v1/picks") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(picksUpdateRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -64,35 +74,41 @@ void test_1() throws Exception { @DisplayName("등록한 찜을 해제할 수 있다.") @Test - @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-alcohol.sql"}) void test_2() throws Exception { + // Given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); - PicksUpdateRequest registerPicksRequest = new PicksUpdateRequest(1L, PICK); - PicksUpdateRequest unregisterPicksRequest = new PicksUpdateRequest(1L, UNPICK); + PicksUpdateRequest registerPicksRequest = new PicksUpdateRequest(alcohol.getId(), PICK); + PicksUpdateRequest unregisterPicksRequest = new PicksUpdateRequest(alcohol.getId(), UNPICK); + // When - 찜 등록 mockMvc .perform( put("/api/v1/picks") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(registerPicksRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()); - Picks picks = picksRepository.findByAlcoholIdAndUserId(1L, getTokenUserId()).orElse(null); + Picks picks = + picksRepository.findByAlcoholIdAndUserId(alcohol.getId(), user.getId()).orElse(null); assertNotNull(picks); assertEquals(PICK, picks.getStatus()); + // When - 찜 해제 MvcResult result = mockMvc .perform( put("/api/v1/picks") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(unregisterPicksRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -100,13 +116,15 @@ void test_2() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); PicksUpdateResponse picksUpdateResponse = mapper.convertValue(response.getData(), PicksUpdateResponse.class); assertEquals(picksUpdateResponse.message(), UNPICKED.message()); - Picks unPick = picksRepository.findByAlcoholIdAndUserId(1L, getTokenUserId()).orElse(null); + Picks unPick = + picksRepository.findByAlcoholIdAndUserId(alcohol.getId(), user.getId()).orElse(null); assertNotNull(unPick); assertEquals(UNPICK, unPick.getStatus()); } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java index 1ad8dabbd..3e1151d98 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java @@ -11,48 +11,53 @@ import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; -import app.bottlenote.alcohols.domain.AlcoholQueryRepository; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.GlobalResponse; -import app.bottlenote.rating.domain.Rating; -import app.bottlenote.rating.domain.Rating.RatingId; -import app.bottlenote.rating.domain.RatingPoint; -import app.bottlenote.rating.domain.RatingRepository; import app.bottlenote.rating.dto.request.RatingRegisterRequest; import app.bottlenote.rating.dto.response.RatingListFetchResponse; import app.bottlenote.rating.dto.response.RatingRegisterResponse; import app.bottlenote.rating.dto.response.RatingRegisterResponse.Message; import app.bottlenote.rating.dto.response.UserRatingResponse; +import app.bottlenote.rating.fixture.RatingTestFactory; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.dto.response.TokenItem; +import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") -@DisplayName("[integration] [controller] PickController") +@DisplayName("[integration] [controller] RatingController") class RatingIntegrationTest extends IntegrationTestSupport { - @Autowired private AlcoholQueryRepository alcoholQueryRepository; - @Autowired private RatingRepository ratingRepository; + @Autowired private UserTestFactory userTestFactory; + @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private RatingTestFactory ratingTestFactory; @DisplayName("별점을 등록할 수 있다.") @Test - @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-alcohol.sql"}) void test_1() throws Exception { + // Given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); - RatingRegisterRequest ratingRegisterRequest = new RatingRegisterRequest(1L, 3.0); + RatingRegisterRequest ratingRegisterRequest = new RatingRegisterRequest(alcohol.getId(), 3.0); + // When & Then MvcResult result = mockMvc .perform( post("/api/v1/rating/register") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(ratingRegisterRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -71,27 +76,28 @@ void test_1() throws Exception { @DisplayName("별점 목록을 조회할 수 있다.") @Test - @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-alcohol.sql"}) void test_2() throws Exception { - List alcohols = alcoholQueryRepository.findAll(); - - alcohols.forEach( - a -> { - double ratingPoint = (double) a.getId() % 5; - Rating rating = - Rating.builder() - .id(RatingId.is(getTokenUserId(), a.getId())) - .ratingPoint(RatingPoint.of(ratingPoint)) - .build(); - ratingRepository.save(rating); - }); - + // Given + User user = userTestFactory.persistUser(); + List alcohols = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + alcohols.add(alcoholTestFactory.persistAlcohol()); + } + TokenItem token = getToken(user); + + // 각 알코올에 별점 등록 + for (int i = 0; i < alcohols.size(); i++) { + int ratingPoint = (i % 5) + 1; + ratingTestFactory.persistRating(user, alcohols.get(i), ratingPoint); + } + + // When MvcResult result = mockMvc .perform( get("/api/v1/rating") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -99,6 +105,7 @@ void test_2() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); RatingListFetchResponse ratingListFetchResponse = @@ -109,27 +116,22 @@ void test_2() throws Exception { @DisplayName("내가 매긴 특정 술의 별점을 조회할 수 있다.") @Test - @Sql(scripts = {"/init-script/init-user.sql", "/init-script/init-alcohol.sql"}) void test_3() throws Exception { - List alcohols = alcoholQueryRepository.findAll(); - - alcohols.forEach( - a -> { - double ratingPoint = (double) a.getId() % 5; - Rating rating = - Rating.builder() - .id(RatingId.is(getTokenUserId(), a.getId())) - .ratingPoint(RatingPoint.of(ratingPoint)) - .build(); - ratingRepository.save(rating); - }); + // Given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + + // 별점 1점 등록 + ratingTestFactory.persistRating(user, alcohol, 1); + // When MvcResult result = mockMvc .perform( - get("/api/v1/rating/{alcoholId}", 1L) + get("/api/v1/rating/{alcoholId}", alcohol.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -137,6 +139,7 @@ void test_3() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); UserRatingResponse userRatingResponse = @@ -144,7 +147,7 @@ void test_3() throws Exception { assertNotNull(userRatingResponse); assertEquals(1.0, userRatingResponse.rating()); - assertEquals(getTokenUserId(), userRatingResponse.userId()); - assertEquals(1, userRatingResponse.alcoholId()); + assertEquals(user.getId(), userRatingResponse.userId()); + assertEquals(alcohol.getId(), userRatingResponse.alcoholId()); } } From f26f7423dacf0f05695bba3be0358dcaf1af5cd9 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 14:36:17 +0900 Subject: [PATCH 90/95] =?UTF-8?q?refactor:=20User=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20@Sql=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Sql 어노테이션을 TestFactory 패턴으로 대체: - UserQueryIntegrationTest: TestDataSetupHelper를 활용한 복합 데이터 설정 - UserCommandIntegrationTest: TestFactory 직접 사용 Co-Authored-By: Claude Opus 4.5 --- .../UserCommandIntegrationTest.java | 100 ++++----- .../integration/UserQueryIntegrationTest.java | 195 +++++++++--------- 2 files changed, 152 insertions(+), 143 deletions(-) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java index 28c73e939..11f7172b3 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java @@ -15,7 +15,6 @@ import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.user.constant.SocialType; import app.bottlenote.user.constant.UserStatus; -import app.bottlenote.user.constant.UserType; import app.bottlenote.user.domain.User; import app.bottlenote.user.domain.UserRepository; import app.bottlenote.user.dto.request.NicknameChangeRequest; @@ -23,18 +22,18 @@ import app.bottlenote.user.dto.request.ProfileImageChangeRequest; import app.bottlenote.user.dto.response.NicknameChangeResponse; import app.bottlenote.user.dto.response.ProfileImageChangeResponse; +import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.dto.response.WithdrawUserResultResponse; import app.bottlenote.user.exception.UserExceptionCode; +import app.bottlenote.user.fixture.UserTestFactory; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @@ -43,20 +42,22 @@ class UserCommandIntegrationTest extends IntegrationTestSupport { @Autowired private UserRepository userRepository; + @Autowired private UserTestFactory userTestFactory; - @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @DisplayName("회원탈퇴에 성공한다.") @Test void test_1() throws Exception { - // given + // Given + User user = userTestFactory.persistUser(); + TokenItem token = getToken(user); + + // When MvcResult result = mockMvc .perform( delete("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -64,6 +65,7 @@ void test_1() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); WithdrawUserResultResponse withdrawUserResultResponse = @@ -74,23 +76,25 @@ void test_1() throws Exception { .ifPresent(withdraw -> assertEquals(DELETED, withdraw.getStatus())); } - @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @DisplayName("탈퇴한 회원이 다시 탈퇴하는 경우 성공") @Test void test_2() throws Exception { - // given - final User firstUser = authSupport.getFirstUser(); + // Given + User user = userTestFactory.persistUser(); + TokenItem token = getToken(user); + + // 사용자를 탈퇴 상태로 변경 Field statusField = User.class.getDeclaredField("status"); statusField.setAccessible(true); - statusField.set(firstUser, UserStatus.DELETED); + statusField.set(user, UserStatus.DELETED); + userRepository.save(user); + // When & Then mockMvc .perform( delete("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -98,33 +102,33 @@ void test_2() throws Exception { .andExpect(jsonPath("$.data").exists()); } - @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @DisplayName("탈퇴한 회원이 재로그인 하는 경우 예외가 발생한다.") @Test void test_3() throws Exception { - // given - final User firstUser = authSupport.getFirstUser(); + // Given + User user = userTestFactory.persistUser(); + TokenItem token = getToken(user); + // 먼저 회원 탈퇴 mockMvc .perform( delete("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()); + // When - 재로그인 시도 mockMvc .perform( post("/api/v1/oauth/login") .contentType(MediaType.APPLICATION_JSON) .content( mapper.writeValueAsString( - new OauthRequest(firstUser.getEmail(), null, SocialType.KAKAO, null, null))) + new OauthRequest(user.getEmail(), null, SocialType.KAKAO, null, null))) .with(csrf())) .andDo(print()) .andExpect(status().isBadRequest()) @@ -135,19 +139,20 @@ void test_3() throws Exception { jsonPath("$.errors[0].message").value(UserExceptionCode.USER_DELETED.getMessage())); } - @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @DisplayName("닉네임 변경에 성공한다.") @Test void test_4() throws Exception { + // Given + User user = userTestFactory.persistUser(); + TokenItem token = getToken(user); + // When MvcResult result = mockMvc .perform( patch("/api/v1/users/nickname") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .content(mapper.writeValueAsString(new NicknameChangeRequest("newNickname"))) .with(csrf())) .andDo(print()) @@ -156,6 +161,7 @@ void test_4() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); NicknameChangeResponse nicknameChangeResponse = @@ -164,29 +170,21 @@ void test_4() throws Exception { assertEquals("newNickname", nicknameChangeResponse.getChangedNickname()); } - @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @DisplayName("이미 존재하는 닉네임으로 변경할 수 없다.") @Test void test_5() throws Exception { + // Given + User user = userTestFactory.persistUser(); + User otherUser = userTestFactory.persistUserWithNickname("중복닉네임"); + TokenItem token = getToken(user); - // 중복 닉네임 생성 - userRepository.save( - User.builder() - .email("test@email.com") - .password("password") - .nickName("fail") - .role(UserType.ROLE_USER) - .socialType(List.of(SocialType.KAKAO)) - .build()); - + // When & Then mockMvc .perform( patch("/api/v1/users/nickname") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) - .content(mapper.writeValueAsString(new NicknameChangeRequest("fail"))) + .header("Authorization", "Bearer " + token.accessToken()) + .content(mapper.writeValueAsString(new NicknameChangeRequest("중복닉네임"))) .with(csrf())) .andDo(print()) .andExpect(status().isBadRequest()) @@ -199,19 +197,20 @@ void test_5() throws Exception { .value(UserExceptionCode.USER_NICKNAME_NOT_VALID.getMessage())); } - @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @DisplayName("프로필 이미지 변경에 성공한다.") @Test void test_6() throws Exception { + // Given + User user = userTestFactory.persistUser(); + TokenItem token = getToken(user); + // When MvcResult result = mockMvc .perform( patch("/api/v1/users/profile-image") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .content( mapper.writeValueAsString( new ProfileImageChangeRequest("newProfileImageUrl"))) @@ -222,6 +221,7 @@ void test_6() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); ProfileImageChangeResponse profileImageChangeResponse = @@ -230,19 +230,20 @@ void test_6() throws Exception { assertEquals("newProfileImageUrl", profileImageChangeResponse.profileImageUrl()); } - @Sql( - scripts = {"/init-script/init-user.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @DisplayName("프로필 이미지에 null을 넣는 경우 변경에 성공한다.(삭제)") @Test void test_7() throws Exception { + // Given + User user = userTestFactory.persistUser(); + TokenItem token = getToken(user); + // When MvcResult result = mockMvc .perform( patch("/api/v1/users/profile-image") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .content(mapper.writeValueAsString(new ProfileImageChangeRequest(null))) .with(csrf())) .andDo(print()) @@ -251,6 +252,7 @@ void test_7() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); ProfileImageChangeResponse profileImageChangeResponse = diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java index 25d49842a..43ba78aae 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java @@ -10,17 +10,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; +import app.bottlenote.common.fixture.MyPageTestData; +import app.bottlenote.common.fixture.TestDataSetupHelper; import app.bottlenote.global.data.response.Error; import app.bottlenote.global.data.response.GlobalResponse; -import app.bottlenote.user.constant.FollowStatus; -import app.bottlenote.user.domain.Follow; -import app.bottlenote.user.domain.FollowRepository; +import app.bottlenote.rating.fixture.RatingTestFactory; +import app.bottlenote.review.fixture.ReviewTestFactory; import app.bottlenote.user.domain.User; -import app.bottlenote.user.domain.UserRepository; import app.bottlenote.user.dto.response.FollowerSearchResponse; import app.bottlenote.user.dto.response.FollowingSearchResponse; +import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.exception.UserExceptionCode; +import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -28,108 +33,100 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @DisplayName("[integration] [controller] UserQueryController") class UserQueryIntegrationTest extends IntegrationTestSupport { - @Autowired private FollowRepository followRepository; - @Autowired private UserRepository userRepository; + @Autowired private UserTestFactory userTestFactory; + @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private ReviewTestFactory reviewTestFactory; + @Autowired private RatingTestFactory ratingTestFactory; + @Autowired private TestDataSetupHelper testDataSetupHelper; @Nested @DisplayName("팔로우/팔로잉") class Follower { @DisplayName("유저는 자신의 팔로잉 목록을 조회할 수 있다.") - @Sql(scripts = {"/init-script/init-user.sql"}) @Test void test_1() throws Exception { - - final Long tokenUserId = getTokenUserId(); - - List allUsers = - userRepository.findAll().stream() - .filter(userId -> !userId.getId().equals(tokenUserId)) - .toList(); - - allUsers.forEach( - u -> { - Follow follow = - Follow.builder() - .userId(tokenUserId) - .targetUserId(u.getId()) - .status(FollowStatus.FOLLOWING) - .build(); - followRepository.save(follow); - }); - + // Given + User me = userTestFactory.persistUser(); + List otherUsers = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + otherUsers.add(userTestFactory.persistUser()); + } + TokenItem token = getToken(me); + + // 팔로잉 관계 생성 + for (User target : otherUsers) { + userTestFactory.persistFollow(me, target); + } + + // When MvcResult result = mockMvc .perform( - get("/api/v1/follow/{userId}/following-list", tokenUserId) + get("/api/v1/follow/{userId}/following-list", me.getId()) .contentType(MediaType.APPLICATION_JSON) .with(csrf()) - .header("Authorization", "Bearer " + getToken())) + .header("Authorization", "Bearer " + token.accessToken())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse globalResponse = mapper.readValue(responseString, GlobalResponse.class); FollowingSearchResponse followingSearchResponse = mapper.convertValue(globalResponse.getData(), FollowingSearchResponse.class); assertNotNull(followingSearchResponse); - assertEquals(followingSearchResponse.totalCount(), allUsers.size()); + assertEquals(followingSearchResponse.totalCount(), otherUsers.size()); } @DisplayName("유저는 자신을 팔로우하는 팔로워 목록을 조회할 수 있다.") - @Sql(scripts = {"/init-script/init-user.sql"}) @Test void test_2() throws Exception { - - final Long tokenUserId = getTokenUserId(); - - List allUsers = - userRepository.findAll().stream() - .filter(userId -> !userId.getId().equals(tokenUserId)) - .toList(); - - allUsers.forEach( - u -> { - Follow follow = - Follow.builder() - .userId(u.getId()) - .targetUserId(tokenUserId) - .status(FollowStatus.FOLLOWING) - .build(); - followRepository.save(follow); - }); - + // Given + User me = userTestFactory.persistUser(); + List followers = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + followers.add(userTestFactory.persistUser()); + } + TokenItem token = getToken(me); + + // 팔로워 관계 생성 (다른 유저들이 나를 팔로우) + for (User follower : followers) { + userTestFactory.persistFollow(follower, me); + } + + // When MvcResult result = mockMvc .perform( - get("/api/v1/follow/{userId}/follower-list", tokenUserId) + get("/api/v1/follow/{userId}/follower-list", me.getId()) .contentType(MediaType.APPLICATION_JSON) .with(csrf()) - .header("Authorization", "Bearer " + getToken())) + .header("Authorization", "Bearer " + token.accessToken())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()) .andReturn(); + // Then String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); GlobalResponse globalResponse = mapper.readValue(responseString, GlobalResponse.class); FollowerSearchResponse followerSearchResponse = mapper.convertValue(globalResponse.getData(), FollowerSearchResponse.class); assertNotNull(followerSearchResponse); - assertEquals(followerSearchResponse.totalCount(), allUsers.size()); + assertEquals(followerSearchResponse.totalCount(), followers.size()); } } @@ -138,82 +135,87 @@ void test_2() throws Exception { class myPage { @DisplayName("로그인 유저가 타인의 마이페이지를 조회할 수 있다.") - @Sql(scripts = {"/init-script/init-user-mypage-query.sql"}) @Test void test_1() throws Exception { + // Given + MyPageTestData data = testDataSetupHelper.setupMyPageTestData(); + User me = data.getUser(0); + User targetUser = data.getUser(1); + TokenItem token = getToken(me); - String accessToken = getToken(); - Long userId = 2L; - Long requestUserId = getTokenUserId(); - + // When & Then mockMvc .perform( - get("/api/v1/my-page/{userId}", userId) + get("/api/v1/my-page/{userId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) .with(csrf()) - .header("Authorization", "Bearer " + accessToken)) + .header("Authorization", "Bearer " + token.accessToken())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.userId").value(userId)) + .andExpect(jsonPath("$.data.userId").value(targetUser.getId())) .andReturn(); - assertNotEquals(userId, requestUserId); + assertNotEquals(targetUser.getId(), me.getId()); } @DisplayName("로그인 유저가 자신의 마이페이지를 조회할 수 있다.") - @Sql(scripts = {"/init-script/init-user-mypage-query.sql"}) @Test void test_2() throws Exception { + // Given + MyPageTestData data = testDataSetupHelper.setupMyPageTestData(); + User me = data.getUser(0); + TokenItem token = getToken(me); - String accessToken = getToken(); - Long userId = getTokenUserId(); - + // When & Then mockMvc .perform( - get("/api/v1/my-page/{userId}", userId) + get("/api/v1/my-page/{userId}", me.getId()) .contentType(MediaType.APPLICATION_JSON) .with(csrf()) - .header("Authorization", "Bearer " + accessToken)) + .header("Authorization", "Bearer " + token.accessToken())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.userId").value(userId)) + .andExpect(jsonPath("$.data.userId").value(me.getId())) .andExpect(jsonPath("$.data.isMyPage").value(true)) .andReturn(); } @DisplayName("비회원 유저가 타인의 마이페이지를 조회할 수 있다.") - @Sql(scripts = {"/init-script/init-user-mypage-query.sql"}) @Test void test_3() throws Exception { + // Given + MyPageTestData data = testDataSetupHelper.setupMyPageTestData(); + User targetUser = data.getUser(1); - final Long userId = 2L; - + // When & Then mockMvc .perform( - get("/api/v1/my-page/{userId}", userId) + get("/api/v1/my-page/{userId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.userId").value(userId)) + .andExpect(jsonPath("$.data.userId").value(targetUser.getId())) .andReturn(); } @DisplayName("유저가 존재하지 않는 경우 MYPAGE_NOT_ACCESSIBLE 에러를 발생한다.") - @Sql(scripts = {"/init-script/init-user-mypage-query.sql"}) @Test void test_4() throws Exception { + // Given Error error = Error.of(UserExceptionCode.MYPAGE_NOT_ACCESSIBLE); - final Long userId = 999L; // 존재하지 않는 유저 ID + final Long nonExistentUserId = 999999L; + + // When & Then mockMvc .perform( - get("/api/v1/my-page/{userId}", userId) + get("/api/v1/my-page/{userId}", nonExistentUserId) .contentType(MediaType.APPLICATION_JSON) .with(csrf())) .andDo(print()) @@ -229,17 +231,19 @@ void test_4() throws Exception { class myBottle { @DisplayName("리뷰 마이보틀을 조회할 수 있다.") - @Sql(scripts = {"/init-script/init-user-mybottle-query.sql"}) @Test void test_1() throws Exception { - - String accessToken = getToken(); - Long userId = 2L; - Long requestUserId = getTokenUserId(); - + // Given + User me = userTestFactory.persistUser(); + User targetUser = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + reviewTestFactory.persistReview(targetUser, alcohol); + TokenItem token = getToken(me); + + // When & Then mockMvc .perform( - get("/api/v1/my-page/{userId}/my-bottle/reviews", userId) + get("/api/v1/my-page/{userId}/my-bottle/reviews", targetUser.getId()) .param("keyword", "") .param("regionId", "") .param("sortType", "LATEST") @@ -247,7 +251,7 @@ void test_1() throws Exception { .param("cursor", "0") .param("pageSize", "50") .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + accessToken) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -255,19 +259,21 @@ void test_1() throws Exception { .andExpect(jsonPath("$.data").exists()) .andReturn(); - assertNotEquals(userId, requestUserId); + assertNotEquals(targetUser.getId(), me.getId()); } @DisplayName("비회원 유저는 조회하면 BAD_REQUEST 예외를 반환한다.") - @Sql(scripts = {"/init-script/init-user-mybottle-query.sql"}) @Test void test_3() throws Exception { + // Given + User targetUser = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + reviewTestFactory.persistReview(targetUser, alcohol); - final Long userId = 2L; - + // When & Then mockMvc .perform( - get("/api/v1/my-page/{userId}/my-bottle/reviews", userId) + get("/api/v1/my-page/{userId}/my-bottle/reviews", targetUser.getId()) .param("keyword", "") .param("regionId", "") .param("sortType", "LATEST") @@ -277,19 +283,20 @@ void test_3() throws Exception { .contentType(MediaType.APPLICATION_JSON) .with(csrf())) .andDo(print()) - .andExpect(status().isBadRequest()); // 비회원은 접근 불가 + .andExpect(status().isBadRequest()); } @DisplayName("마이보틀 유저가 존재하지 않는 경우 REQUIRED_USER_ID 예외를 반환한다.") - @Sql(scripts = {"/init-script/init-user-mybottle-query.sql"}) @Test void test_4() throws Exception { + // Given Error error = Error.of(UserExceptionCode.REQUIRED_USER_ID); - final Long userId = 999L; // 존재하지 않는 유저 ID + final Long nonExistentUserId = 999999L; + // When & Then mockMvc .perform( - get("/api/v1/my-page/{userId}/my-bottle/reviews", userId) + get("/api/v1/my-page/{userId}/my-bottle/reviews", nonExistentUserId) .param("keyword", "") .param("regionId", "") .param("sortType", "LATEST") From 4d2307562a936a754642e72d38c3f34402262791 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 14:36:55 +0900 Subject: [PATCH 91/95] =?UTF-8?q?refactor:=20UserHistory=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20@Sql=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Sql 어노테이션을 TestFactory 패턴으로 대체: - UserHistoryIntegrationTest: TestDataSetupHelper.setupHistoryTestData() 활용 Co-Authored-By: Claude Opus 4.5 --- .../UserHistoryIntegrationTest.java | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java index ac7982eb3..bbc665401 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java @@ -4,11 +4,15 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.common.fixture.HistoryTestData; +import app.bottlenote.common.fixture.TestDataSetupHelper; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.service.cursor.SortOrder; import app.bottlenote.history.dto.response.UserHistoryItem; import app.bottlenote.history.dto.response.UserHistorySearchResponse; import app.bottlenote.picks.constant.PicksStatus; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.dto.response.TokenItem; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; @@ -16,27 +20,30 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @DisplayName("[integration] [history] UserHistory") class UserHistoryIntegrationTest extends IntegrationTestSupport { - @Sql(scripts = {"/init-script/init-user-history.sql"}) + @Autowired private TestDataSetupHelper testDataSetupHelper; + @DisplayName("파라미터 없이 유저 히스토리를 조회할 수 있다.") @Test void test_1() throws Exception { // given - final Long targetUserId = 1L; + HistoryTestData data = testDataSetupHelper.setupHistoryTestData(); + User targetUser = data.getUser(0); + TokenItem token = getToken(targetUser); MvcResult result = mockMvc .perform( - get("/api/v1/history/{targetUserId}", targetUserId) + get("/api/v1/history/{targetUserId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andReturn(); @@ -52,19 +59,20 @@ void test_1() throws Exception { Assertions.assertNotNull(userHistorySearchResponse.userHistories()); } - @Sql(scripts = {"/init-script/init-user-history.sql"}) @DisplayName("유저 히스토리를 정렬해서 조회할 수 있다.") @Test void test_2() throws Exception { // given - final Long targetUserId = 1L; + HistoryTestData data = testDataSetupHelper.setupHistoryTestData(); + User targetUser = data.getUser(0); + TokenItem token = getToken(targetUser); MvcResult result = mockMvc .perform( - get("/api/v1/history/{targetUserId}", targetUserId) + get("/api/v1/history/{targetUserId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .param("sortOrder", SortOrder.DESC.name()) .with(csrf())) .andReturn(); @@ -90,19 +98,20 @@ void test_2() throws Exception { Assertions.assertEquals(userHistories.size(), userHistorySearchResponse.totalCount()); } - @Sql(scripts = {"/init-script/init-user-history.sql"}) @DisplayName("날짜 검색조건으로 유저 히스토리를 조회할 수 있다.") @Test void test_3() throws Exception { // given - final Long targetUserId = 1L; + HistoryTestData data = testDataSetupHelper.setupHistoryTestData(); + User targetUser = data.getUser(0); + TokenItem token = getToken(targetUser); MvcResult initialMvcResult = mockMvc .perform( - get("/api/v1/history/{targetUserId}", targetUserId) + get("/api/v1/history/{targetUserId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andReturn(); @@ -123,9 +132,9 @@ void test_3() throws Exception { MvcResult filteredMvcResult = mockMvc .perform( - get("/api/v1/history/{targetUserId}", targetUserId) + get("/api/v1/history/{targetUserId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .param("startDate", createdAtList.get(0).toString()) .param("endDate", createdAtList.get(1).toString()) .with(csrf())) @@ -153,19 +162,20 @@ void test_3() throws Exception { } } - @Sql(scripts = {"/init-script/init-user-history.sql"}) @DisplayName("별점으로 유저 히스토리 필터링하여 조회할 수 있다.") @Test void test_4() throws Exception { // given - final Long targetUserId = 1L; + HistoryTestData data = testDataSetupHelper.setupHistoryTestData(); + User targetUser = data.getUser(0); + TokenItem token = getToken(targetUser); MvcResult result = mockMvc .perform( - get("/api/v1/history/{targetUserId}", targetUserId) + get("/api/v1/history/{targetUserId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .param("ratingPoint", "5") .with(csrf())) .andReturn(); @@ -181,19 +191,20 @@ void test_4() throws Exception { Assertions.assertNotNull(userHistorySearchResponse.userHistories()); } - @Sql(scripts = {"/init-script/init-user-history.sql"}) @DisplayName("찜/찜 해제 검색조건으로 유저 히스토리를 조회할 수 있다.") @Test void test_5() throws Exception { // given - final Long targetUserId = 1L; + HistoryTestData data = testDataSetupHelper.setupHistoryTestData(); + User targetUser = data.getUser(0); + TokenItem token = getToken(targetUser); MvcResult result = mockMvc .perform( - get("/api/v1/history/{targetUserId}", targetUserId) + get("/api/v1/history/{targetUserId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .param("picksStatus", PicksStatus.PICK.name()) .param("picksStatus", PicksStatus.UNPICK.name()) .with(csrf())) @@ -210,19 +221,20 @@ void test_5() throws Exception { Assertions.assertNotNull(userHistorySearchResponse.userHistories()); } - @Sql(scripts = {"/init-script/init-user-history.sql"}) @DisplayName("리뷰 필터 조건으로 유저 히스토리를 조회할 수 있다.") @Test void test_6() throws Exception { // given - final Long targetUserId = 1L; + HistoryTestData data = testDataSetupHelper.setupHistoryTestData(); + User targetUser = data.getUser(0); + TokenItem token = getToken(targetUser); MvcResult result = mockMvc .perform( - get("/api/v1/history/{targetUserId}", targetUserId) + get("/api/v1/history/{targetUserId}", targetUser.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .param("historyReviewFilterType", "BEST_REVIEW") .param("historyReviewFilterType", "REVIEW_LIKE") .param("historyReviewFilterType", "REVIEW_REPLY") From 3aa9e24ef0e97da67d1830cadc79ea09ac85f3a6 Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 14:37:59 +0900 Subject: [PATCH 92/95] =?UTF-8?q?refactor:=20Review/Reply=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20@Sql=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Sql 어노테이션을 TestFactory 패턴으로 대체: - ReviewIntegrationTest: ReviewTestFactory 활용 - ReviewReplyIntegrationTest: ReviewTestFactory.persistReviewReply() 활용 - JpaAuditingIntegrationTest: TestFactory 직접 사용 - ReviewObjectFixture: getReviewCreateRequestWithAlcoholId() 메서드 추가 Co-Authored-By: Claude Opus 4.5 --- .../config/JpaAuditingIntegrationTest.java | 39 ++-- .../review/fixture/ReviewObjectFixture.java | 7 +- .../integration/ReviewIntegrationTest.java | 204 ++++++++---------- .../ReviewReplyIntegrationTest.java | 150 ++++++------- 4 files changed, 180 insertions(+), 220 deletions(-) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java index 6d07acd54..c73bd8cec 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java @@ -8,48 +8,43 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.review.domain.Review; import app.bottlenote.review.domain.ReviewRepository; import app.bottlenote.review.dto.request.ReviewCreateRequest; import app.bottlenote.review.fixture.ReviewObjectFixture; -import app.bottlenote.user.constant.SocialType; -import app.bottlenote.user.dto.request.OauthRequest; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.dto.response.TokenItem; +import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @DisplayName("[integration] [infra] JpaAuditing") class JpaAuditingIntegrationTest extends IntegrationTestSupport { - private ReviewCreateRequest reviewCreateRequest; - private OauthRequest oauthRequest; - @Autowired private ReviewRepository reviewRepository; - - @BeforeEach - void setUp() { - oauthRequest = new OauthRequest("chadongmin@naver.com", null, SocialType.KAKAO, null, null); - reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequest(); - } + @Autowired private UserTestFactory userTestFactory; + @Autowired private AlcoholTestFactory alcoholTestFactory; @DisplayName("DB 저장 시 생성자와 수정자가 기록된다.") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @Test void test_1() throws Exception { + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + + ReviewCreateRequest reviewCreateRequest = + ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); + // when MvcResult result = mockMvc @@ -57,7 +52,7 @@ void test_1() throws Exception { post("/api/v1/reviews") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(reviewCreateRequest)) - .header("Authorization", "Bearer " + getToken(oauthRequest).accessToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -73,6 +68,6 @@ void test_1() throws Exception { Review savedReview = reviewRepository.findById(review.getId()).orElseGet(null); - assertEquals(oauthRequest.email(), savedReview.getCreateBy()); + assertEquals(user.getEmail(), savedReview.getCreateBy()); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/ReviewObjectFixture.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/ReviewObjectFixture.java index 60af1c35c..7b5e1139f 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/ReviewObjectFixture.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/fixture/ReviewObjectFixture.java @@ -160,8 +160,13 @@ public static ReviewModifyRequest getWrongReviewModifyRequest() { /** 기본 ReviewCreateRequest 객체를 생성합니다. */ public static ReviewCreateRequest getReviewCreateRequest() { + return getReviewCreateRequestWithAlcoholId(1L); + } + + /** alcoholId를 지정한 ReviewCreateRequest 객체를 생성합니다. */ + public static ReviewCreateRequest getReviewCreateRequestWithAlcoholId(Long alcoholId) { return new ReviewCreateRequest( - 1L, + alcoholId, ReviewDisplayStatus.PUBLIC, "맛있어요", SizeType.GLASS, diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java index ceb70c464..19dbe790b 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java @@ -15,6 +15,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.Error; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.exception.custom.code.ValidExceptionCode; @@ -30,17 +32,19 @@ import app.bottlenote.review.dto.response.ReviewResultResponse; import app.bottlenote.review.facade.payload.ReviewInfo; import app.bottlenote.review.fixture.ReviewObjectFixture; +import app.bottlenote.review.fixture.ReviewTestFactory; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.dto.response.TokenItem; +import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; import java.util.List; import org.junit.jupiter.api.Assertions; -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; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @@ -48,28 +52,28 @@ class ReviewIntegrationTest extends IntegrationTestSupport { @Autowired private ReviewRepository reviewRepository; + @Autowired private UserTestFactory userTestFactory; + @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private ReviewTestFactory reviewTestFactory; @Nested @DisplayName("리뷰 조회 테스트") class select { - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) + @DisplayName("리뷰 목록 조회에 성공한다.") @Test void test_1() throws Exception { // given - Long alcoholId = 4L; + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + reviewTestFactory.persistReview(user, alcohol); + reviewTestFactory.persistReview(user, alcohol); // when MvcResult result = mockMvc .perform( - get("/api/v1/reviews/{alcoholId}", alcoholId) + get("/api/v1/reviews/{alcoholId}", alcohol.getId()) .contentType(MediaType.APPLICATION_JSON) .with(csrf())) .andDo(print()) @@ -84,30 +88,28 @@ void test_1() throws Exception { mapper.convertValue(response.getData(), ReviewListResponse.class); List reviewInfos = reviewListResponse.reviewList(); - // when + // then assertNotNull(reviewListResponse); assertFalse(reviewInfos.isEmpty()); } - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("리뷰 상세 조회에 성공한다.") @Test void test_2() throws Exception { + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); - ReviewCreateRequest reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequest(); + ReviewCreateRequest reviewCreateRequest = + ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); MvcResult result = mockMvc .perform( post("/api/v1/reviews") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsBytes(reviewCreateRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -145,25 +147,23 @@ void test_2() throws Exception { reviewCreateRequest.content(), reviewDetailResponse.reviewInfo().reviewContent()); } - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("내가 작성한 리뷰 조회에 성공한다.") @Test void test_3() throws Exception { + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); - List reviewList = reviewRepository.findByUserId(getTokenUserId()); + Review review = reviewTestFactory.persistReview(user, alcohol); + List reviewList = reviewRepository.findByUserId(user.getId()); MvcResult result = mockMvc .perform( - get("/api/v1/reviews/me/{alcoholId}", 1L) + get("/api/v1/reviews/me/{alcoholId}", alcohol.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -180,13 +180,13 @@ void test_3() throws Exception { assertEquals(reviewList.size(), reviewListResponse.reviewList().size()); reviewList.forEach( - review -> { + r -> { ReviewInfo reviewInfo = reviewListResponse.reviewList().stream() - .filter(info -> info.reviewId().equals(review.getId())) + .filter(info -> info.reviewId().equals(r.getId())) .findFirst() .orElseThrow(); - assertEquals(review.getContent(), reviewInfo.reviewContent()); + assertEquals(r.getContent(), reviewInfo.reviewContent()); }); } } @@ -195,18 +195,16 @@ void test_3() throws Exception { @DisplayName("리뷰 생성 테스트") class create { - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("모든 필드가 포함된 리뷰 생성에 성공한다.") @Test void test_1() throws Exception { + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); - ReviewCreateRequest reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequest(); + ReviewCreateRequest reviewCreateRequest = + ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); MvcResult result = mockMvc @@ -214,7 +212,7 @@ void test_1() throws Exception { post("/api/v1/reviews") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsBytes(reviewCreateRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -233,20 +231,18 @@ void test_1() throws Exception { @Nested @DisplayName("리뷰 삭제 테스트") - class delete { - - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) + class deleteTest { + @DisplayName("리뷰 삭제에 성공한다. (목록 조회 시 미노출)") @Test void test_1() throws Exception { - // 리뷰 생성 - ReviewCreateRequest reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequest(); + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + + ReviewCreateRequest reviewCreateRequest = + ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); MvcResult result = mockMvc @@ -254,7 +250,7 @@ void test_1() throws Exception { post("/api/v1/reviews") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsBytes(reviewCreateRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -275,7 +271,7 @@ void test_1() throws Exception { .perform( delete("/api/v1/reviews/{reviewId}", reviewId) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -316,25 +312,16 @@ void test_1() throws Exception { @DisplayName("리뷰 수정 테스트") class update { - @BeforeEach - void setUp() { - final Long tokenUserId = getTokenUserId(); - Review review = ReviewObjectFixture.getReviewFixture(1L, tokenUserId, "content1"); - reviewRepository.save(review); - } - - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("리뷰 수정에 성공한다.") @Test void test_1() throws Exception { - final Long tokenUserId = getTokenUserId(); - final Long reviewId = reviewRepository.findByUserId(tokenUserId).get(0).getId(); + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + Review review = reviewTestFactory.persistReview(user, alcohol); + + final Long reviewId = review.getId(); final ReviewModifyRequest request = ReviewObjectFixture.getReviewModifyRequest(ReviewDisplayStatus.PUBLIC); @@ -343,7 +330,7 @@ void test_1() throws Exception { patch("/api/v1/reviews/{reviewId}", reviewId) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -354,18 +341,16 @@ void test_1() throws Exception { assertEquals(savedReview.getContent(), request.content()); } - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("content와 status를 제외한 필드에 null이 할당되어도 수정에 성공한다.") @Test void test_2() throws Exception { - final Long tokenUserId = getTokenUserId(); - final Long reviewId = reviewRepository.findByUserId(tokenUserId).get(0).getId(); + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + Review review = reviewTestFactory.persistReview(user, alcohol); + + final Long reviewId = review.getId(); final ReviewModifyRequest request = ReviewObjectFixture.getNullableReviewModifyRequest(ReviewDisplayStatus.PRIVATE); @@ -374,7 +359,7 @@ void test_2() throws Exception { patch("/api/v1/reviews/{reviewId}", reviewId) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -387,21 +372,19 @@ void test_2() throws Exception { assertEquals(savedReview.getContent(), request.content()); } - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("Not Null인 필드에 null이 할당되면 리뷰 수정에 실패한다.") @Test void test_3() throws Exception { + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + Review review = reviewTestFactory.persistReview(user, alcohol); + final Error notNullEmpty = Error.of(ValidExceptionCode.REVIEW_CONTENT_REQUIRED); final Error notStatusEmpty = Error.of(ValidExceptionCode.REVIEW_DISPLAY_STATUS_NOT_EMPTY); - final Long tokenUserId = getTokenUserId(); - final Long reviewId = reviewRepository.findByUserId(tokenUserId).get(0).getId(); + final Long reviewId = review.getId(); final ReviewModifyRequest request = ReviewObjectFixture.getWrongReviewModifyRequest(); mockMvc @@ -409,7 +392,7 @@ void test_3() throws Exception { patch("/api/v1/reviews/{reviewId}", reviewId) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andExpect(status().isBadRequest()) .andDo(print()) @@ -429,19 +412,16 @@ void test_3() throws Exception { .andReturn(); } - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("리뷰 상태 변경에 성공한다.") @Test void test_4() throws Exception { + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + Review review = reviewTestFactory.persistReview(user, alcohol); - final Long tokenUserId = getTokenUserId(); - final Long reviewId = reviewRepository.findByUserId(tokenUserId).get(0).getId(); + final Long reviewId = review.getId(); final ReviewModifyRequest request = ReviewObjectFixture.getReviewModifyRequest(ReviewDisplayStatus.PRIVATE); @@ -450,7 +430,7 @@ void test_4() throws Exception { patch("/api/v1/reviews/{reviewId}/display", reviewId) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -461,28 +441,26 @@ void test_4() throws Exception { assertEquals(ReviewDisplayStatus.PRIVATE, savedReview.getStatus()); } - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @DisplayName("Not null인 필드에 null이 할당되면 리뷰 상태 변경에 실패한다.") @Test void test_5() throws Exception { + // given + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + TokenItem token = getToken(user); + Review review = reviewTestFactory.persistReview(user, alcohol); + final Error notStatusEmpty = Error.of(ValidExceptionCode.REVIEW_DISPLAY_STATUS_NOT_EMPTY); final ReviewModifyRequest request = ReviewObjectFixture.getNullableReviewModifyRequest(null); - final Long tokenUserId = getTokenUserId(); - final Long reviewId = reviewRepository.findByUserId(tokenUserId).get(0).getId(); + final Long reviewId = review.getId(); mockMvc .perform( patch("/api/v1/reviews/{reviewId}/display", reviewId) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isBadRequest()) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java index 403a8416a..e940533cf 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java @@ -11,15 +11,21 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.domain.Alcohol; +import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.review.constant.ReviewReplyResultMessage; -import app.bottlenote.review.constant.ReviewReplyStatus; +import app.bottlenote.review.domain.Review; import app.bottlenote.review.domain.ReviewReply; import app.bottlenote.review.domain.ReviewReplyRepository; import app.bottlenote.review.dto.request.ReviewReplyRegisterRequest; import app.bottlenote.review.dto.response.ReviewReplyResponse; import app.bottlenote.review.dto.response.RootReviewReplyResponse; import app.bottlenote.review.dto.response.SubReviewReplyResponse; +import app.bottlenote.review.fixture.ReviewTestFactory; +import app.bottlenote.user.domain.User; +import app.bottlenote.user.dto.response.TokenItem; +import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -27,7 +33,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; -import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MvcResult; @Tag("integration") @@ -35,33 +40,34 @@ class ReviewReplyIntegrationTest extends IntegrationTestSupport { @Autowired private ReviewReplyRepository reviewReplyRepository; + @Autowired private UserTestFactory userTestFactory; + @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private ReviewTestFactory reviewTestFactory; @Nested @DisplayName("리뷰 댓글 생성 테스트") class create { @DisplayName("리뷰의 댓글을 생성할 수 있다.") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @Test void test_1() throws Exception { + // given + User reviewAuthor = userTestFactory.persistUser(); + User replyAuthor = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(reviewAuthor, alcohol); + TokenItem token = getToken(replyAuthor); ReviewReplyRegisterRequest replyRegisterRequest = new ReviewReplyRegisterRequest("댓글 내용", null); - final Long reviewId = 1L; MvcResult result = mockMvc .perform( - post("/api/v1/review/reply/register/{reviewId}", reviewId) + post("/api/v1/review/reply/register/{reviewId}", review.getId()) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -79,35 +85,26 @@ void test_1() throws Exception { } @DisplayName("댓글의 대댓글을 생성할 수 있다.") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql" - }) @Test void test_2() throws Exception { - - final Long reviewId = 1L; - ReviewReply savedReply = - reviewReplyRepository.save( - ReviewReply.builder() - .reviewId(reviewId) - .userId(getTokenUserId()) - .content("댓글 내용") - .status(ReviewReplyStatus.NORMAL) - .build()); + // given + User reviewAuthor = userTestFactory.persistUser(); + User replyAuthor = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(reviewAuthor, alcohol); + ReviewReply parentReply = reviewTestFactory.persistReviewReply(review, replyAuthor); + TokenItem token = getToken(replyAuthor); ReviewReplyRegisterRequest replyRegisterRequest = - new ReviewReplyRegisterRequest("대댓글 내용", savedReply.getId()); + new ReviewReplyRegisterRequest("대댓글 내용", parentReply.getId()); MvcResult result = mockMvc .perform( - post("/api/v1/review/reply/register/{reviewId}", reviewId) + post("/api/v1/review/reply/register/{reviewId}", review.getId()) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -130,23 +127,22 @@ void test_2() throws Exception { class read { @DisplayName("리뷰의 최상위 댓글을 조회할 수 있다.") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql", - "/init-script/init-review-reply.sql" - }) @Test void test_1() throws Exception { - // given - final Long reviewId = 4L; + User reviewAuthor = userTestFactory.persistUser(); + User replyAuthor1 = userTestFactory.persistUser(); + User replyAuthor2 = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(reviewAuthor, alcohol); + reviewTestFactory.persistReviewReply(review, replyAuthor1); + reviewTestFactory.persistReviewReply(review, replyAuthor2); + // when && then MvcResult result = mockMvc .perform( - get("/api/v1/review/reply/{reviewId}", reviewId) + get("/api/v1/review/reply/{reviewId}", review.getId()) .contentType(MediaType.APPLICATION_JSON) .param("cursor", "0") .param("pageSize", "50") @@ -163,36 +159,27 @@ void test_1() throws Exception { } @DisplayName("리뷰의 대댓글 목록을 조회할 수 있다.") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql" - }) @Test void test_2() throws Exception { - - final Long reviewId = 1L; - ReviewReply savedReply = - reviewReplyRepository.save( - ReviewReply.builder() - .reviewId(reviewId) - .userId(getTokenUserId()) - .content("댓글 내용") - .status(ReviewReplyStatus.NORMAL) - .build()); + // given + User reviewAuthor = userTestFactory.persistUser(); + User replyAuthor = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(reviewAuthor, alcohol); + ReviewReply parentReply = reviewTestFactory.persistReviewReply(review, replyAuthor); + TokenItem token = getToken(replyAuthor); ReviewReplyRegisterRequest replyRegisterRequest = - new ReviewReplyRegisterRequest("대댓글 내용", savedReply.getId()); + new ReviewReplyRegisterRequest("대댓글 내용", parentReply.getId()); final int count = 2; for (int i = 0; i < count; i++) { mockMvc .perform( - post("/api/v1/review/reply/register/{reviewId}", reviewId) + post("/api/v1/review/reply/register/{reviewId}", review.getId()) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -205,11 +192,11 @@ void test_2() throws Exception { .perform( get( "/api/v1/review/reply/{reviewId}/sub/{rootReplyId}", - reviewId, - savedReply.getId()) + review.getId(), + parentReply.getId()) .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -228,34 +215,29 @@ void test_2() throws Exception { @Nested @DisplayName("리뷰 댓글 삭제 테스트") - class delete { + class deleteTest { @DisplayName("리뷰 댓글을 삭제할 수 있다.") - @Sql( - scripts = { - "/init-script/init-alcohol.sql", - "/init-script/init-user.sql", - "/init-script/init-review.sql" - }) @Test void test_1() throws Exception { - - final Long reviewId = 1L; - - ReviewReply savedReply = - reviewReplyRepository.save( - ReviewReply.builder() - .reviewId(reviewId) - .userId(getTokenUserId()) - .content("댓글 내용") - .status(ReviewReplyStatus.NORMAL) - .build()); + // given + User reviewAuthor = userTestFactory.persistUser(); + User replyAuthor1 = userTestFactory.persistUser(); + User replyAuthor2 = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + Review review = reviewTestFactory.persistReview(reviewAuthor, alcohol); + ReviewReply replyToDelete = reviewTestFactory.persistReviewReply(review, replyAuthor1); + reviewTestFactory.persistReviewReply(review, replyAuthor2); + TokenItem token = getToken(replyAuthor1); mockMvc .perform( - delete("/api/v1/review/reply/{reviewId}/{replyId}", reviewId, savedReply.getId()) + delete( + "/api/v1/review/reply/{reviewId}/{replyId}", + review.getId(), + replyToDelete.getId()) .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken()) + .header("Authorization", "Bearer " + token.accessToken()) .with(csrf())) .andDo(print()) .andExpect(status().isOk()) @@ -265,7 +247,7 @@ void test_1() throws Exception { MvcResult result = mockMvc .perform( - get("/api/v1/review/reply/{reviewId}", reviewId) + get("/api/v1/review/reply/{reviewId}", review.getId()) .contentType(MediaType.APPLICATION_JSON) .with(csrf())) .andDo(print()) From c2403f2a8a00e1c6ec480c2bf96c9b6955ec7b3c Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 14:39:28 +0900 Subject: [PATCH 93/95] =?UTF-8?q?chore:=20init-script=20SQL=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 통합 테스트가 TestFactory 패턴으로 마이그레이션되어 더 이상 사용되지 않는 SQL 파일 삭제: - init-alcohol.sql - init-help.sql - init-popular_alcohol.sql - init-review-reply.sql - init-review.sql - init-user-history.sql - init-user-mybottle-query.sql - init-user-mypage-query.sql - init-user.sql - schema.sql.bak Co-Authored-By: Claude Opus 4.5 --- .../resources/init-script/init-alcohol.sql | 230 -------- .../test/resources/init-script/init-help.sql | 3 - .../init-script/init-popular_alcohol.sql | 27 - .../init-script/init-review-reply.sql | 9 - .../resources/init-script/init-review.sql | 40 -- .../init-script/init-user-history.sql | 507 ------------------ .../init-script/init-user-mybottle-query.sql | 343 ------------ .../init-script/init-user-mypage-query.sql | 354 ------------ .../test/resources/init-script/init-user.sql | 41 -- .../test/resources/init-script/schema.sql.bak | 437 --------------- 10 files changed, 1991 deletions(-) delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-alcohol.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-help.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-popular_alcohol.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-review-reply.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-review.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-user-history.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-user-mybottle-query.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-user-mypage-query.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/init-user.sql delete mode 100644 bottlenote-product-api/src/test/resources/init-script/schema.sql.bak diff --git a/bottlenote-product-api/src/test/resources/init-script/init-alcohol.sql b/bottlenote-product-api/src/test/resources/init-script/init-alcohol.sql deleted file mode 100644 index 1e0d5d7fa..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-alcohol.sql +++ /dev/null @@ -1,230 +0,0 @@ -insert into regions (kor_name, eng_name, continent, description) -values ('호주', 'Australia', null, '오세아니아에 위치한 나라로 다양한 위스키를 생산.'), - ('핀란드', 'Finland', null, '북유럽에 위치한 나라로 청정한 자연환경을 자랑.'), - ('프랑스', 'France', null, '와인과 브랜디로 유명한 유럽의 나라.'), - ('타이완', 'Taiwan', null, '고품질의 위스키로 유명한 동아시아의 섬나라.'), - ('캐나다', 'Canada', null, '위스키 생산이 활발한 북미의 나라.'), - ('체코', 'Czech Republic', null, '중앙유럽에 위치한 나라로 맥주로도 유명.'), - ('일본', 'Japan', null, '전통과 현대가 조화된 동아시아의 섬나라.'), - ('인도', 'India', null, '다양한 문화와 역사를 지닌 남아시아의 나라.'), - ('이스라엘', 'Israel', null, '중동에 위치한 나라로 와인과 위스키 생산이 증가.'), - ('웨일즈', 'Wales', null, '영국의 구성국 중 하나로 독자적인 위스키 생산.'), - ('영국', 'United Kingdom', null, '다양한 위스키 지역을 가진 나라.'), - ('아일랜드', 'Ireland', null, '풍부한 전통을 지닌 위스키의 본고장 중 하나입니다.'), - ('아이슬란드', 'Iceland', null, '청정한 자연환경과 독특한 술 문화를 가진 섬나라입니다.'), - ('스코틀랜드/캠벨타운', 'Scotland/Campbeltown', null, '스코틀랜드의 위스키 지역 중 하나로 해안가에 위치한 반도 형태의 지역입니다.'), - ('스코틀랜드/아일라', 'Scotland/Islay', null, '피트위스키의 대표 생산지입니다. 스모키한맛이 특징인 위스키로 유명한 섬입니다.'), - ('스코틀랜드/스페이사이드 ', 'Scotland/Speyside', null, '스코틀랜드의 중심부에 위치하고있습니다. 더프타운에 다양한 증류소들이 있습니다.'), - ('스코틀랜드/로우랜드', 'Scotland/Lowlands', null, '부드럽고 가벼운 맛이 특징인 위스키로 유명합니다.'), - ('스코틀랜드/기타섬', 'Scotland/Islands', null, '다양한 섬에서 독특한 맛의 위스키를 생산하고 있습니다.'), - ('스코틀랜드', 'Scotland/', null, '위스키의 본고장으로 다양한 스타일의 위스키를 생산합니다.'), - ('스코트랜드/하이랜드', 'Scotland/Highlands', null, '스코틀랜드 북부 지역으로 섬세한 맛의 위스키로 유명.'), - ('스위스', 'Switzerland', null, '알프스 산맥을 배경으로 한 유럽의 나라입니다.'), - ('스웨덴', 'Sweden', null, '북유럽에 위치한 나라로 맥주와 위스키 생산 증가하고 있습니다.'), - ('미국', 'United States', null, '다양한 스타일의 위스키를 생산하는 나라. 주로 켄터키의 버번위스키가 유명합니다.'), - ('독일', 'Germany', null, '맥주로 유명한 나라로 위스키 생산도 활발합니다.'), - ('덴마크', 'Denmark', null, '북유럽에 위치한 나라로 고유의 위스키를 생산합니다.'), - ('네덜란드', 'Netherlands', null, '맥주와 진으로 유명한 나라로 위스키 생산도 증가하고 있습니다.'); - -insert into distilleries (kor_name, eng_name, logo_img_url) -values ('글래스고', 'The Glasgow Distillery Co.', null), - ('글렌 그란트', 'Glen Grant', null), - ('글렌 기어리', 'Glen Garioch', null), - ('글렌 모레이', 'Glen Moray', null), - ('글렌 스코샤', 'Glen Scotia', null), - ('글렌 스페이', 'Glen Spey', null), - ('글렌 엘스', 'Glen Els', null), - ('글렌 엘진', 'Glen Elgin', null), - ('글렌가일', 'Glengyle', null), - ('글렌고인', 'Glengoyne', null), - ('글렌글라쏘', 'Glenglassaugh', null), - ('글렌달로', 'Glendalough Distillery', null), - ('글렌드로낙', 'Glendronach', null), - ('글렌로시', 'Glenlossie', null), - ('글렌로티스', 'Glenrothes', null), - ('글렌리벳', 'Glenlivet', null), - ('글렌모렌지', 'Glenmorangie', null), - ('글렌알라키', 'Glenallachie', null), - ('글렌카담', 'Glencadam', null), - ('글렌킨치', 'Glenkinchie', null), - ('글렌터렛', 'Glenturret', null), - ('글렌파클라스', 'Glenfarclas', null), - ('글렌피딕', 'Glenfiddich', null), - ('글리나', 'Glina Destillerie', null), - ('녹듀', 'Knockdhu', null), - ('뉴 리브 티스틸링', 'New Riff Distilling', null), - ('뉴 리프 디스틸링', 'New Riff Distilling', null), - ('달루인', 'Dailuaine', null), - ('달모어', 'Dalmore', null), - ('달위니', 'Dalwhinnie', null), - ('더프타운', 'Dufftown', null), - ('딘스톤', 'Deanston', null), - ('딩글', 'The Dingle Whiskey Distillery', null), - ('라가불린', 'Lagavulin', null), - ('라세이', 'Raasay Distillery', null), - ('라프로익', 'Laphroaig', null), - ('로얄 로크나가', 'Royal Lochnagar', null), - ('로얄 브라클라', 'Royal Brackla', null), - ('로크몬드', 'Loch Lomond', null), - ('루겐브로이', 'Rugenbräu', null), - ('링크우드', 'Linkwood', null), - ('마르스 신슈', 'The Mars Shinshu Distillery', null), - ('맥더프', 'Macduff', null), - ('맥미라', 'Mackmyra', null), - ('맥캘란', 'Macallan', null), - ('메이커스마크', 'Maker''s Mark Distillery, Inc.', null), - ('미들턴', 'Midleton (1975-)', null), - ('미야기쿄', 'Miyagikyo', null), - ('믹터스', 'Michter''s Distillery', null), - ('밀크 앤 허니', 'Milk & Honey Whisky Distillery', null), - ('밀톤더프', 'Miltonduff', null), - ('바렐 크래프트 스피릿', 'Barrell Craft Spirits', null), - ('발베니', 'Balvenie', null), - ('발블레어', 'Balblair', null), - ('발코네스', 'Balcones Distilling', null), - ('버팔로 트레이스', 'Buffalo Trace Distillery', null), - ('베르그슬라겐스', 'Bergslagens Destilleri', null), - ('벤 네비스', 'Ben Nevis', null), - ('벤로막', 'Benromach', null), - ('벤리네스', 'Benrinnes', null), - ('벤리악', 'BenRiach', null), - ('보모어', 'Bowmore', null), - ('부나하벤', 'Bunnahabhain', null), - ('부시밀', 'Bushmills', null), - ('브라우레이 로커', 'Brauerei Locher', null), - ('브라운 포 맨', 'Brown-Forman Distillery', null), - ('브라운슈타인', 'Braunstein', null), - ('브룩라디', 'Bruichladdich', null), - ('블라드녹', 'Bladnoch', null), - ('블레어 아톨', 'Blair Athol', null), - ('설리반 코브', 'Sullivans Cove', null), - ('세인트 킬리안', 'St. Kilian Distillers', null), - ('스뫼겐', 'Smögen', null), - ('스무스 앰블러 스피릿', 'Smooth Ambler Spirits', null), - ('스카파', 'Scapa', null), - ('스타워드', 'Starward', null), - ('스터닝', 'Stauning Whisky', null), - ('스트라스밀', 'Strathmill', null), - ('스트라티슬라', 'Strathisla', null), - ('스페이번', 'Speyburn', null), - ('스페이사이드', 'Speyside Distillery', null), - ('스프링뱅크', 'Springbank', null), - ('실리스', 'Slyrs', null), - ('아드나머칸', 'Ardnamurchan', null), - ('아드모어', 'Ardmore', null), - ('아드벡', 'Ardbeg', null), - ('아란', 'Arran', null), - ('아벨라워', 'Aberlour', null), - ('아사카', 'Asaka', null), - ('암룻', 'Amrut', null), - ('애버펠디', 'Aberfeldy', null), - ('야마자키', 'Yamazaki', null), - ('에드라두르', 'Edradour', null), - ('에드라두어', 'Edradour', null), - ('에이가시마 슈조', 'Eigashima Shuzo', null), - ('오반', 'Oban', null), - ('오켄토션', 'Auchentoshan', null), - ('와렝햄', 'Warenghem', null), - ('와일드 터키', 'Wild Turkey Distillery', null), - ('요이치', 'Yoichi', null), - ('우드포드 리저브', 'Woodford Reserve', null), - ('울트모어', 'Aultmore', null), - ('울프번', 'Wolfburn', null), - ('월렛', 'Willett Distillery', null), - ('웨스트랜드', 'Westland Distillery', null), - ('위도우 제인', 'Widow Jane Distillery', null), - ('잭다니엘', 'Jack Daniel''s', null), - ('존', 'John Distilleries', null), - ('주라', 'Isle of Jura', null), - ('쥐담', 'Zuidam Distillery', null), - ('짐 빔', 'Jim Beam', null), - ('츠누키', 'Tsunuki', null), - ('치치부', 'Chichibu', null), - ('카듀', 'Cardhu', null), - ('카발란', 'Kavalan', null), - ('카퍼도니히', 'Caperdonich', null), - ('쿨레이', 'Cooley', null), - ('쿨리', 'Cooley', null), - ('쿨일라', 'Caol Ila', null), - ('크라겐모어', 'Cragganmore', null), - ('크라이겔라키', 'Craigellachie', null), - ('크라이넬리쉬', 'Clynelish', null), - ('클레이', 'Cley Distillery', null), - ('클로나킬티', 'Clonakilty Distillery', null), - ('킬호만', 'Kilchoman', null), - ('탈라모어 듀', 'Tullamore Dew (2014 - present)', null), - ('탈리스커', 'Talisker', null), - ('탐나불린', 'Tamnavulin', null), - ('탐듀', 'Tamdhu', null), - ('터틸타운 스피리츠', 'Tuthilltown Spirits', null), - ('테렌펠리', 'Teerenpeli', null), - ('토마틴', 'Tomatin', null), - ('토모어', 'Tormore', null), - ('토민타울', 'Tomintoul', null), - ('토버모리', 'Tobermory', null), - ('툴리바딘', 'Tullibardine', null), - ('티니닉', 'Teaninich', null), - ('틸링', 'Teeling Whiskey Distillery', null), - ('페터케른', 'Fettercairn', null), - ('펜데린', 'Penderyn Distillery', null), - ('포 로지스', 'Four Roses Distillery', null), - ('풀티니', 'Pulteney', null), - ('하이 코스트', 'High Coast Distillery', null), - ('하이랜드 파크', 'Highland Park', null), - ('하쿠슈', 'Hakushu', null), - ('헤븐힐', 'Heaven Hill Distilleries, Inc.', null), - ('후지 코텐바', 'Fuji Gotemba', null), - ('휘슬피그', 'WhistlePig', null), - ('휘슬피거', 'WhistlePager', null), - ('ETC', 'ETC', null); - -insert into alcohols (kor_name, eng_name, abv, type, kor_category, eng_category, category_group, region_id, - distillery_id, age, cask, image_url) -values ('라이터스 티얼즈 레드 헤드', 'Writers'' Tears Red Head', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 12, 150, - null, 'Oloroso Sherry Butts', 'https://static.whiskybase.com/storage/whiskies/1/8/3881/318643-big.jpg'), - ('라이터스 티얼즈 더블 오크', 'Writers'' Tears Double Oak', '46', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, null, - 'American & French Oak', 'https://static.whiskybase.com/storage/whiskies/1/3/1308/282645-big.jpg'), - ('라이터스 티얼즈 코퍼 팟', 'Writers'' Tears Copper Pot', '40', 'WHISKY', '블렌디드 몰트', 'Blended Malt', 'BLENDED_MALT', 12, - 150, null, 'Bourbon Barrels', 'https://static.whiskybase.com/storage/whiskies/7/7/471/189958-big.jpg'), - ('VAT 69 블렌디드 스카치 위스키', 'VAT 69 Blended Scotch Whisky', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 16, 150, null, - null, 'https://static.whiskybase.com/storage/whiskies/8/1/189/246095-big.jpg'), - ('툴리바딘 소버린', 'Tullibardine Sovereign', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 137, null, - '1st fill bourbon barrel', 'https://static.whiskybase.com/storage/whiskies/1/7/4450/390659-big.jpg'), - ('탈라모어 듀 XO', 'Tullamore Dew XO', '43', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 127, null, - 'Caribbean Rum Cask Finish', 'https://static.whiskybase.com/storage/whiskies/1/0/9073/192995-big.jpg'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg'), - ('탈라모어 듀 사이다 캐스크 피니시', 'Tullamore Dew Cider Cask Finish', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, null, - 'Cider Cask Finish', 'https://static.whiskybase.com/storage/whiskies/6/9/408/192993-big.jpg'), - ('탈라모어 듀 14년', 'Tullamore Dew 14y', '41.3', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 12, 47, '14', - 'Bourbon, Oloroso Sherry, Port, Madeira Finish', - 'https://static.whiskybase.com/storage/whiskies/7/8/743/201942-big.jpg'), - ('탈라모어 듀 12년', 'Tullamore Dew 12y', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, '12', - 'Ex-Bourbon & Oloroso Sherry Casks', 'https://static.whiskybase.com/storage/whiskies/8/1/442/229572-big.jpg'), - ('토모어 16년', 'Tormore 16y', '48', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, '16', - 'American Oak Cask', 'https://static.whiskybase.com/storage/whiskies/2/0/0044/349731-big.jpg'), - ('토모어 14년', 'Tormore 14y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, '14', 'American Oak', - 'https://static.whiskybase.com/storage/whiskies/4/6/006/87125-big.jpg'), - ('토민타울 피티탱', 'Tomintoul With A Peaty Tang', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, null, - null, 'https://static.whiskybase.com/storage/whiskies/1/2/2408/373021-big.jpg'), - ('토민타울 시가몰트', 'Tomintoul Cigar Malt', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, null, - 'Oloroso', 'https://static.whiskybase.com/storage/whiskies/1/6/7902/372433-big.jpg'), - ('토민타울 16년', 'Tomintoul 16y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '16', null, - 'https://static.whiskybase.com/storage/whiskies/6/7/387/106575-big.jpg'), - ('토민타울 14년', 'Tomintoul 14y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '14', - 'American oak ex-bourbon', 'https://static.whiskybase.com/storage/whiskies/2/0/0756/133825-big.jpg'), - ('토민타울 10년', 'Tomintoul 10y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '10', - 'American oak ex-bourbon barrel', 'https://static.whiskybase.com/storage/whiskies/1/2/2573/205398-big.jpg'), - ('아벨라워 18년', 'Aberlour 18y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 89, '18', - 'Am. & Eur. Oak + 1st-Fill PX & Oloroso Finish', - 'https://static.whiskybase.com/storage/whiskies/2/3/7131/424101-big.jpg'), - ('포트 샬롯 10년', 'Port Charlotte 10y', '50', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 15, 69, '10', null, - 'https://static.whiskybase.com/storage/whiskies/1/1/2320/201819-big.jpg'), - ('카듀 엠버 록', 'Cardhu Amber Rock', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 115, null, - 'Bourbon Cask', 'https://static.whiskybase.com/storage/whiskies/5/4/607/343007-big.jpg'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg'), - ('아드나머칸 AD', 'Ardnamurchan AD/', '46.8', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 85, null, - 'Bourbon & Sherry', 'https://static.whiskybase.com/storage/whiskies/2/2/5270/415209-big.jpg'), - ('울프번 10년', 'Wolfburn 10y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 104, '10', - 'Oloroso Sherry Hogsheads', 'https://static.whiskybase.com/storage/whiskies/2/3/0518/412215-big.jpg'); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-help.sql b/bottlenote-product-api/src/test/resources/init-script/init-help.sql deleted file mode 100644 index f5b0760e0..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-help.sql +++ /dev/null @@ -1,3 +0,0 @@ -INSERT INTO helps (user_id, type, help_title, help_content, status, admin_id, response_content, create_at, create_by, - last_modify_at, last_modify_by) -VALUES (2, 'USER', '탈퇴관련문의', '탈퇴가 안돼요', 'WAITING', NULL, NULL, NULL, NULL, NULL, NULL); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-popular_alcohol.sql b/bottlenote-product-api/src/test/resources/init-script/init-popular_alcohol.sql deleted file mode 100644 index 7caeeb0a8..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-popular_alcohol.sql +++ /dev/null @@ -1,27 +0,0 @@ -insert into popular_alcohols (alcohol_id, year, month, day, review_score, rating_score, pick_score, popular_score) -values (1, 2024, 10, 9, 0.15, 0.43, 1.00, 0.49), - (1, 2024, 10, 17, 0.12, 0.42, 1.00, 0.48), - (274, 2024, 10, 17, 0.01, 0.91, 0.33, 0.38), - (2, 2024, 10, 17, 0.04, 0.77, 0.17, 0.29), - (6, 2024, 10, 17, 0.00, 0.76, 0.17, 0.28), - (124, 2024, 10, 17, 0.14, 0.72, 0.00, 0.27), - (135, 2024, 10, 17, 0.02, 0.60, 0.17, 0.24), - (179, 2024, 10, 17, 0.03, 0.73, 0.00, 0.23), - (232, 2024, 10, 17, 0.01, 0.59, 0.00, 0.18), - (5, 2024, 10, 17, 0.04, 0.38, 0.17, 0.18), - (435, 2024, 10, 17, 0.03, 0.48, 0.00, 0.15), - (33, 2024, 10, 17, 0.00, 0.00, 0.33, 0.10), - (42, 2024, 10, 17, 0.04, 0.00, 0.17, 0.07), - (559, 2024, 10, 17, 0.04, 0.00, 0.17, 0.06), - (434, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (462, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (64, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (566, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (485, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (421, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (382, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (289, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (314, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (433, 2024, 10, 17, 0.00, 0.00, 0.17, 0.05), - (43, 2024, 10, 17, 0.09, 0.00, 0.00, 0.04), - (123, 2024, 10, 17, 0.07, 0.00, 0.00, 0.03); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-review-reply.sql b/bottlenote-product-api/src/test/resources/init-script/init-review-reply.sql deleted file mode 100644 index bf34fb7f1..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-review-reply.sql +++ /dev/null @@ -1,9 +0,0 @@ -insert into review_replies (review_id, user_id, root_reply_id, parent_reply_id, content, create_at, create_by, - last_modify_at, last_modify_by) -values (4, 3, null, null, '1️⃣ Root 댓글', '2024-06-27 23:16:16', null, '2024-06-27 23:16:16', null), - (4, 3, null, null, '2️⃣2️⃣ Root 댓글', '2024-06-27 23:16:16', null, '2024-06-27 23:16:16', null), - (4, 3, 1, 1, '👍👍👍👍', '2024-06-27 23:16:23', null, '2024-06-27 23:16:23', null), - (4, 3, 1, 3, '👍👍👍👍', '2024-06-27 23:16:39', null, '2024-06-27 23:16:39', null), - (4, 3, 1, 3, '👍👍👍👍', '2024-06-27 23:16:43', null, '2024-06-27 23:16:43', null), - (4, 3, 1, 4, '👍👍👍👍', '2024-06-28 02:55:46', null, '2024-06-28 02:55:46', null), - (4, 3, 1, 4, '🌿🌿🌿🌿🌿', '2024-06-28 02:55:46', null, '2024-06-28 02:55:46', null); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-review.sql b/bottlenote-product-api/src/test/resources/init-script/init-review.sql deleted file mode 100644 index 4c7a7d318..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-review.sql +++ /dev/null @@ -1,40 +0,0 @@ -INSERT INTO reviews -(user_id, alcohol_id, is_best, content, size_type, price, location_name, zip_code, address, - detail_address, category, map_url, latitude, longitude, status, - image_url, view_count, active_status, create_at, create_by, last_modify_at, last_modify_by) -VALUES (1, 1, true, '이 위스키는 풍부하고 복잡한 맛이 매력적입니다.', 'BOTTLE', 65000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map1', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image01.jpg', NULL, 'ACTIVE', '2024-05-05 12:00:00', NULL, - NULL, NULL), - (1, 1, false, '가벼우면서도 깊은 맛이 느껴지는 위스키입니다.', 'GLASS', 45000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map2', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image02.jpg', NULL, 'ACTIVE', '2024-05-02 13:00:00', NULL, - NULL, NULL), - (1, 1, false, '향기로운 바닐라 향이 나는 부드러운 위스키입니다.', 'BOTTLE', 77000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map3', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image03.jpg', NULL, 'ACTIVE', '2024-05-16 14:30:00', NULL, - NULL, NULL), - (2, 4, false, '스모키하고 강한 페트 향이 인상적인 위스키입니다.', 'BOTTLE', 120000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map4', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image04.jpg', NULL, 'ACTIVE', '2024-05-01 15:45:00', NULL, - NULL, NULL), - (2, 2, false, '달콤한 캐러멜과 과일 향이 조화를 이루는 맛있습니다.', 'GLASS', 99000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map5', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image05.jpg', NULL, 'ACTIVE', '2024-05-08 16:00:00', NULL, - NULL, NULL), - (4, 1, false, '약간의 훈연향이 입안에 오래 남습니다.', 'GLASS', 52000, 'yyPub', '06001', - '서울시 강남구 역삼동', 'yyPub 역삼점', 'bar', 'https://maps.example.com/map9', '12.124', '12.124', - 'PUBLIC', 'https://example.com/image09.jpg', NULL, 'ACTIVE', '2024-05-10 21:00:00', NULL, - NULL, NULL), - (5, 1, true, '고급스러운 피트향과 묵직한 바디감이 만족스럽습니다.', 'BOTTLE', 99000, 'yyPub', '06001', - '서울시 강남구 역삼동', 'yyPub 역삼점', 'bar', 'https://maps.example.com/map10', '12.124', '12.124', - 'PUBLIC', 'https://example.com/image10.jpg', NULL, 'ACTIVE', '2024-05-11 22:00:00', NULL, - NULL, NULL), - (6, 1, false, '산뜻한 감귤 향이 느껴지는 독특한 위스키입니다.', 'GLASS', 46000, 'yyPub', '06001', - '서울시 강남구 역삼동', 'yyPub 역삼점', 'bar', 'https://maps.example.com/map11', '12.124', '12.124', - 'PUBLIC', 'https://example.com/image11.jpg', NULL, 'ACTIVE', '2024-05-12 23:00:00', NULL, - NULL, NULL), - (7, 1, false, '캐러멜과 초콜릿 향이 잘 어우러지는 부드러운 위스키입니다.', 'BOTTLE', 78000, 'yyPub', '06001', - '서울시 강남구 역삼동', 'yyPub 역삼점', 'bar', 'https://maps.example.com/map12', '12.124', '12.124', - 'PUBLIC', 'https://example.com/image12.jpg', NULL, 'ACTIVE', '2024-05-13 23:30:00', NULL, - NULL, NULL); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-user-history.sql b/bottlenote-product-api/src/test/resources/init-script/init-user-history.sql deleted file mode 100644 index b180bcc83..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-user-history.sql +++ /dev/null @@ -1,507 +0,0 @@ -insert into users (email, nick_name, age, image_url, gender, role, status, social_type, - refresh_token, create_at, - last_modify_at) -values ('hyejj19@naver.com', 'WOzU6J8541', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '[ - "KAKAO" -]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJoeWVqajE5QG5hdmVyLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjoxLCJpYXQiOjE3MTkwNDA2ODAsImV4cCI6MTcyMDI1MDI4MH0._s1r4Je9wFTvu_hV0sYBVRr5uDqiHXVBM22jS35YNbH0z-svrTYjysORA4J2J5GQcel9K5FxRBQnWjAeqQNfdw', - NULL, NULL), - ('chadongmin@naver.com', 'xIFo6J8726', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '[ - "KAKAO" - ]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjaGFkb25nbWluQG5hdmVyLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjoyLCJpYXQiOjE3MjA3MDE2NjcsImV4cCI6MTcyMTkxMTI2N30.dNGvbasf2gccc4TImO2pGg7wIZi_VPNupGYmIRYqutRKrXq79ep0J-sE1OMpk7GC4y3nFkiqvwSWznHggrmRFA', - NULL, NULL), - ('dev.bottle-note@gmail.com', 'PARC6J8814', 25, NULL, 'MALE', 'ROLE_USER', 'ACTIVE', - '[ - "GOOGLE" - ]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJkZXYuYm90dGxlLW5vdGVAZ21haWwuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjMsImlhdCI6MTcyMDM1MDY4OCwiZXhwIjoxNzIxNTYwMjg4fQ.two8yLXv2xFCEOhuGrLYV8cmewm8bD8EbIWTXYa896MprhgclsGNDThspjRF9VJmSA3mxvoPjnBJ0ClneCClBQ', - NULL, NULL), - ('eva.park@oysterable.com', 'VOKs6J8831', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', - '[ - "GOOGLE" - ]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldmEucGFya0BveXN0ZXJhYmxlLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjo1LCJpYXQiOjE3MTc4MzU1MDUsImV4cCI6MTcxOTA0NTEwNX0.j6u6u8a8lhedeegOe2wqOjNZkMx0X3RgVeAcvnlCZmj_AXQF5WDo4k71WI-bFt_ypW-ewVCRmdLQoOduaggCRw', - NULL, NULL), - ('rlagusrl928@gmail.com', 'hpPw6J111837', NULL, NULL, 'MALE', 'ROLE_USER', 'ACTIVE', - '[ - "GOOGLE" - ]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJybGFndXNybDkyOEBnbWFpbC5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6NiwiaWF0IjoxNzE4MDk4NjQxLCJleHAiOjE3MTkzMDgyNDF9.0nfUYMm4UEFzfE52ydulDZ0eX5U_2yBN4hCeBXr4PeA3xwbzDo7t2c2kJGNU_LXMbZg2Iz4DAIZnu0QB3DJ8VA', - NULL, NULL), - ('ytest@gmail.com', 'OMkS6J12123', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '[ - "GOOGLE" - ]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ5dGVzdEBnbWFpbC5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6NywiaWF0IjoxNzE4MTIzMDMxLCJleHAiOjE3MTkzMzI2MzF9.8KNJW6havezUSgaWmRyAvlxfwdRZxjdC7mcBuexN0Gy9NtgJIAVWqNMW0wlJXw7d9LVwtZf5Mv4aUdA_V-V8pw', - NULL, NULL), - ('juye@gmail.com', 'juye12', NULL, 'http://example.com/new-profile-image.jpg', NULL, - 'ROLE_USER', 'ACTIVE', - '[ - "GOOGLE" - ]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqdXllQGdtYWlsLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjo4LCJpYXQiOjE3MjA2OTg0MDcsImV4cCI6MTcyMTkwODAwN30.VaTNqvK-e8g9zsaOhoEmrKQKATCKtKqSIvuOCKpB0lS5L2I7Xd-iVhkwuVHfySalK0y_t_hf9CsIfeIjWgO0gw', - NULL, NULL), - ('rkdtkfma@naver.com', 'iZBq6J22547', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '[ - "KAKAO" - ]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJya2R0a2ZtYUBuYXZlci5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6OSwiaWF0IjoxNzIwNzAzMjExLCJleHAiOjE3MjE5MTI4MTF9.pD-MCIPRxbYBLJ2ZPei_529YCop_a8yVKPCsz-YYlvCjyAM40aVQRPv2rDg2wfCZAr5c3NKyS210LwQXwxf1OQ', - NULL, NULL); - -insert into regions (kor_name, eng_name, continent, description, create_at, create_by, - last_modify_at, last_modify_by) -values ('호주', 'Australia', null, '오세아니아에 위치한 나라로 다양한 위스키를 생산.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('핀란드', 'Finland', null, '북유럽에 위치한 나라로 청정한 자연환경을 자랑.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('프랑스', 'France', null, '와인과 브랜디로 유명한 유럽의 나라.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('타이완', 'Taiwan', null, '고품질의 위스키로 유명한 동아시아의 섬나라.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('캐나다', 'Canada', null, '위스키 생산이 활발한 북미의 나라.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('체코', 'Czech Republic', null, '중앙유럽에 위치한 나라로 맥주로도 유명.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('일본', 'Japan', null, '전통과 현대가 조화된 동아시아의 섬나라.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('인도', 'India', null, '다양한 문화와 역사를 지닌 남아시아의 나라.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('이스라엘', 'Israel', null, '중동에 위치한 나라로 와인과 위스키 생산이 증가.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('웨일즈', 'Wales', null, '영국의 구성국 중 하나로 독자적인 위스키 생산.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('영국', 'United Kingdom', null, '다양한 위스키 지역을 가진 나라.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('아일랜드', 'Ireland', null, '풍부한 전통을 지닌 위스키의 본고장 중 하나입니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('아이슬란드', 'Iceland', null, '청정한 자연환경과 독특한 술 문화를 가진 섬나라입니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/캠벨타운', 'Scotland/Campbeltown', null, '스코틀랜드의 위스키 지역 중 하나로 해안가에 위치한 반도 형태의 지역입니다.', - '2024-06-04 17:19:39', - 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/아일라', 'Scotland/Islay', null, '피트위스키의 대표 생산지입니다. 스모키한맛이 특징인 위스키로 유명한 섬입니다.', - '2024-06-04 17:19:39', - 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/스페이사이드 ', 'Scotland/Speyside', null, '스코틀랜드의 중심부에 위치하고있습니다. 더프타운에 다양한 증류소들이 있습니다.', - '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/로우랜드', 'Scotland/Lowlands', null, '부드럽고 가벼운 맛이 특징인 위스키로 유명합니다.', - '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/기타섬', 'Scotland/Islands', null, '다양한 섬에서 독특한 맛의 위스키를 생산하고 있습니다.', - '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드', 'Scotland/', null, '위스키의 본고장으로 다양한 스타일의 위스키를 생산합니다.', '2024-06-04 17:19:39', - 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코트랜드/하이랜드', 'Scotland/Highlands', null, '스코틀랜드 북부 지역으로 섬세한 맛의 위스키로 유명.', - '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스위스', 'Switzerland', null, '알프스 산맥을 배경으로 한 유럽의 나라입니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('스웨덴', 'Sweden', null, '북유럽에 위치한 나라로 맥주와 위스키 생산 증가하고 있습니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('미국', 'United States', null, '다양한 스타일의 위스키를 생산하는 나라. 주로 켄터키의 버번위스키가 유명합니다.', - '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('독일', 'Germany', null, '맥주로 유명한 나라로 위스키 생산도 활발합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('덴마크', 'Denmark', null, '북유럽에 위치한 나라로 고유의 위스키를 생산합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', - 'admin'), - ('네덜란드', 'Netherlands', null, '맥주와 진으로 유명한 나라로 위스키 생산도 증가하고 있습니다.', '2024-06-04 17:19:39', - 'admin', - '2024-06-04 17:19:39', 'admin'); - -insert into distilleries (kor_name, eng_name, logo_img_url, create_at, create_by, last_modify_at, - last_modify_by) -values ('글래스고', 'The Glasgow Distillery Co.', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('글렌 그란트', 'Glen Grant', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌 기어리', 'Glen Garioch', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌 모레이', 'Glen Moray', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌 스코샤', 'Glen Scotia', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌 스페이', 'Glen Spey', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌 엘스', 'Glen Els', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 엘진', 'Glen Elgin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌가일', 'Glengyle', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌고인', 'Glengoyne', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌글라쏘', 'Glenglassaugh', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌달로', 'Glendalough Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('글렌드로낙', 'Glendronach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌로시', 'Glenlossie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌로티스', 'Glenrothes', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌리벳', 'Glenlivet', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌모렌지', 'Glenmorangie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌알라키', 'Glenallachie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌카담', 'Glencadam', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌킨치', 'Glenkinchie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌터렛', 'Glenturret', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌파클라스', 'Glenfarclas', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글렌피딕', 'Glenfiddich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('글리나', 'Glina Destillerie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('녹듀', 'Knockdhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('뉴 리브 티스틸링', 'New Riff Distilling', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('뉴 리프 디스틸링', 'New Riff Distilling', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('달루인', 'Dailuaine', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달모어', 'Dalmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달위니', 'Dalwhinnie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('더프타운', 'Dufftown', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('딘스톤', 'Deanston', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('딩글', 'The Dingle Whiskey Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('라가불린', 'Lagavulin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('라세이', 'Raasay Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('라프로익', 'Laphroaig', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('로얄 로크나가', 'Royal Lochnagar', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('로얄 브라클라', 'Royal Brackla', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('로크몬드', 'Loch Lomond', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('루겐브로이', 'Rugenbräu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('링크우드', 'Linkwood', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('마르스 신슈', 'The Mars Shinshu Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('맥더프', 'Macduff', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥미라', 'Mackmyra', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥캘란', 'Macallan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('메이커스마크', 'Maker''s Mark Distillery, Inc.', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', - 'admin'), - ('미들턴', 'Midleton (1975-)', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('미야기쿄', 'Miyagikyo', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('믹터스', 'Michter''s Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('밀크 앤 허니', 'Milk & Honey Whisky Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', - 'admin'), - ('밀톤더프', 'Miltonduff', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('바렐 크래프트 스피릿', 'Barrell Craft Spirits', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('발베니', 'Balvenie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발블레어', 'Balblair', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발코네스', 'Balcones Distilling', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('버팔로 트레이스', 'Buffalo Trace Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('베르그슬라겐스', 'Bergslagens Destilleri', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('벤 네비스', 'Ben Nevis', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤로막', 'Benromach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤리네스', 'Benrinnes', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤리악', 'BenRiach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('보모어', 'Bowmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('부나하벤', 'Bunnahabhain', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('부시밀', 'Bushmills', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브라우레이 로커', 'Brauerei Locher', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('브라운 포 맨', 'Brown-Forman Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('브라운슈타인', 'Braunstein', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('브룩라디', 'Bruichladdich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('블라드녹', 'Bladnoch', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('블레어 아톨', 'Blair Athol', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('설리반 코브', 'Sullivans Cove', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('세인트 킬리안', 'St. Kilian Distillers', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('스뫼겐', 'Smögen', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스무스 앰블러 스피릿', 'Smooth Ambler Spirits', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('스카파', 'Scapa', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스타워드', 'Starward', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스터닝', 'Stauning Whisky', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('스트라스밀', 'Strathmill', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('스트라티슬라', 'Strathisla', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('스페이번', 'Speyburn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스페이사이드', 'Speyside Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('스프링뱅크', 'Springbank', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('실리스', 'Slyrs', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드나머칸', 'Ardnamurchan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('아드모어', 'Ardmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드벡', 'Ardbeg', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아란', 'Arran', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아벨라워', 'Aberlour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아사카', 'Asaka', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('암룻', 'Amrut', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('애버펠디', 'Aberfeldy', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('야마자키', 'Yamazaki', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에드라두르', 'Edradour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에드라두어', 'Edradour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에이가시마 슈조', 'Eigashima Shuzo', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('오반', 'Oban', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('오켄토션', 'Auchentoshan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('와렝햄', 'Warenghem', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('와일드 터키', 'Wild Turkey Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('요이치', 'Yoichi', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('우드포드 리저브', 'Woodford Reserve', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('울트모어', 'Aultmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('울프번', 'Wolfburn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('월렛', 'Willett Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('웨스트랜드', 'Westland Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('위도우 제인', 'Widow Jane Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('잭다니엘', 'Jack Daniel''s', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('존', 'John Distilleries', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('주라', 'Isle of Jura', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쥐담', 'Zuidam Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('짐 빔', 'Jim Beam', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('츠누키', 'Tsunuki', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('치치부', 'Chichibu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카듀', 'Cardhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카발란', 'Kavalan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카퍼도니히', 'Caperdonich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('쿨레이', 'Cooley', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨리', 'Cooley', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨일라', 'Caol Ila', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('크라겐모어', 'Cragganmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('크라이겔라키', 'Craigellachie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('크라이넬리쉬', 'Clynelish', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('클레이', 'Cley Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('클로나킬티', 'Clonakilty Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('킬호만', 'Kilchoman', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탈라모어 듀', 'Tullamore Dew (2014 - present)', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', - 'admin'), - ('탈리스커', 'Talisker', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탐나불린', 'Tamnavulin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탐듀', 'Tamdhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('터틸타운 스피리츠', 'Tuthilltown Spirits', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('테렌펠리', 'Teerenpeli', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토마틴', 'Tomatin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토모어', 'Tormore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토민타울', 'Tomintoul', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토버모리', 'Tobermory', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('툴리바딘', 'Tullibardine', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('티니닉', 'Teaninich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('틸링', 'Teeling Whiskey Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('페터케른', 'Fettercairn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('펜데린', 'Penderyn Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('포 로지스', 'Four Roses Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('풀티니', 'Pulteney', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('하이 코스트', 'High Coast Distillery', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('하이랜드 파크', 'Highland Park', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('하쿠슈', 'Hakushu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('헤븐힐', 'Heaven Hill Distilleries, Inc.', null, '2024-06-04 17:09:03', 'admin', - '2024-06-04 17:09:03', 'admin'), - ('후지 코텐바', 'Fuji Gotemba', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('휘슬피그', 'WhistlePig', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('휘슬피거', 'WhistlePager', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('ETC', 'ETC', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'); - -insert into alcohols (kor_name, eng_name, abv, type, kor_category, eng_category, category_group, - region_id, - distillery_id, age, cask, image_url, create_at, create_by, last_modify_at, - last_modify_by) -values ('라이터스 티얼즈 레드 헤드', 'Writers'' Tears Red Head', '46', 'WHISKY', '싱글 몰트', 'Single Malt', - 'SINGLE_MALT', 12, 150, - null, 'Oloroso Sherry Butts', - 'https://static.whiskybase.com/storage/whiskies/1/8/3881/318643-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('라이터스 티얼즈 더블 오크', 'Writers'' Tears Double Oak', '46', 'WHISKY', '블렌디드', 'Blend', 'BLEND', - 12, 47, null, - 'American & French Oak', - 'https://static.whiskybase.com/storage/whiskies/1/3/1308/282645-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('라이터스 티얼즈 코퍼 팟', 'Writers'' Tears Copper Pot', '40', 'WHISKY', '블렌디드 몰트', 'Blended Malt', - 'BLENDED_MALT', 12, - 150, null, 'Bourbon Barrels', - 'https://static.whiskybase.com/storage/whiskies/7/7/471/189958-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('VAT 69 블렌디드 스카치 위스키', 'VAT 69 Blended Scotch Whisky', '40', 'WHISKY', '블렌디드', 'Blend', - 'BLEND', 16, 150, null, - null, 'https://static.whiskybase.com/storage/whiskies/8/1/189/246095-big.jpg', - '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('툴리바딘 소버린', 'Tullibardine Sovereign', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', - 20, 137, null, - '1st fill bourbon barrel', - 'https://static.whiskybase.com/storage/whiskies/1/7/4450/390659-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 XO', 'Tullamore Dew XO', '43', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 127, null, - 'Caribbean Rum Cask Finish', - 'https://static.whiskybase.com/storage/whiskies/1/0/9073/192995-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', - '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 사이다 캐스크 피니시', 'Tullamore Dew Cider Cask Finish', '40', 'WHISKY', '블렌디드', 'Blend', - 'BLEND', 12, 47, null, - 'Cider Cask Finish', - 'https://static.whiskybase.com/storage/whiskies/6/9/408/192993-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 14년', 'Tullamore Dew 14y', '41.3', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', - 12, 47, '14', - 'Bourbon, Oloroso Sherry, Port, Madeira Finish', - 'https://static.whiskybase.com/storage/whiskies/7/8/743/201942-big.jpg', - '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 12년', 'Tullamore Dew 12y', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, '12', - 'Ex-Bourbon & Oloroso Sherry Casks', - 'https://static.whiskybase.com/storage/whiskies/8/1/442/229572-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토모어 16년', 'Tormore 16y', '48', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, - '16', - 'American Oak Cask', - 'https://static.whiskybase.com/storage/whiskies/2/0/0044/349731-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토모어 14년', 'Tormore 14y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, - '14', 'American Oak', - 'https://static.whiskybase.com/storage/whiskies/4/6/006/87125-big.jpg', - '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 피티탱', 'Tomintoul With A Peaty Tang', '40', 'WHISKY', '싱글 몰트', 'Single Malt', - 'SINGLE_MALT', 16, 135, null, - null, 'https://static.whiskybase.com/storage/whiskies/1/2/2408/373021-big.jpg', - '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 시가몰트', 'Tomintoul Cigar Malt', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', - 16, 135, null, - 'Oloroso', 'https://static.whiskybase.com/storage/whiskies/1/6/7902/372433-big.jpg', - '2024-06-08 05:06:00', - 'admin', '2024-06-08 05:06:00', 'admin'), - ('토민타울 16년', 'Tomintoul 16y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, - '16', null, - 'https://static.whiskybase.com/storage/whiskies/6/7/387/106575-big.jpg', - '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 14년', 'Tomintoul 14y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, - '14', - 'American oak ex-bourbon', - 'https://static.whiskybase.com/storage/whiskies/2/0/0756/133825-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토민타울 10년', 'Tomintoul 10y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, - '10', - 'American oak ex-bourbon barrel', - 'https://static.whiskybase.com/storage/whiskies/1/2/2573/205398-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('아벨라워 18년', 'Aberlour 18y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 89, - '18', - 'Am. & Eur. Oak + 1st-Fill PX & Oloroso Finish', - 'https://static.whiskybase.com/storage/whiskies/2/3/7131/424101-big.jpg', - '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('포트 샬롯 10년', 'Port Charlotte 10y', '50', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', - 15, 69, '10', null, - 'https://static.whiskybase.com/storage/whiskies/1/1/2320/201819-big.jpg', - '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('카듀 엠버 록', 'Cardhu Amber Rock', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, - 115, null, - 'Bourbon Cask', 'https://static.whiskybase.com/storage/whiskies/5/4/607/343007-big.jpg', - '2024-06-08 05:06:00', - 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', - '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('아드나머칸 AD', 'Ardnamurchan AD/', '46.8', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, - 85, null, - 'Bourbon & Sherry', - 'https://static.whiskybase.com/storage/whiskies/2/2/5270/415209-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('울프번 10년', 'Wolfburn 10y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 104, - '10', - 'Oloroso Sherry Hogsheads', - 'https://static.whiskybase.com/storage/whiskies/2/3/0518/412215-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'); - -INSERT INTO user_histories (id, user_id, event_category, event_type, redirect_url, image_url, alcohol_id, - content, dynamic_message, event_year, - event_month, - create_at, create_by, last_modify_at, last_modify_by) -VALUES (1, 1, 'RATING', 'START_RATING', 'api/v1/rating', - 'https://static.whiskybase.com/storage/whiskies/2/0/8916/404538-big.jpg', 1, - null, null, '2025', '01', - '2025-01-01 19:50:26', 'chadongmin@example.com', '2025-01-20 19:50:26', - 'chadongmin@example.com'), - - (2, 1, 'REVIEW', 'REVIEW_CREATE', 'api/v1/reviews', - 'https://static.whiskybase.com/storage/whiskies/2/0/8916/404538-big.jpg', 1, - 'blah blah', null, '2025', '01', - '2025-01-02 19:50:35', 'chadongmin@example.com', '2025-01-20 19:50:35', - 'chadongmin@example.com'), - - (3, 1, 'REVIEW', 'REVIEW_CREATE', 'api/v1/reviews', - 'https://static.whiskybase.com/storage/whiskies/2/0/8888/404535-big.jpg', 2, - '리뷰입니다.', null, '2025', '01', - '2025-01-03 19:50:47', 'chadongmin@example.com', '2025-01-20 19:50:47', - 'chadongmin@example.com'), - - (4, 1, 'REVIEW', 'REVIEW_CREATE', 'api/v1/reviews', - 'https://static.whiskybase.com/storage/whiskies/2/1/1644/404542-big.jpg', 3, - '리뷰 등록', null, '2025', '01', - '2025-01-04 19:50:56', 'chadongmin@example.com', '2025-01-20 19:50:56', - 'chadongmin@example.com'), - - (5, 1, 'PICK', 'UNPICK', 'api/v1/picks', - 'https://static.whiskybase.com/storage/whiskies/2/0/8916/404538-big.jpg', 1, - null, null, '2025', '01', - '2025-01-05 19:52:07', 'chadongmin@example.com', '2025-01-20 19:52:07', - 'chadongmin@example.com') diff --git a/bottlenote-product-api/src/test/resources/init-script/init-user-mybottle-query.sql b/bottlenote-product-api/src/test/resources/init-script/init-user-mybottle-query.sql deleted file mode 100644 index 06443fb99..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-user-mybottle-query.sql +++ /dev/null @@ -1,343 +0,0 @@ -insert into users (email, nick_name, age, image_url, gender, role, status, social_type, refresh_token, create_at, - last_modify_at) -values ('hyejj19@naver.com', 'WOzU6J8541', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '["KAKAO"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJoeWVqajE5QG5hdmVyLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjoxLCJpYXQiOjE3MTkwNDA2ODAsImV4cCI6MTcyMDI1MDI4MH0._s1r4Je9wFTvu_hV0sYBVRr5uDqiHXVBM22jS35YNbH0z-svrTYjysORA4J2J5GQcel9K5FxRBQnWjAeqQNfdw', - NULL, NULL), - ('chadongmin@naver.com', 'xIFo6J8726', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '["KAKAO"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjaGFkb25nbWluQG5hdmVyLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjoyLCJpYXQiOjE3MjA3MDE2NjcsImV4cCI6MTcyMTkxMTI2N30.dNGvbasf2gccc4TImO2pGg7wIZi_VPNupGYmIRYqutRKrXq79ep0J-sE1OMpk7GC4y3nFkiqvwSWznHggrmRFA', - NULL, NULL), - ('dev.bottle-note@gmail.com', 'PARC6J8814', 25, NULL, 'MALE', 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJkZXYuYm90dGxlLW5vdGVAZ21haWwuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjMsImlhdCI6MTcyMDM1MDY4OCwiZXhwIjoxNzIxNTYwMjg4fQ.two8yLXv2xFCEOhuGrLYV8cmewm8bD8EbIWTXYa896MprhgclsGNDThspjRF9VJmSA3mxvoPjnBJ0ClneCClBQ', - NULL, NULL), - ('eva.park@oysterable.com', 'VOKs6J8831', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldmEucGFya0BveXN0ZXJhYmxlLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjo1LCJpYXQiOjE3MTc4MzU1MDUsImV4cCI6MTcxOTA0NTEwNX0.j6u6u8a8lhedeegOe2wqOjNZkMx0X3RgVeAcvnlCZmj_AXQF5WDo4k71WI-bFt_ypW-ewVCRmdLQoOduaggCRw', - NULL, NULL), - ('rlagusrl928@gmail.com', 'hpPw6J111837', NULL, NULL, 'MALE', 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJybGFndXNybDkyOEBnbWFpbC5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6NiwiaWF0IjoxNzE4MDk4NjQxLCJleHAiOjE3MTkzMDgyNDF9.0nfUYMm4UEFzfE52ydulDZ0eX5U_2yBN4hCeBXr4PeA3xwbzDo7t2c2kJGNU_LXMbZg2Iz4DAIZnu0QB3DJ8VA', - NULL, NULL), - ('ytest@gmail.com', 'OMkS6J12123', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '["GOOGLE"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ5dGVzdEBnbWFpbC5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6NywiaWF0IjoxNzE4MTIzMDMxLCJleHAiOjE3MTkzMzI2MzF9.8KNJW6havezUSgaWmRyAvlxfwdRZxjdC7mcBuexN0Gy9NtgJIAVWqNMW0wlJXw7d9LVwtZf5Mv4aUdA_V-V8pw', - NULL, NULL), - ('juye@gmail.com', 'juye12', NULL, 'http://example.com/new-profile-image.jpg', NULL, 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqdXllQGdtYWlsLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjo4LCJpYXQiOjE3MjA2OTg0MDcsImV4cCI6MTcyMTkwODAwN30.VaTNqvK-e8g9zsaOhoEmrKQKATCKtKqSIvuOCKpB0lS5L2I7Xd-iVhkwuVHfySalK0y_t_hf9CsIfeIjWgO0gw', - NULL, NULL), - ('rkdtkfma@naver.com', 'iZBq6J22547', NULL, NULL, NULL, 'ROLE_USER', 'ACTIVE', '["KAKAO"]', - 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJya2R0a2ZtYUBuYXZlci5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6OSwiaWF0IjoxNzIwNzAzMjExLCJleHAiOjE3MjE5MTI4MTF9.pD-MCIPRxbYBLJ2ZPei_529YCop_a8yVKPCsz-YYlvCjyAM40aVQRPv2rDg2wfCZAr5c3NKyS210LwQXwxf1OQ', - NULL, NULL); - -insert into regions (kor_name, eng_name, continent, description, create_at, create_by, last_modify_at, last_modify_by) -values ('호주', 'Australia', null, '오세아니아에 위치한 나라로 다양한 위스키를 생산.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('핀란드', 'Finland', null, '북유럽에 위치한 나라로 청정한 자연환경을 자랑.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('프랑스', 'France', null, '와인과 브랜디로 유명한 유럽의 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('타이완', 'Taiwan', null, '고품질의 위스키로 유명한 동아시아의 섬나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('캐나다', 'Canada', null, '위스키 생산이 활발한 북미의 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('체코', 'Czech Republic', null, '중앙유럽에 위치한 나라로 맥주로도 유명.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('일본', 'Japan', null, '전통과 현대가 조화된 동아시아의 섬나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('인도', 'India', null, '다양한 문화와 역사를 지닌 남아시아의 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('이스라엘', 'Israel', null, '중동에 위치한 나라로 와인과 위스키 생산이 증가.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('웨일즈', 'Wales', null, '영국의 구성국 중 하나로 독자적인 위스키 생산.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('영국', 'United Kingdom', null, '다양한 위스키 지역을 가진 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('아일랜드', 'Ireland', null, '풍부한 전통을 지닌 위스키의 본고장 중 하나입니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('아이슬란드', 'Iceland', null, '청정한 자연환경과 독특한 술 문화를 가진 섬나라입니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/캠벨타운', 'Scotland/Campbeltown', null, '스코틀랜드의 위스키 지역 중 하나로 해안가에 위치한 반도 형태의 지역입니다.', '2024-06-04 17:19:39', - 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/아일라', 'Scotland/Islay', null, '피트위스키의 대표 생산지입니다. 스모키한맛이 특징인 위스키로 유명한 섬입니다.', '2024-06-04 17:19:39', - 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/스페이사이드 ', 'Scotland/Speyside', null, '스코틀랜드의 중심부에 위치하고있습니다. 더프타운에 다양한 증류소들이 있습니다.', - '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/로우랜드', 'Scotland/Lowlands', null, '부드럽고 가벼운 맛이 특징인 위스키로 유명합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/기타섬', 'Scotland/Islands', null, '다양한 섬에서 독특한 맛의 위스키를 생산하고 있습니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드', 'Scotland/', null, '위스키의 본고장으로 다양한 스타일의 위스키를 생산합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코트랜드/하이랜드', 'Scotland/Highlands', null, '스코틀랜드 북부 지역으로 섬세한 맛의 위스키로 유명.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스위스', 'Switzerland', null, '알프스 산맥을 배경으로 한 유럽의 나라입니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('스웨덴', 'Sweden', null, '북유럽에 위치한 나라로 맥주와 위스키 생산 증가하고 있습니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('미국', 'United States', null, '다양한 스타일의 위스키를 생산하는 나라. 주로 켄터키의 버번위스키가 유명합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('독일', 'Germany', null, '맥주로 유명한 나라로 위스키 생산도 활발합니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('덴마크', 'Denmark', null, '북유럽에 위치한 나라로 고유의 위스키를 생산합니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('네덜란드', 'Netherlands', null, '맥주와 진으로 유명한 나라로 위스키 생산도 증가하고 있습니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'); - -insert into distilleries (kor_name, eng_name, logo_img_url, create_at, create_by, last_modify_at, last_modify_by) -values ('글래스고', 'The Glasgow Distillery Co.', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 그란트', 'Glen Grant', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 기어리', 'Glen Garioch', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 모레이', 'Glen Moray', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 스코샤', 'Glen Scotia', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 스페이', 'Glen Spey', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 엘스', 'Glen Els', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 엘진', 'Glen Elgin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌가일', 'Glengyle', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌고인', 'Glengoyne', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌글라쏘', 'Glenglassaugh', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌달로', 'Glendalough Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌드로낙', 'Glendronach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌로시', 'Glenlossie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌로티스', 'Glenrothes', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌리벳', 'Glenlivet', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌모렌지', 'Glenmorangie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌알라키', 'Glenallachie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌카담', 'Glencadam', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌킨치', 'Glenkinchie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌터렛', 'Glenturret', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌파클라스', 'Glenfarclas', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌피딕', 'Glenfiddich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글리나', 'Glina Destillerie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('녹듀', 'Knockdhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('뉴 리브 티스틸링', 'New Riff Distilling', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('뉴 리프 디스틸링', 'New Riff Distilling', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달루인', 'Dailuaine', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달모어', 'Dalmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달위니', 'Dalwhinnie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('더프타운', 'Dufftown', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('딘스톤', 'Deanston', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('딩글', 'The Dingle Whiskey Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('라가불린', 'Lagavulin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('라세이', 'Raasay Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('라프로익', 'Laphroaig', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('로얄 로크나가', 'Royal Lochnagar', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('로얄 브라클라', 'Royal Brackla', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('로크몬드', 'Loch Lomond', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('루겐브로이', 'Rugenbräu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('링크우드', 'Linkwood', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('마르스 신슈', 'The Mars Shinshu Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥더프', 'Macduff', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥미라', 'Mackmyra', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥캘란', 'Macallan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('메이커스마크', 'Maker''s Mark Distillery, Inc.', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('미들턴', 'Midleton (1975-)', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('미야기쿄', 'Miyagikyo', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('믹터스', 'Michter''s Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('밀크 앤 허니', 'Milk & Honey Whisky Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('밀톤더프', 'Miltonduff', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('바렐 크래프트 스피릿', 'Barrell Craft Spirits', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발베니', 'Balvenie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발블레어', 'Balblair', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발코네스', 'Balcones Distilling', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('버팔로 트레이스', 'Buffalo Trace Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('베르그슬라겐스', 'Bergslagens Destilleri', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤 네비스', 'Ben Nevis', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤로막', 'Benromach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤리네스', 'Benrinnes', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤리악', 'BenRiach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('보모어', 'Bowmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('부나하벤', 'Bunnahabhain', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('부시밀', 'Bushmills', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브라우레이 로커', 'Brauerei Locher', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브라운 포 맨', 'Brown-Forman Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브라운슈타인', 'Braunstein', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브룩라디', 'Bruichladdich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('블라드녹', 'Bladnoch', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('블레어 아톨', 'Blair Athol', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('설리반 코브', 'Sullivans Cove', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('세인트 킬리안', 'St. Kilian Distillers', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스뫼겐', 'Smögen', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스무스 앰블러 스피릿', 'Smooth Ambler Spirits', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스카파', 'Scapa', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스타워드', 'Starward', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스터닝', 'Stauning Whisky', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스트라스밀', 'Strathmill', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스트라티슬라', 'Strathisla', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스페이번', 'Speyburn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스페이사이드', 'Speyside Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스프링뱅크', 'Springbank', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('실리스', 'Slyrs', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드나머칸', 'Ardnamurchan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드모어', 'Ardmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드벡', 'Ardbeg', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아란', 'Arran', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아벨라워', 'Aberlour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아사카', 'Asaka', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('암룻', 'Amrut', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('애버펠디', 'Aberfeldy', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('야마자키', 'Yamazaki', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에드라두르', 'Edradour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에드라두어', 'Edradour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에이가시마 슈조', 'Eigashima Shuzo', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('오반', 'Oban', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('오켄토션', 'Auchentoshan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('와렝햄', 'Warenghem', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('와일드 터키', 'Wild Turkey Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('요이치', 'Yoichi', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('우드포드 리저브', 'Woodford Reserve', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('울트모어', 'Aultmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('울프번', 'Wolfburn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('월렛', 'Willett Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('웨스트랜드', 'Westland Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('위도우 제인', 'Widow Jane Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('잭다니엘', 'Jack Daniel''s', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('존', 'John Distilleries', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('주라', 'Isle of Jura', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쥐담', 'Zuidam Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('짐 빔', 'Jim Beam', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('츠누키', 'Tsunuki', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('치치부', 'Chichibu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카듀', 'Cardhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카발란', 'Kavalan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카퍼도니히', 'Caperdonich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨레이', 'Cooley', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨리', 'Cooley', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨일라', 'Caol Ila', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('크라겐모어', 'Cragganmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('크라이겔라키', 'Craigellachie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('크라이넬리쉬', 'Clynelish', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('클레이', 'Cley Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('클로나킬티', 'Clonakilty Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('킬호만', 'Kilchoman', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탈라모어 듀', 'Tullamore Dew (2014 - present)', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('탈리스커', 'Talisker', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탐나불린', 'Tamnavulin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탐듀', 'Tamdhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('터틸타운 스피리츠', 'Tuthilltown Spirits', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('테렌펠리', 'Teerenpeli', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토마틴', 'Tomatin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토모어', 'Tormore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토민타울', 'Tomintoul', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토버모리', 'Tobermory', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('툴리바딘', 'Tullibardine', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('티니닉', 'Teaninich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('틸링', 'Teeling Whiskey Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('페터케른', 'Fettercairn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('펜데린', 'Penderyn Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('포 로지스', 'Four Roses Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('풀티니', 'Pulteney', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('하이 코스트', 'High Coast Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('하이랜드 파크', 'Highland Park', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('하쿠슈', 'Hakushu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('헤븐힐', 'Heaven Hill Distilleries, Inc.', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('후지 코텐바', 'Fuji Gotemba', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('휘슬피그', 'WhistlePig', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('휘슬피거', 'WhistlePager', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('ETC', 'ETC', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'); - -insert into alcohols (kor_name, eng_name, abv, type, kor_category, eng_category, category_group, region_id, - distillery_id, age, cask, image_url, create_at, create_by, last_modify_at, last_modify_by) -values ('라이터스 티얼즈 레드 헤드', 'Writers'' Tears Red Head', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 12, 150, - null, 'Oloroso Sherry Butts', 'https://static.whiskybase.com/storage/whiskies/1/8/3881/318643-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('라이터스 티얼즈 더블 오크', 'Writers'' Tears Double Oak', '46', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, null, - 'American & French Oak', 'https://static.whiskybase.com/storage/whiskies/1/3/1308/282645-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('라이터스 티얼즈 코퍼 팟', 'Writers'' Tears Copper Pot', '40', 'WHISKY', '블렌디드 몰트', 'Blended Malt', 'BLENDED_MALT', 12, - 150, null, 'Bourbon Barrels', 'https://static.whiskybase.com/storage/whiskies/7/7/471/189958-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('VAT 69 블렌디드 스카치 위스키', 'VAT 69 Blended Scotch Whisky', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 16, 150, null, - null, 'https://static.whiskybase.com/storage/whiskies/8/1/189/246095-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('툴리바딘 소버린', 'Tullibardine Sovereign', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 137, null, - '1st fill bourbon barrel', 'https://static.whiskybase.com/storage/whiskies/1/7/4450/390659-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 XO', 'Tullamore Dew XO', '43', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 127, null, - 'Caribbean Rum Cask Finish', 'https://static.whiskybase.com/storage/whiskies/1/0/9073/192995-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 사이다 캐스크 피니시', 'Tullamore Dew Cider Cask Finish', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, null, - 'Cider Cask Finish', 'https://static.whiskybase.com/storage/whiskies/6/9/408/192993-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 14년', 'Tullamore Dew 14y', '41.3', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 12, 47, '14', - 'Bourbon, Oloroso Sherry, Port, Madeira Finish', - 'https://static.whiskybase.com/storage/whiskies/7/8/743/201942-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 12년', 'Tullamore Dew 12y', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, '12', - 'Ex-Bourbon & Oloroso Sherry Casks', 'https://static.whiskybase.com/storage/whiskies/8/1/442/229572-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토모어 16년', 'Tormore 16y', '48', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, '16', - 'American Oak Cask', 'https://static.whiskybase.com/storage/whiskies/2/0/0044/349731-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토모어 14년', 'Tormore 14y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, '14', 'American Oak', - 'https://static.whiskybase.com/storage/whiskies/4/6/006/87125-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 피티탱', 'Tomintoul With A Peaty Tang', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, null, - null, 'https://static.whiskybase.com/storage/whiskies/1/2/2408/373021-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 시가몰트', 'Tomintoul Cigar Malt', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, null, - 'Oloroso', 'https://static.whiskybase.com/storage/whiskies/1/6/7902/372433-big.jpg', '2024-06-08 05:06:00', - 'admin', '2024-06-08 05:06:00', 'admin'), - ('토민타울 16년', 'Tomintoul 16y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '16', null, - 'https://static.whiskybase.com/storage/whiskies/6/7/387/106575-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 14년', 'Tomintoul 14y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '14', - 'American oak ex-bourbon', 'https://static.whiskybase.com/storage/whiskies/2/0/0756/133825-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토민타울 10년', 'Tomintoul 10y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '10', - 'American oak ex-bourbon barrel', 'https://static.whiskybase.com/storage/whiskies/1/2/2573/205398-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('아벨라워 18년', 'Aberlour 18y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 89, '18', - 'Am. & Eur. Oak + 1st-Fill PX & Oloroso Finish', - 'https://static.whiskybase.com/storage/whiskies/2/3/7131/424101-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('포트 샬롯 10년', 'Port Charlotte 10y', '50', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 15, 69, '10', null, - 'https://static.whiskybase.com/storage/whiskies/1/1/2320/201819-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('카듀 엠버 록', 'Cardhu Amber Rock', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 115, null, - 'Bourbon Cask', 'https://static.whiskybase.com/storage/whiskies/5/4/607/343007-big.jpg', '2024-06-08 05:06:00', - 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('아드나머칸 AD', 'Ardnamurchan AD/', '46.8', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 85, null, - 'Bourbon & Sherry', 'https://static.whiskybase.com/storage/whiskies/2/2/5270/415209-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('울프번 10년', 'Wolfburn 10y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 104, '10', - 'Oloroso Sherry Hogsheads', 'https://static.whiskybase.com/storage/whiskies/2/3/0518/412215-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'); - -INSERT INTO follows (id, user_id, follow_user_id, status, create_at, create_by, last_modify_at, last_modify_by) -VALUES (1, 8, 1, 'FOLLOWING', '2024-06-14 08:35:59', 'dev.bottle-note@gmail.com', '2024-06-27 22:13:55', - 'dev.bottle-note@gmail.com'), - (2, 8, 2, 'FOLLOWING', '2024-06-16 19:52:34', 'dev.bottle-note@gmail.com', '2024-06-16 19:52:34', - 'dev.bottle-note@gmail.com'), - (3, 8, 3, 'FOLLOWING', '2024-06-16 19:52:41', 'dev.bottle-note@gmail.com', '2024-06-16 19:52:41', - 'dev.bottle-note@gmail.com'), - (4, 3, 1, 'FOLLOWING', '2024-08-17 13:23:27', 'dev.bottle-note@gmail.com', '2024-08-17 13:23:27', - 'dev.bottle-note@gmail.com'); - -INSERT INTO picks (id, user_id, alcohol_id, status, create_at, last_modify_at) -VALUES (1, 3, 2, 'PICK', '2024-06-11 02:45:38', '2024-06-11 02:45:38'), - (2, 4, 3, 'UNPICK', '2024-06-22 17:32:32', '2024-07-14 17:23:42'), - (3, 8, 1, 'PICK', '2024-06-27 22:14:06', '2024-06-27 22:14:06'), - (4, 4, 4, 'UNPICK', '2024-07-14 17:25:56', '2024-07-14 20:43:18'), - (5, 1, 5, 'UNPICK', '2024-07-18 21:12:45', '2024-07-18 21:12:45'), - (6, 1, 6, 'UNPICK', '2024-07-18 21:12:47', '2024-07-18 21:12:47'), - (7, 1, 7, 'PICK', '2024-07-18 22:01:45', '2024-07-18 22:01:45'), - (8, 1, 8, 'PICK', '2024-07-28 13:46:54', '2024-08-01 21:22:16'); - -INSERT INTO ratings (alcohol_id, user_id, rating, create_at, create_by, last_modify_at, last_modify_by) -VALUES (1, 3, 3.5, '2024-07-07 19:46:40', NULL, '2024-07-07 19:46:40', NULL), - (1, 6, 3.5, '2024-08-11 14:12:26', 'rlagusrl928@gmail.com', '2024-08-11 14:12:26', 'rlagusrl928@gmail.com'), - (2, 3, 3.5, '2024-06-11 02:39:27', NULL, '2024-06-11 02:39:27', NULL), - (2, 8, 3.5, '2024-06-25 21:14:32', NULL, '2024-06-25 21:14:32', NULL), - (2, 6, 4, '2024-07-11 21:44:39', NULL, '2024-07-11 21:58:02', NULL), - (3, 1, 4.5, '2024-08-01 22:43:31', 'hyejj19@naver.com', '2024-08-01 22:51:49', 'hyejj19@naver.com'), - (4, 1, 4.5, '2024-08-01 21:57:42', 'hyejj19@naver.com', '2024-08-01 21:57:42', 'hyejj19@naver.com'), - (5, 1, 4, '2024-08-01 21:57:48', 'hyejj19@naver.com', '2024-08-01 21:57:48', 'hyejj19@naver.com'), - (6, 4, 5, '2024-08-13 20:05:35', 'cdm2883@gmail.com', '2024-08-13 20:05:35', 'cdm2883@gmail.com'), - (7, 1, 4.5, '2024-08-01 21:26:20', 'hyejj19@naver.com', '2024-08-01 21:26:20', 'hyejj19@naver.com'), - (1, 4, 0.5, '2024-08-11 14:10:28', 'rkdtkfma@naver.com', '2024-08-11 14:10:28', 'rkdtkfma@naver.com'); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-user-mypage-query.sql b/bottlenote-product-api/src/test/resources/init-script/init-user-mypage-query.sql deleted file mode 100644 index 260ca2919..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-user-mypage-query.sql +++ /dev/null @@ -1,354 +0,0 @@ -insert into users (email, nick_name, age, image_url, gender, role, status, social_type, - last_login_at, refresh_token, - create_at, last_modify_at) -values ('hyejj19@naver.com', 'WOzU6J8541', null, null, 'FEMALE', 'ROLE_USER', 'ACTIVE', '["GOOGLE"]', - null, 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJoeWVqajE5QG5hdmVyLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjoxLCJpYXQiOjE3MTkwNDA2ODAsImV4cCI6MTcyMDI1MDI4MH0._s1r4Je9wFTvu_hV0sYBVRr5uDqiHXVBM22jS35YNbH0z-svrTYjysORA4J2J5GQcel9K5FxRBQnWjAeqQNfdw', - null, null), - ('chadongmin@naver.com', 'xIFo6J8726', null, null, 'MALE', 'ROLE_USER', 'ACTIVE', - '["KAKAO"]', - null, 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjaGFkb25nbWluQG5hdmVyLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjoyLCJpYXQiOjE3MjA3MDE2NjcsImV4cCI6MTcyMTkxMTI2N30.dNGvbasf2gccc4TImO2pGg7wIZi_VPNupGYmIRYqutRKrXq79ep0J-sE1OMpk7GC4y3nFkiqvwSWznHggrmRFA', - null, null), - ('dev.bottle-note@gmail.com', 'PARC6J8814', 25, null, 'MALE', 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - null, 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJkZXYuYm90dGxlLW5vdGVAZ21haWwuY29tIiwicm9sZXMiOiJST0xFX1VTRVIiLCJ1c2VySWQiOjMsImlhdCI6MTcyMDM1MDY4OCwiZXhwIjoxNzIxNTYwMjg4fQ.two8yLXv2xFCEOhuGrLYV8cmewm8bD8EbIWTXYa896MprhgclsGNDThspjRF9VJmSA3mxvoPjnBJ0ClneCClBQ', - null, null), - ('eva.park@oysterable.com', 'VOKs6J8831', null, null, null, 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - null, 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldmEucGFya0BveXN0ZXJhYmxlLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjo1LCJpYXQiOjE3MTc4MzU1MDUsImV4cCI6MTcxOTA0NTEwNX0.j6u6u8a8lhedeegOe2wqOjNZkMx0X3RgVeAcvnlCZmj_AXQF5WDo4k71WI-bFt_ypW-ewVCRmdLQoOduaggCRw', - null, null), - ('rlagusrl928@gmail.com', 'hpPw6J111837', null, null, 'MALE', 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - null, 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJybGFndXNybDkyOEBnbWFpbC5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6NiwiaWF0IjoxNzE4MDk4NjQxLCJleHAiOjE3MTkzMDgyNDF9.0nfUYMm4UEFzfE52ydulDZ0eX5U_2yBN4hCeBXr4PeA3xwbzDo7t2c2kJGNU_LXMbZg2Iz4DAIZnu0QB3DJ8VA', - null, null), - ('ytest@gmail.com', 'OMkS6J12123', null, null, 'MALE', 'ROLE_USER', 'ACTIVE', '["GOOGLE"]', - null, 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ5dGVzdEBnbWFpbC5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6NywiaWF0IjoxNzE4MTIzMDMxLCJleHAiOjE3MTkzMzI2MzF9.8KNJW6havezUSgaWmRyAvlxfwdRZxjdC7mcBuexN0Gy9NtgJIAVWqNMW0wlJXw7d9LVwtZf5Mv4aUdA_V-V8pw', - null, null), - ('juye@gmail.com', 'juye12', null, - '{ "viewUrl": "http://example.com/new-profile-image.jpg" }', 'FEMALE', 'ROLE_USER', 'ACTIVE', - '["GOOGLE"]', - NOW(), 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJqdXllQGdtYWlsLmNvbSIsInJvbGVzIjoiUk9MRV9VU0VSIiwidXNlcklkIjo4LCJpYXQiOjE3MjA2OTg0MDcsImV4cCI6MTcyMTkwODAwN30.VaTNqvK-e8g9zsaOhoEmrKQKATCKtKqSIvuOCKpB0lS5L2I7Xd-iVhkwuVHfySalK0y_t_hf9CsIfeIjWgO0gw', - null, null), - ('rkdtkfma@naver.com', 'iZBq6J22547', null, null, 'MALE', 'ROLE_USER', 'ACTIVE', '["KAKAO"]', - null, 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJya2R0a2ZtYUBuYXZlci5jb20iLCJyb2xlcyI6IlJPTEVfVVNFUiIsInVzZXJJZCI6OSwiaWF0IjoxNzIwNzAzMjExLCJleHAiOjE3MjE5MTI4MTF9.pD-MCIPRxbYBLJ2ZPei_529YCop_a8yVKPCsz-YYlvCjyAM40aVQRPv2rDg2wfCZAr5c3NKyS210LwQXwxf1OQ', - null, null); - --- 지역 -insert into regions (kor_name, eng_name, continent, description, create_at, create_by, last_modify_at, last_modify_by) -values ('호주', 'Australia', null, '오세아니아에 위치한 나라로 다양한 위스키를 생산.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('핀란드', 'Finland', null, '북유럽에 위치한 나라로 청정한 자연환경을 자랑.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('프랑스', 'France', null, '와인과 브랜디로 유명한 유럽의 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('타이완', 'Taiwan', null, '고품질의 위스키로 유명한 동아시아의 섬나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('캐나다', 'Canada', null, '위스키 생산이 활발한 북미의 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('체코', 'Czech Republic', null, '중앙유럽에 위치한 나라로 맥주로도 유명.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('일본', 'Japan', null, '전통과 현대가 조화된 동아시아의 섬나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('인도', 'India', null, '다양한 문화와 역사를 지닌 남아시아의 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('이스라엘', 'Israel', null, '중동에 위치한 나라로 와인과 위스키 생산이 증가.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('웨일즈', 'Wales', null, '영국의 구성국 중 하나로 독자적인 위스키 생산.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('영국', 'United Kingdom', null, '다양한 위스키 지역을 가진 나라.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('아일랜드', 'Ireland', null, '풍부한 전통을 지닌 위스키의 본고장 중 하나입니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('아이슬란드', 'Iceland', null, '청정한 자연환경과 독특한 술 문화를 가진 섬나라입니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/캠벨타운', 'Scotland/Campbeltown', null, '스코틀랜드의 위스키 지역 중 하나로 해안가에 위치한 반도 형태의 지역입니다.', '2024-06-04 17:19:39', - 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/아일라', 'Scotland/Islay', null, '피트위스키의 대표 생산지입니다. 스모키한맛이 특징인 위스키로 유명한 섬입니다.', '2024-06-04 17:19:39', - 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/스페이사이드 ', 'Scotland/Speyside', null, '스코틀랜드의 중심부에 위치하고있습니다. 더프타운에 다양한 증류소들이 있습니다.', - '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/로우랜드', 'Scotland/Lowlands', null, '부드럽고 가벼운 맛이 특징인 위스키로 유명합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드/기타섬', 'Scotland/Islands', null, '다양한 섬에서 독특한 맛의 위스키를 생산하고 있습니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코틀랜드', 'Scotland/', null, '위스키의 본고장으로 다양한 스타일의 위스키를 생산합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스코트랜드/하이랜드', 'Scotland/Highlands', null, '스코틀랜드 북부 지역으로 섬세한 맛의 위스키로 유명.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('스위스', 'Switzerland', null, '알프스 산맥을 배경으로 한 유럽의 나라입니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('스웨덴', 'Sweden', null, '북유럽에 위치한 나라로 맥주와 위스키 생산 증가하고 있습니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('미국', 'United States', null, '다양한 스타일의 위스키를 생산하는 나라. 주로 켄터키의 버번위스키가 유명합니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'), - ('독일', 'Germany', null, '맥주로 유명한 나라로 위스키 생산도 활발합니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('덴마크', 'Denmark', null, '북유럽에 위치한 나라로 고유의 위스키를 생산합니다.', '2024-06-04 17:19:39', 'admin', '2024-06-04 17:19:39', - 'admin'), - ('네덜란드', 'Netherlands', null, '맥주와 진으로 유명한 나라로 위스키 생산도 증가하고 있습니다.', '2024-06-04 17:19:39', 'admin', - '2024-06-04 17:19:39', 'admin'); - --- 증류소 -insert into distilleries (kor_name, eng_name, logo_img_url, create_at, create_by, last_modify_at, last_modify_by) -values ('글래스고', 'The Glasgow Distillery Co.', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 그란트', 'Glen Grant', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 기어리', 'Glen Garioch', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 모레이', 'Glen Moray', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 스코샤', 'Glen Scotia', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 스페이', 'Glen Spey', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 엘스', 'Glen Els', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌 엘진', 'Glen Elgin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌가일', 'Glengyle', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌고인', 'Glengoyne', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌글라쏘', 'Glenglassaugh', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌달로', 'Glendalough Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌드로낙', 'Glendronach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌로시', 'Glenlossie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌로티스', 'Glenrothes', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌리벳', 'Glenlivet', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌모렌지', 'Glenmorangie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌알라키', 'Glenallachie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌카담', 'Glencadam', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌킨치', 'Glenkinchie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌터렛', 'Glenturret', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌파클라스', 'Glenfarclas', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글렌피딕', 'Glenfiddich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('글리나', 'Glina Destillerie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('녹듀', 'Knockdhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('뉴 리브 티스틸링', 'New Riff Distilling', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('뉴 리프 디스틸링', 'New Riff Distilling', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달루인', 'Dailuaine', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달모어', 'Dalmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('달위니', 'Dalwhinnie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('더프타운', 'Dufftown', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('딘스톤', 'Deanston', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('딩글', 'The Dingle Whiskey Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('라가불린', 'Lagavulin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('라세이', 'Raasay Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('라프로익', 'Laphroaig', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('로얄 로크나가', 'Royal Lochnagar', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('로얄 브라클라', 'Royal Brackla', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('로크몬드', 'Loch Lomond', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('루겐브로이', 'Rugenbräu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('링크우드', 'Linkwood', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('마르스 신슈', 'The Mars Shinshu Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥더프', 'Macduff', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥미라', 'Mackmyra', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('맥캘란', 'Macallan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('메이커스마크', 'Maker''s Mark Distillery, Inc.', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('미들턴', 'Midleton (1975-)', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('미야기쿄', 'Miyagikyo', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('믹터스', 'Michter''s Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('밀크 앤 허니', 'Milk & Honey Whisky Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('밀톤더프', 'Miltonduff', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('바렐 크래프트 스피릿', 'Barrell Craft Spirits', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발베니', 'Balvenie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발블레어', 'Balblair', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('발코네스', 'Balcones Distilling', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('버팔로 트레이스', 'Buffalo Trace Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('베르그슬라겐스', 'Bergslagens Destilleri', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤 네비스', 'Ben Nevis', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤로막', 'Benromach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤리네스', 'Benrinnes', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('벤리악', 'BenRiach', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('보모어', 'Bowmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('부나하벤', 'Bunnahabhain', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('부시밀', 'Bushmills', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브라우레이 로커', 'Brauerei Locher', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브라운 포 맨', 'Brown-Forman Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브라운슈타인', 'Braunstein', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('브룩라디', 'Bruichladdich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('블라드녹', 'Bladnoch', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('블레어 아톨', 'Blair Athol', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('설리반 코브', 'Sullivans Cove', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('세인트 킬리안', 'St. Kilian Distillers', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스뫼겐', 'Smögen', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스무스 앰블러 스피릿', 'Smooth Ambler Spirits', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스카파', 'Scapa', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스타워드', 'Starward', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스터닝', 'Stauning Whisky', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스트라스밀', 'Strathmill', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스트라티슬라', 'Strathisla', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스페이번', 'Speyburn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스페이사이드', 'Speyside Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('스프링뱅크', 'Springbank', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('실리스', 'Slyrs', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드나머칸', 'Ardnamurchan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드모어', 'Ardmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아드벡', 'Ardbeg', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아란', 'Arran', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아벨라워', 'Aberlour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('아사카', 'Asaka', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('암룻', 'Amrut', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('애버펠디', 'Aberfeldy', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('야마자키', 'Yamazaki', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에드라두르', 'Edradour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에드라두어', 'Edradour', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('에이가시마 슈조', 'Eigashima Shuzo', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('오반', 'Oban', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('오켄토션', 'Auchentoshan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('와렝햄', 'Warenghem', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('와일드 터키', 'Wild Turkey Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('요이치', 'Yoichi', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('우드포드 리저브', 'Woodford Reserve', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('울트모어', 'Aultmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('울프번', 'Wolfburn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('월렛', 'Willett Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('웨스트랜드', 'Westland Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('위도우 제인', 'Widow Jane Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('잭다니엘', 'Jack Daniel''s', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('존', 'John Distilleries', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('주라', 'Isle of Jura', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쥐담', 'Zuidam Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('짐 빔', 'Jim Beam', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('츠누키', 'Tsunuki', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('치치부', 'Chichibu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카듀', 'Cardhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카발란', 'Kavalan', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('카퍼도니히', 'Caperdonich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨레이', 'Cooley', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨리', 'Cooley', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('쿨일라', 'Caol Ila', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('크라겐모어', 'Cragganmore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('크라이겔라키', 'Craigellachie', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('크라이넬리쉬', 'Clynelish', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('클레이', 'Cley Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('클로나킬티', 'Clonakilty Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('킬호만', 'Kilchoman', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탈라모어 듀', 'Tullamore Dew (2014 - present)', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', - 'admin'), - ('탈리스커', 'Talisker', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탐나불린', 'Tamnavulin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('탐듀', 'Tamdhu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('터틸타운 스피리츠', 'Tuthilltown Spirits', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('테렌펠리', 'Teerenpeli', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토마틴', 'Tomatin', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토모어', 'Tormore', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토민타울', 'Tomintoul', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('토버모리', 'Tobermory', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('툴리바딘', 'Tullibardine', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('티니닉', 'Teaninich', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('틸링', 'Teeling Whiskey Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('페터케른', 'Fettercairn', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('펜데린', 'Penderyn Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('포 로지스', 'Four Roses Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('풀티니', 'Pulteney', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('하이 코스트', 'High Coast Distillery', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('하이랜드 파크', 'Highland Park', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('하쿠슈', 'Hakushu', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('헤븐힐', 'Heaven Hill Distilleries, Inc.', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('후지 코텐바', 'Fuji Gotemba', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('휘슬피그', 'WhistlePig', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('휘슬피거', 'WhistlePager', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'), - ('ETC', 'ETC', null, '2024-06-04 17:09:03', 'admin', '2024-06-04 17:09:03', 'admin'); - --- 알콜 -insert into alcohols (kor_name, eng_name, abv, type, kor_category, eng_category, category_group, region_id, - distillery_id, age, cask, image_url, create_at, create_by, last_modify_at, last_modify_by) -values ('라이터스 티얼즈 레드 헤드', 'Writers'' Tears Red Head', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 12, 150, - null, 'Oloroso Sherry Butts', 'https://static.whiskybase.com/storage/whiskies/1/8/3881/318643-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('라이터스 티얼즈 더블 오크', 'Writers'' Tears Double Oak', '46', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, null, - 'American & French Oak', 'https://static.whiskybase.com/storage/whiskies/1/3/1308/282645-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('라이터스 티얼즈 코퍼 팟', 'Writers'' Tears Copper Pot', '40', 'WHISKY', '블렌디드 몰트', 'Blended Malt', 'BLENDED_MALT', 12, - 150, null, 'Bourbon Barrels', 'https://static.whiskybase.com/storage/whiskies/7/7/471/189958-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('VAT 69 블렌디드 스카치 위스키', 'VAT 69 Blended Scotch Whisky', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 16, 150, null, - null, 'https://static.whiskybase.com/storage/whiskies/8/1/189/246095-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('툴리바딘 소버린', 'Tullibardine Sovereign', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 137, null, - '1st fill bourbon barrel', 'https://static.whiskybase.com/storage/whiskies/1/7/4450/390659-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 XO', 'Tullamore Dew XO', '43', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 127, null, - 'Caribbean Rum Cask Finish', 'https://static.whiskybase.com/storage/whiskies/1/0/9073/192995-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 사이다 캐스크 피니시', 'Tullamore Dew Cider Cask Finish', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, null, - 'Cider Cask Finish', 'https://static.whiskybase.com/storage/whiskies/6/9/408/192993-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 14년', 'Tullamore Dew 14y', '41.3', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 12, 47, '14', - 'Bourbon, Oloroso Sherry, Port, Madeira Finish', - 'https://static.whiskybase.com/storage/whiskies/7/8/743/201942-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 12년', 'Tullamore Dew 12y', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', 12, 47, '12', - 'Ex-Bourbon & Oloroso Sherry Casks', 'https://static.whiskybase.com/storage/whiskies/8/1/442/229572-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토모어 16년', 'Tormore 16y', '48', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, '16', - 'American Oak Cask', 'https://static.whiskybase.com/storage/whiskies/2/0/0044/349731-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토모어 14년', 'Tormore 14y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 134, '14', 'American Oak', - 'https://static.whiskybase.com/storage/whiskies/4/6/006/87125-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 피티탱', 'Tomintoul With A Peaty Tang', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, null, - null, 'https://static.whiskybase.com/storage/whiskies/1/2/2408/373021-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 시가몰트', 'Tomintoul Cigar Malt', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, null, - 'Oloroso', 'https://static.whiskybase.com/storage/whiskies/1/6/7902/372433-big.jpg', '2024-06-08 05:06:00', - 'admin', '2024-06-08 05:06:00', 'admin'), - ('토민타울 16년', 'Tomintoul 16y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '16', null, - 'https://static.whiskybase.com/storage/whiskies/6/7/387/106575-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('토민타울 14년', 'Tomintoul 14y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '14', - 'American oak ex-bourbon', 'https://static.whiskybase.com/storage/whiskies/2/0/0756/133825-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('토민타울 10년', 'Tomintoul 10y', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 135, '10', - 'American oak ex-bourbon barrel', 'https://static.whiskybase.com/storage/whiskies/1/2/2573/205398-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('아벨라워 18년', 'Aberlour 18y', '43', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 89, '18', - 'Am. & Eur. Oak + 1st-Fill PX & Oloroso Finish', - 'https://static.whiskybase.com/storage/whiskies/2/3/7131/424101-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('포트 샬롯 10년', 'Port Charlotte 10y', '50', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 15, 69, '10', null, - 'https://static.whiskybase.com/storage/whiskies/1/1/2320/201819-big.jpg', '2024-06-08 05:06:00', 'admin', - '2024-06-08 05:06:00', 'admin'), - ('카듀 엠버 록', 'Cardhu Amber Rock', '40', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 16, 115, null, - 'Bourbon Cask', 'https://static.whiskybase.com/storage/whiskies/5/4/607/343007-big.jpg', '2024-06-08 05:06:00', - 'admin', '2024-06-08 05:06:00', 'admin'), - ('탈라모어 듀 더 레전더리 아이리시 위스키', 'Tullamore Dew The Legendary Irish Whiskey', '40', 'WHISKY', '블렌디드', 'Blend', 'BLEND', - 12, 47, null, null, 'https://static.whiskybase.com/storage/whiskies/4/0/935/122228-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('아드나머칸 AD', 'Ardnamurchan AD/', '46.8', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 85, null, - 'Bourbon & Sherry', 'https://static.whiskybase.com/storage/whiskies/2/2/5270/415209-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'), - ('울프번 10년', 'Wolfburn 10y', '46', 'WHISKY', '싱글 몰트', 'Single Malt', 'SINGLE_MALT', 20, 104, '10', - 'Oloroso Sherry Hogsheads', 'https://static.whiskybase.com/storage/whiskies/2/3/0518/412215-big.jpg', - '2024-06-08 05:06:00', 'admin', '2024-06-08 05:06:00', 'admin'); - --- review 테이블에 데이터 삽입 -INSERT INTO reviews -(id, user_id, alcohol_id, is_best, content, size_type, price, location_name, zip_code, address, - detail_address, category, map_url, latitude, longitude, status, - image_url, view_count, active_status, create_at, create_by, last_modify_at, last_modify_by) -VALUES (1, 3, 1, true, '이 위스키는 풍부하고 복잡한 맛이 매력적입니다.', 'BOTTLE', 65000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map1', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image01.jpg', NULL, 'ACTIVE', '2024-05-05 12:00:00', NULL, - NULL, NULL), - (2, 4, 1, false, '가벼우면서도 깊은 맛이 느껴지는 위스키입니다.', 'GLASS', 45000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map2', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image02.jpg', NULL, 'ACTIVE', '2024-05-02 13:00:00', NULL, - NULL, NULL), - (3, 1, 1, false, '향기로운 바닐라 향이 나는 부드러운 위스키입니다.', 'BOTTLE', 77000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map3', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image03.jpg', NULL, 'ACTIVE', '2024-05-16 14:30:00', NULL, - NULL, NULL), - (4, 2, 2, false, '스모키하고 강한 페트 향이 인상적인 위스키입니다.', 'BOTTLE', 120000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map4', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image04.jpg', NULL, 'ACTIVE', '2024-05-01 15:45:00', NULL, - NULL, NULL), - (5, 5, 2, false, '달콤한 캐러멜과 과일 향이 조화를 이루는 맛있습니다.', 'GLASS', 99000, 'xxPub', '06000', - '서울시 강남구 청담동', 'xxPub 청담점', 'bar', 'https://maps.example.com/map5', '12.123', '12.123', - 'PUBLIC', 'https://example.com/image05.jpg', NULL, 'ACTIVE', '2024-05-08 16:00:00', NULL, - NULL, NULL); - - -insert into follows (user_id, follow_user_id, status, create_at, last_modify_at) -values (1, 2, 'FOLLOWING', now(), now()), - (2, 3, 'FOLLOWING', now(), now()), - (3, 1, 'FOLLOWING', now(), now()); - - -insert into ratings (user_id, alcohol_id, rating, create_at, last_modify_at) -values (1, 1, 5, now(), now()), - (2, 1, 4, now(), now()), - (3, 2, 5, now(), now()); diff --git a/bottlenote-product-api/src/test/resources/init-script/init-user.sql b/bottlenote-product-api/src/test/resources/init-script/init-user.sql deleted file mode 100644 index 222308f42..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/init-user.sql +++ /dev/null @@ -1,41 +0,0 @@ -insert into users (email, nick_name, age, image_url, gender, role, status, social_type, - last_login_at, create_at, last_modify_at) -values ('hyejj19@naver.com', 'WOzU6J8541', null, null, 'FEMALE', 'ROLE_USER', 'ACTIVE', '[ - "KAKAO" -]', - null, null, null), - ('chadongmin@naver.com', 'xIFo6J8726', null, null, 'MALE', 'ROLE_USER', 'ACTIVE', - '[ - "KAKAO" - ]', - null, null, null), - ('dev.bottle-note@gmail.com', 'PARC6J8814', 25, null, 'MALE', 'ROLE_USER', 'ACTIVE', - '[ - "GOOGLE" - ]', - null, null, null), - ('eva.park@oysterable.com', 'VOKs6J8831', null, null, 'FEMALE', 'ROLE_USER', 'ACTIVE', - '[ - "GOOGLE" - ]', - null, null, null), - ('rlagusrl928@gmail.com', 'hpPw6J111837', null, null, 'MALE', 'ROLE_USER', 'ACTIVE', - '[ - "KAKAO" - ]', - null, null, null), - ('ytest@gmail.com', 'OMkS6J12123', null, null, 'MALE', 'ROLE_USER', 'ACTIVE', '[ - "KAKAO" - ]', - null, null, null), - ('juye@gmail.com', 'juye12', null, - '{ "viewUrl": "http://example.com/new-profile-image.jpg" }', 'MALE', 'ROLE_USER', 'ACTIVE', - '[ - "GOOGLE" - ]', - NOW(), null, null), - ('rkdtkfma@naver.com', 'iZBq6J22547', null, null, null, 'ROLE_USER', 'ACTIVE', '[ - "KAKAO" - ]', - null, null, null) -ON DUPLICATE KEY UPDATE email = email; diff --git a/bottlenote-product-api/src/test/resources/init-script/schema.sql.bak b/bottlenote-product-api/src/test/resources/init-script/schema.sql.bak deleted file mode 100644 index d865f24d9..000000000 --- a/bottlenote-product-api/src/test/resources/init-script/schema.sql.bak +++ /dev/null @@ -1,437 +0,0 @@ -CREATE TABLE `region` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '국가', - `kor_name` varchar(255) NOT NULL COMMENT '국가 한글명', - `eng_name` varchar(255) NOT NULL COMMENT '국가 영문명', - `continent` varchar(255) NULL COMMENT '대륙', - `description` varchar(255) NULL COMMENT '주석', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`) -) COMMENT = '국가'; - -CREATE TABLE `distillery` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '증류소', - `kor_name` varchar(255) NOT NULL COMMENT '증류소 한글 이름', - `eng_name` varchar(255) NOT NULL COMMENT '증류소 영문 이름', - `logo_img_url` varchar(255) NULL COMMENT '로고 이미지', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`) -) COMMENT = '증류소'; - -CREATE TABLE `alcohol` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '술', - `kor_name` varchar(255) NOT NULL COMMENT '한글 이름', - `eng_name` varchar(255) NOT NULL COMMENT '영문 이름', - `abv` varchar(255) NULL COMMENT '도수', - `type` varchar(255) NOT NULL COMMENT '위스키 고정 ( 추후 럼,진등으로 확장 가능)', - `kor_category` varchar(255) NOT NULL COMMENT '위스키, 럼, 브랜디의 하위상세 카테고리 한글명', - `eng_category` varchar(255) NOT NULL COMMENT '위스키, 럼, 브랜디의 하위상세 카테고리 영문명 ', - `category_group` varchar(255) NOT NULL COMMENT '하위 카테고리 그룹', - `region_id` bigint NULL COMMENT 'https://www.data.go.kr/data/15076566/fileData.do?recommendDataYn=Y', - `distillery_id` bigint NULL COMMENT '증류소 정보', - `age` varchar(255) NULL COMMENT '숙성년도', - `cask` varchar(255) NULL COMMENT '캐스트 타입(단순 문자열로 박기) - 한글 정제화가 힘들 수 있음. 영문사용 권장', - `image_url` varchar(255) NULL COMMENT '썸네일 이미지', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`region_id`) REFERENCES `region` (`id`), - FOREIGN KEY (`distillery_id`) REFERENCES `distillery` (`id`) -) COMMENT = '술'; - -CREATE TABLE `users` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '사용자', - `email` varchar(255) NOT NULL COMMENT '사용자 소셜 이메일', - `password` varchar(255) NULL COMMENT '사용자 비밀번호', - `nick_name` varchar(255) NOT NULL COMMENT '사용자 소셜 닉네임 ( 수정 가능 )', - `age` Integer NULL COMMENT '사용자 나이', - `image_url` varchar(255) NULL COMMENT '사용자 프로필 이미지', - `gender` varchar(255) NULL COMMENT '사용자 성별', - `role` varchar(255) NOT NULL COMMENT '사용자 역할' DEFAULT 'GUEST', - `status` varchar(255) NOT NULL COMMENT '사용자 상태', - `social_type` json NOT NULL COMMENT '소셜 타입 ( NAVER ,GOOGLE , APPLE )', - `refresh_token` varchar(255) NULL COMMENT 'access token 재발급을 위한 토큰', - `create_at` timestamp NULL COMMENT '최초 생성일', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - PRIMARY KEY (`id`), - UNIQUE KEY `email` (`email`), - UNIQUE KEY `nick_name` (`nick_name`) -) COMMENT = '사용자'; - -CREATE TABLE `picks` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '찜하기', - `user_id` bigint NOT NULL COMMENT '찜한 사용자', - `alcohol_id` bigint NOT NULL COMMENT '찜한 술', - `status` varchar(255) NOT NULL COMMENT '찜 취소 찜 재취소', - `create_at` timestamp NULL COMMENT '최초 생성일', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - PRIMARY KEY (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), - FOREIGN KEY (`alcohol_id`) REFERENCES `alcohol` (`id`) -) COMMENT = '찜하기'; - -create table popular_alcohol -( - id bigint auto_increment comment '기본 키' - primary key, - alcohol_id bigint not null comment '술 ID', - year smallint not null comment '년도', - month tinyint not null comment '월', - day tinyint not null comment '일', - review_score decimal(5, 2) not null comment '리뷰 점수', - rating_score decimal(5, 2) not null comment '평점 점수', - pick_score decimal(5, 2) not null comment '찜하기 점수', - popular_score decimal(5, 2) not null comment '인기도 점수', - created_at timestamp default CURRENT_TIMESTAMP null comment '생성일시', - constraint uniq_alcohol_year_month - unique (alcohol_id, year, month, day) -) - comment '술 인기도 통계 테이블' charset = utf8mb4; - -create table popularity_table -( - alcohol_id int not null - primary key, - review_score float null, - rating_score float null, - pick_score float null, - popularity_score float null -); - -CREATE TABLE `user_report` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '유저 신고', - `user_id` bigint NOT NULL COMMENT '신고자', - `report_user_id` bigint NOT NULL COMMENT '신고 대상자', - `type` varchar(255) NOT NULL COMMENT '악성유저 ,스팸등 신고의 타입', - `report_content` varchar(255) NOT NULL COMMENT '어던 문제로 신고했는지.', - `status` varchar(255) NOT NULL DEFAULT 'WAITING' COMMENT '진행상태', - `admin_id` bigint NULL COMMENT '처리 어드민', - `response_content` varchar(255) NULL COMMENT '처리 결과', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), - FOREIGN KEY (`report_user_id`) REFERENCES `users` (`id`) - -- 복합 유니크 UNIQUE KEY `user_id_report_user` (`user_id`, `report_user`) -) COMMENT = '유저 신고'; - -CREATE TABLE `rating` -( - `alcohol_id` bigint NOT NULL COMMENT '평가 대상 술', - `user_id` bigint NOT NULL COMMENT '평가자(사용자)', - `rating` DOUBLE NOT NULL DEFAULT 0 COMMENT '0점 : 삭제, 0.5:최저점수, 5:최고점수', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`alcohol_id`, `user_id`), - foreign key (`alcohol_id`) references `alcohol` (`id`), - foreign key (`user_id`) references `users` (`id`) -) COMMENT = '술 평점'; - -CREATE TABLE `help` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '문의', - `user_id` bigint NOT NULL COMMENT '문의자', - `type` varchar(255) NOT NULL COMMENT 'ADD , USER... 개발때 enum 추가', - `help_content` text NOT NULL COMMENT '문의내용 최대 1000글자', - `status` varchar(255) NOT NULL DEFAULT 'WAITING' COMMENT '진행상태', - `admin_id` bigint NULL COMMENT '처리 어드민', - `response_content` varchar(255) NULL COMMENT 'WAITING : 대기중, SSUCCESS : 처리 완료 , REJECT : 반려', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) -) COMMENT = '문의'; - -CREATE TABLE `help_image` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '리뷰-이미지 등록은 최대 5장', - `help_id` bigint NOT NULL comment '문의글 아이디', - `order` bigint NOT NULL COMMENT '이미지 순서', - `image_url` varchar(255) NOT NULL COMMENT 'S3 이미지 경로', - `image_key` varchar(255) NOT NULL COMMENT '업로드된 루트 경로(버킷부터 이미지 이름까지)', - `image_path` varchar(255) NOT NULL COMMENT '져장된 이미지의 경로(버킷부터 최종폴더까지)', - `image_name` varchar(255) NOT NULL COMMENT '생성된 UUID + 확장자 파일명', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`help_id`) REFERENCES `help` (`id`) -) COMMENT = '문의-이미지 등록은 최대 5장'; - -CREATE TABLE `follow` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '팔로우', - `user_id` bigint NOT NULL COMMENT '팔로우 하는 사람 아이디', - `follow_user_id` bigint NOT NULL COMMENT '팔로우 대상 아이디', - `status` varchar(255) NOT NULL COMMENT '팔로우, 언팔로우', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), - FOREIGN KEY (`follow_user_id`) REFERENCES `users` (`id`) --- 복합 유니크 UNIQUE KEY `user_id_follow_user_id` (`user_id`, `follow_user_id`) -) COMMENT = '팔로우'; - -CREATE TABLE `tasting_tag` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '테이스팅 태그', - `kor_name` varchar(255) NOT NULL COMMENT '한글 태그 이름', - `eng_name` varchar(255) NOT NULL COMMENT '영문 태그 이름', - `icon` varchar(255) NULL COMMENT '앱 출시 후 디벨롭 할 때 사용', - `description` varchar(255) NULL COMMENT '태그 설명', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`) -) COMMENT = '테이스팅 태그'; - -CREATE TABLE `alcohol_tasting_tags` -( - `id` bigint NOT NULL comment '술/테이스팅 태그 연관관계 해소', - `alcohol_id` bigint NOT NULL comment '술 아이디', - `tasting_tag_id` bigint NOT NULL comment '태그 아이디', - `create_at` timestamp NULL comment '최초 생성일', - `last_modify_at` timestamp NULL comment '최종 생성일', - PRIMARY KEY (`id`), - FOREIGN KEY (`alcohol_id`) REFERENCES `alcohol` (`id`), - FOREIGN KEY (`tasting_tag_id`) REFERENCES `tasting_tag` (`id`) -) COMMENT = '술/테이스팅 태그 연관관계 해소'; - -CREATE TABLE `review` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '술 리뷰', - `user_id` bigint NOT NULL COMMENT '리뷰 작성자', - `alcohol_id` bigint NOT NULL COMMENT '리뷰 대상 술', - `is_best` boolean NOT NULL DEFAULT FALSE COMMENT '베스트 리뷰 여부', - `review_rating` double default 0 null comment '0점 : 삭제, 0.5:최저점수, 5:최고점수', - `content` varchar(1000) NOT NULL COMMENT '1000글자', - `size_type` varchar(255) NULL COMMENT '잔 : GLASS , 보틀 : BOTTLE', - `price` decimal(38, 2) NULL COMMENT '가격', - `location_name` varchar(255) NULL COMMENT '상호 명', - `zip_code` varchar(5) NULL COMMENT '우편번호', - `address` varchar(255) NULL COMMENT '주소', - `detail_address` varchar(255) NULL COMMENT '상세주소', - `category` varchar(255) NULL COMMENT '장소 카테고리', - `map_url` varchar(255) NULL COMMENT '지도 URL', - `latitude` varchar(255) NULL COMMENT '위도 (x좌표)', - `longitude` varchar(255) NULL COMMENT '경도 (y좌표)', - `status` varchar(255) NULL COMMENT '리뷰 상태', - `image_url` varchar(255) NULL COMMENT '썸네일 이미지', - `view_count` bigint NULL COMMENT '조회수', - `active_status` varchar(255) NULL COMMENT '리뷰활성상태 (활성, 삭제, 비활성)', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), - FOREIGN KEY (`alcohol_id`) REFERENCES `alcohol` (`id`) -) COMMENT = '술 리뷰'; - -CREATE TABLE `review_report` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '리뷰 신고', - `user_id` bigint NOT NULL COMMENT '신고자', - `review_id` bigint NOT NULL COMMENT '신고 대상 리뷰', - `type` varchar(255) NOT NULL COMMENT '광고 리뷰인지, 욕설 리뷰인지등의 타입', - `report_content` varchar(255) NOT NULL COMMENT '어떤 문제로 신고했는지.', - `status` varchar(255) NOT NULL DEFAULT 'WAITING' COMMENT '진행상태', - `ip_address` varchar(255) NOT NULL COMMENT '신고자 IP', - `admin_id` bigint NULL COMMENT '처리 어드민', - `response_content` varchar(255) NULL COMMENT '처리 결과', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), - FOREIGN KEY (`review_id`) REFERENCES `review` (`id`) -) COMMENT = '리뷰 신고'; - -CREATE TABLE `review_image` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '리뷰-이미지 등록은 최대 5장', - `review_id` bigint NOT NULL comment '리뷰 아이디', - `order` bigint NOT NULL COMMENT '이미지 순서', - `image_url` varchar(255) NOT NULL COMMENT 'S3 이미지 경로', - `image_key` varchar(255) NOT NULL COMMENT '업로드된 루트 경로(버킷부터 이미지 이름까지)', - `image_path` varchar(255) NOT NULL COMMENT '져장된 이미지의 경로(버킷부터 최종폴더까지)', - `image_name` varchar(255) NOT NULL COMMENT '생성된 UUID + 확장자 파일명', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`review_id`) REFERENCES `review` (`id`) -) COMMENT = '리뷰-이미지 등록은 최대 5장'; - -CREATE TABLE `review_tasting_tag` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '리뷰 테이스팅 태그 - 최대 10개', - `review_id` bigint NOT NULL comment '리뷰 아이디', - `tasting_tag` varchar(12) NOT NULL COMMENT '테이스팅 태그 - 최대 12자', - `create_at` timestamp NULL COMMENT '최초 생성일', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - PRIMARY KEY (`id`), - FOREIGN KEY (`review_id`) REFERENCES `review` (`id`) -) COMMENT = '리뷰 테이스팅 태그'; - -CREATE TABLE `review_reply` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '리뷰 댓글', - `review_id` bigint NOT NULL COMMENT '리뷰 아이디', - `user_id` bigint NOT NULL COMMENT '리뷰 작성자', - `status` varchar(255) NULL COMMENT '리뷰 댓글의 현재 상태', - `root_reply_id` bigint NULL comment '최상위 댓글 식별자', - `parent_reply_id` bigint NULL comment '상위 댓글 식별', - `content` text NOT NULL COMMENT '댓글 최대 1000글자', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`review_id`) REFERENCES `review` (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), - foreign key (`root_reply_id`) references `review_reply` (`id`), - FOREIGN KEY (`parent_reply_id`) REFERENCES `review_reply` (`id`) -) COMMENT = '리뷰 댓글'; - -CREATE TABLE `notice` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '공지사항', - `title` varchar(255) NULL COMMENT '공지사항 제목', - `category` varchar(255) NULL COMMENT '공지사항 카테고리', - `content` text NULL COMMENT '공지사항 내용 최대 1000', - `view_count` bigint NULL COMMENT '조회수', - `admin_id` bigint NULL COMMENT '추후 어드민 역할 추가 후', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`) -) COMMENT = '공지사항'; - -CREATE TABLE `likes` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '좋아요', - `review_id` bigint NOT NULL COMMENT '좋아요의 대상 리뷰', - `user_id` bigint NOT NULL COMMENT '좋아요를 누른 사용자 식별자', - `user_nick_name` varchar(255) NOT NULL COMMENT '좋아요를 누른 사용자 닉네임', - `status` varchar(255) NULL COMMENT '공감, 공감취소', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`review_id`) REFERENCES `review` (`id`), - FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) -) COMMENT = '좋아요'; - -CREATE TABLE `alcohol_image` -( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '술 이미지', - `alcohol_id` bigint NOT NULL COMMENT '술 아이디', - `order` bigint NOT NULL COMMENT '이미지 순서', - `image_url` varchar(255) NOT NULL COMMENT 'S3 이미지 경로', - `image_key` varchar(255) NOT NULL COMMENT '업로드된 루트 경로(버킷부터 이미지 이름까지)', - `image_path` varchar(255) NOT NULL COMMENT '져장된 이미지의 경로(버킷부터 최종폴더까지)', - `image_name` varchar(255) NOT NULL COMMENT '생성된 UUID + 확장자 파일명', - `create_at` timestamp NULL COMMENT '최초 생성일', - `create_by` varchar(255) NULL COMMENT '최초 생성자', - `last_modify_at` timestamp NULL COMMENT '최종 생성일', - `last_modify_by` varchar(255) NULL COMMENT '최종 생성자', - PRIMARY KEY (`id`), - FOREIGN KEY (`alcohol_id`) REFERENCES `alcohol` (`id`) -) COMMENT = '술 이미지'; - -create table user_history -( - id bigint not null AUTO_INCREMENT comment '히스토리 id', - user_id bigint not null comment '사용자 id', - event_category varchar(255) not null comment 'pick, review, rating', - event_type varchar(255) null comment 'isPick,unPick || like, create, review, best || start, update, delete', - redirect_url varchar(255) null comment '발생되는 api의 도메인주소를 뺀 url', - image_url varchar(255) null comment '발생되는 api의 도메인주소를 뺀 url', - alcohol_id bigint null comment '알코올 이름(한글)', - content varchar(255) null comment '히스토리 컨텐츠', - dynamic_message json null comment '가변데이터(현재는 별점에서만 사용)', - event_year varchar(255) null comment '발생 년(YYYY)', - event_month varchar(255) null comment '발생 월(MM)', - create_at timestamp null, - create_by varchar(255) null, - last_modify_at timestamp null comment '최종 생성일', - last_modify_by varchar(255) null comment '최종 생성자', - PRIMARY KEY (`id`), - constraint user_history_ibfk_1 - foreign key (user_id) references users (id) -) - comment '유저 히스토리'; - -create index user_id - on user_history (user_id); - -create table notification -( - id bigint auto_increment primary key, - user_id bigint not null comment '사용자 id', - title varchar(255) not null comment '알림 제목', - content text not null comment '알림 내용', - type varchar(255) not null comment '알림 타입 (SYSTEM: 시스템 알림, USER: 사용자 알림, PROMOTION: 프로모션 알림)', - category varchar(255) not null comment '알림의 종류 ( 리뷰, 댓글, 팔로우, 좋아요, 프로모션 )', - status varchar(255) not null comment '알림 상태 (PENDING: 대기 중, SENT: 전송됨, READ: 읽음, FAILED: 실패)', - is_read boolean not null comment '읽음 여부', - create_at timestamp default now() comment '최초 생성일', - create_by varchar(255) null comment '최초 생성자', - last_modify_at timestamp default now() comment '최종 수정일', - last_modify_by varchar(255) null comment '최종 수정자', - constraint notification_users_id_fk - foreign key (user_id) references users (id) -) comment = '사용자 알림'; - -CREATE TABLE user_device_token -( - id BIGINT AUTO_INCREMENT COMMENT '사용자 디바이스 토큰 아이디', - user_id BIGINT COMMENT '사용자 아이디', - device_token VARCHAR(255) COMMENT '디바이스 토큰', - platform VARCHAR(255) DEFAULT 'ANDROID' COMMENT '플랫폼 (ANDROID, IOS)', - create_at TIMESTAMP DEFAULT NOW() COMMENT '생성일시', - last_modify_at TIMESTAMP DEFAULT NOW() COMMENT '수정일시', - FOREIGN KEY (user_id) REFERENCES users (id), - UNIQUE (user_id, device_token), - PRIMARY KEY (id) -) COMMENT = '사용자 디바이스 토큰 테이블'; - - -create table user_push_config -( - user_id bigint not null, - event boolean not null, - promotion boolean not null, - follower boolean not null, - review boolean not null, - night boolean not null, - primary key (user_id) -) comment '사용자 푸시 설정'; From 67da49aab0e023f6508fe8aeb6230c57d8b3f14b Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 14:17:28 +0900 Subject: [PATCH 94/95] =?UTF-8?q?docs:=20MockMvc=20to=20MockMvcTester=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit product-api 통합 테스트의 레거시 MockMvc API를 MockMvcTester로 마이그레이션하기 위한 상세 계획 문서 작성 - 마이그레이션 대상 15개 파일 분석 - 코드 변환 패턴 및 가이드라인 정의 - 주의사항 및 트러블슈팅 가이드 포함 - 권장 마이그레이션 순서 정의 Co-Authored-By: Claude Opus 4.5 --- plan/mockmvc-to-mockmvctester-migration.md | 464 +++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 plan/mockmvc-to-mockmvctester-migration.md diff --git a/plan/mockmvc-to-mockmvctester-migration.md b/plan/mockmvc-to-mockmvctester-migration.md new file mode 100644 index 000000000..b7d457f2c --- /dev/null +++ b/plan/mockmvc-to-mockmvctester-migration.md @@ -0,0 +1,464 @@ +# MockMvc to MockMvcTester 마이그레이션 계획 + +## 1. 개요 + +### 1.1 목표 +product-api 모듈의 통합 테스트에서 레거시 `MockMvc` API를 최신 `MockMvcTester` API로 마이그레이션하여 테스트 코드의 가독성과 유지보수성을 향상시킨다. + +### 1.2 현황 분석 + +| 구분 | MockMvc (레거시) | MockMvcTester (신규) | +|------|-----------------|---------------------| +| 통합 테스트 | 15개 파일 | 2개 파일 | +| RestDocs 테스트 | 27개 파일 | 해당 없음 | + +**마이그레이션 대상**: 통합 테스트 15개 파일 +**제외 대상**: RestDocs 테스트 27개 파일 (스탠드얼론 MockMvc 설정 유지) + +### 1.3 마이그레이션 이점 + +| 항목 | MockMvc (레거시) | MockMvcTester (신규) | +|------|-----------------|---------------------| +| 빌더 패턴 | X | O | +| HTTP 메서드 명시성 | `.perform(get(...))` | `.get().uri(...)` | +| 응답 처리 | 수동 파싱 필요 | AssertJ 통합 | +| 코드 라인 수 | 많음 | 적음 | +| 가독성 | 중간 | 높음 | + +--- + +## 2. 마이그레이션 대상 파일 + +### 2.1 통합 테스트 (15개 파일) + +``` +bottlenote-product-api/src/test/java/app/bottlenote/ +├── alcohols/integration/ +│ ├── PopularIntegrationTest.java +│ └── TastingTagIntegrationTest.java +├── banner/integration/ +│ └── BannerIntegrationTest.java +├── history/integration/ +│ └── UserHistoryIntegrationTest.java +├── like/integration/ +│ └── LikesIntegrationTest.java +├── picks/integration/ +│ └── PicksIntegrationTest.java +├── rating/integration/ +│ └── RatingIntegrationTest.java +├── review/integration/ +│ ├── ReviewIntegrationTest.java +│ └── ReviewReplyIntegrationTest.java +├── support/ +│ ├── business/integration/BusinessSupportIntegrationTest.java +│ ├── help/integration/HelpIntegrationTest.java +│ └── report/integration/ +│ ├── DailyDataReportIntegrationTest.java +│ └── ReportIntegrationTest.java +└── user/integration/ + ├── UserCommandIntegrationTest.java + └── UserQueryIntegrationTest.java +``` + +### 2.2 마이그레이션 제외 대상 + +#### RestDocs 테스트 (27개 파일) - 제외 사유 +- `AbstractRestDocs` 베이스 클래스가 `MockMvcBuilders.standaloneSetup()` 사용 +- RestDocs 문서화 설정이 MockMvc에 의존 +- 스탠드얼론 모드로 빠른 테스트 속도 유지 필요 + +#### 이미 마이그레이션된 파일 (2개 파일) +- `AlcoholQueryIntegrationTest.java` +- `ImageUploadIntegrationTest.java` + +--- + +## 3. 마이그레이션 가이드 + +### 3.1 코드 변환 패턴 + +#### Before (MockMvc 레거시) +```java +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.test.web.servlet.MvcResult; + +MvcResult result = mockMvc + .perform( + put("/api/v1/picks") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").exists()) + .andReturn(); + +String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); +GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); +PicksUpdateResponse data = mapper.convertValue(response.getData(), PicksUpdateResponse.class); +``` + +#### After (MockMvcTester 신규) +```java +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import org.springframework.test.web.servlet.assertj.MvcTestResult; + +MvcTestResult result = mockMvcTester + .put() + .uri("/api/v1/picks") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + +PicksUpdateResponse data = extractData(result, PicksUpdateResponse.class); +``` + +### 3.2 HTTP 메서드별 변환 + +| MockMvc | MockMvcTester | +|---------|---------------| +| `perform(get("/path"))` | `.get().uri("/path")` | +| `perform(post("/path"))` | `.post().uri("/path")` | +| `perform(put("/path"))` | `.put().uri("/path")` | +| `perform(patch("/path"))` | `.patch().uri("/path")` | +| `perform(delete("/path"))` | `.delete().uri("/path")` | + +### 3.3 파라미터 및 헤더 설정 + +| 항목 | MockMvc | MockMvcTester | +|------|---------|---------------| +| Query Param | `.param("key", "value")` | `.param("key", "value")` | +| Path Variable | `get("/api/{id}", 1L)` | `.get().uri("/api/{id}", 1L)` | +| Content Type | `.contentType(MediaType.APPLICATION_JSON)` | `.contentType(APPLICATION_JSON)` | +| Header | `.header("Authorization", token)` | `.header("Authorization", token)` | +| CSRF | `.with(csrf())` | `.with(csrf())` | + +### 3.4 응답 처리 변환 + +#### 단순 상태 검증 +```java +// Before +mockMvc.perform(get("/api/v1/test")) + .andExpect(status().isOk()); + +// After +mockMvcTester.get().uri("/api/v1/test").exchange() + .assertThat().hasStatusOk(); +``` + +#### 응답 데이터 추출 +```java +// Before +MvcResult result = mockMvc.perform(...).andReturn(); +String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); +GlobalResponse response = mapper.readValue(json, GlobalResponse.class); +MyDto data = mapper.convertValue(response.getData(), MyDto.class); + +// After (IntegrationTestSupport의 헬퍼 메서드 활용) +MvcTestResult result = mockMvcTester.get().uri(...).exchange(); +MyDto data = extractData(result, MyDto.class); +``` + +### 3.5 Import 문 변경 + +#### 제거할 import +```java +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import org.springframework.test.web.servlet.MvcResult; +import java.nio.charset.StandardCharsets; +``` + +#### 추가할 import +```java +import static org.springframework.http.MediaType.APPLICATION_JSON; +import org.springframework.test.web.servlet.assertj.MvcTestResult; +``` + +#### 유지할 import +```java +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +``` + +--- + +## 4. 마이그레이션 단계 + +### Phase 1: 인프라 확인 (완료) +- [x] `IntegrationTestSupport`에 `mockMvcTester` 주입 확인 +- [x] `extractData(MvcTestResult, Class)` 헬퍼 메서드 확인 +- [x] `parseResponse(MvcTestResult)` 헬퍼 메서드 확인 + +### Phase 2: 도메인별 순차 마이그레이션 + +#### 2-1. picks (1개 파일) - 우선순위 높음 (간단한 CRUD) +- [ ] `PicksIntegrationTest.java` + +#### 2-2. like (1개 파일) +- [ ] `LikesIntegrationTest.java` + +#### 2-3. rating (1개 파일) +- [ ] `RatingIntegrationTest.java` + +#### 2-4. banner (1개 파일) +- [ ] `BannerIntegrationTest.java` + +#### 2-5. history (1개 파일) +- [ ] `UserHistoryIntegrationTest.java` + +#### 2-6. alcohols (2개 파일) +- [ ] `PopularIntegrationTest.java` +- [ ] `TastingTagIntegrationTest.java` + +#### 2-7. review (2개 파일) +- [ ] `ReviewIntegrationTest.java` +- [ ] `ReviewReplyIntegrationTest.java` + +#### 2-8. support (4개 파일) +- [ ] `BusinessSupportIntegrationTest.java` +- [ ] `HelpIntegrationTest.java` +- [ ] `ReportIntegrationTest.java` +- [ ] `DailyDataReportIntegrationTest.java` + +#### 2-9. user (2개 파일) +- [ ] `UserCommandIntegrationTest.java` +- [ ] `UserQueryIntegrationTest.java` + +### Phase 3: 정리 +- [ ] 미사용 import 제거 +- [ ] `IntegrationTestSupport`에서 MockMvc 필드 제거 검토 +- [ ] 테스트 실행 및 검증 + +--- + +## 5. 참조 코드 + +### 5.1 admin-api IntegrationTestSupport (Kotlin) +```kotlin +// 참조: MockMvcTester만 사용하는 깔끔한 구조 +@Autowired +protected lateinit var mockMvcTester: MockMvcTester + +protected fun extractData(result: MvcTestResult, dataType: Class): T { + val response = parseResponse(result) + return mapper.convertValue(response.data, dataType) +} +``` + +### 5.2 이미 마이그레이션된 테스트 예시 +- `ImageUploadIntegrationTest.java`: 복잡한 비동기 시나리오 +- `AlcoholQueryIntegrationTest.java`: 페이징 및 검색 쿼리 + +--- + +## 6. 예상 작업량 + +| 파일 | 예상 테스트 메서드 수 | 복잡도 | +|------|---------------------|--------| +| PicksIntegrationTest | 2 | 낮음 | +| LikesIntegrationTest | 2-3 | 낮음 | +| RatingIntegrationTest | 3-5 | 중간 | +| BannerIntegrationTest | 2-3 | 낮음 | +| UserHistoryIntegrationTest | 3-5 | 중간 | +| PopularIntegrationTest | 3-5 | 중간 | +| TastingTagIntegrationTest | 2-3 | 낮음 | +| ReviewIntegrationTest | 5-10 | 높음 | +| ReviewReplyIntegrationTest | 3-5 | 중간 | +| BusinessSupportIntegrationTest | 3-5 | 중간 | +| HelpIntegrationTest | 3-5 | 중간 | +| ReportIntegrationTest | 3-5 | 중간 | +| DailyDataReportIntegrationTest | 2-3 | 낮음 | +| UserCommandIntegrationTest | 5-8 | 높음 | +| UserQueryIntegrationTest | 3-5 | 중간 | + +**총 예상**: 약 45-70개 테스트 메서드 마이그레이션 + +--- + +## 7. 검증 체크리스트 + +마이그레이션 완료 후 각 파일별로 확인: + +- [ ] 모든 테스트가 성공적으로 실행되는가? +- [ ] `mockMvc` 필드 사용이 완전히 제거되었는가? +- [ ] 불필요한 import가 제거되었는가? +- [ ] `extractData()` 또는 `parseResponse()` 헬퍼 메서드를 활용하고 있는가? +- [ ] HTTP 메서드가 명시적으로 표현되어 있는가? (`.get()`, `.post()` 등) + +--- + +## 8. 최종 정리 작업 + +모든 파일 마이그레이션 완료 후: + +### 8.1 IntegrationTestSupport 정리 (선택적) +```java +// 제거 검토 대상 +@Autowired protected MockMvc mockMvc; + +// 제거 검토 대상 (레거시 오버로드) +protected T extractData(MvcResult result, Class dataType) +protected GlobalResponse parseResponse(MvcResult result) +``` + +### 8.2 최종 테스트 실행 +```bash +./gradlew :bottlenote-product-api:integration_test +``` + +--- + +## 9. 주의사항 및 트러블슈팅 + +### 9.1 에러 응답 처리 주의 + +`extractData()` 헬퍼 메서드는 내부적으로 `hasStatusOk()`를 호출하므로, **에러 케이스 테스트에서는 사용 불가**. + +```java +// BAD - extractData()는 200 OK만 처리 가능 +MvcTestResult result = mockMvcTester.get().uri(...).exchange(); +extractData(result, ErrorResponse.class); // hasStatusOk() 실패! + +// GOOD - 에러 케이스는 직접 검증 +MvcTestResult result = mockMvcTester.get().uri(...).exchange(); +result.assertThat() + .hasStatus(HttpStatus.BAD_REQUEST); +// 또는 response body 직접 파싱 +``` + +**해당 테스트 파일**: +- `ReviewIntegrationTest.java` - `test_3()`, `test_5()` (validation 에러 검증) +- `UserQueryIntegrationTest.java` - `test_4()` (MYPAGE_NOT_ACCESSIBLE), `test_3()`, `test_4()` (마이보틀 에러) + +### 9.2 jsonPath 검증 마이그레이션 + +MockMvc의 `andExpect(jsonPath(...))` 체인은 MockMvcTester에서 다르게 처리해야 함. + +```java +// Before (MockMvc) +mockMvc.perform(get("/api")) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.userId").value(1L)); + +// After (MockMvcTester) - 방법 1: AssertJ 체인 +MvcTestResult result = mockMvcTester.get().uri("/api").exchange(); +result.assertThat() + .hasStatusOk() + .bodyJson() + .extractingPath("$.code").isEqualTo(200); + +// After (MockMvcTester) - 방법 2: extractData 후 객체 검증 (권장) +MyResponse data = extractData(result, MyResponse.class); +assertEquals(1L, data.getUserId()); +``` + +**권장**: 복잡한 jsonPath 검증보다 `extractData()` 후 객체 단위 검증이 더 명확함. + +### 9.3 andDo(print()) 제거 + +MockMvcTester는 `print()` 메서드가 없음. 디버깅이 필요하면 로그를 직접 출력. + +```java +// Before +mockMvc.perform(get("/api")).andDo(print()); + +// After - print() 없음, 필요시 로그 출력 +MvcTestResult result = mockMvcTester.get().uri("/api").exchange(); +log.info("Response: {}", result.getResponse().getContentAsString()); +``` + +### 9.4 content() 메서드 - byte[] vs String + +MockMvc에서 `mapper.writeValueAsBytes()`를 사용하는 경우가 있음. MockMvcTester에서도 동일하게 동작. + +```java +// 둘 다 동작함 +.content(mapper.writeValueAsString(request)) // String +.content(mapper.writeValueAsBytes(request)) // byte[] +``` + +### 9.5 연속 API 호출 테스트 + +한 테스트에서 여러 API를 순차 호출하는 경우, 각각 별도의 `MvcTestResult`로 받아야 함. + +```java +// ReviewIntegrationTest의 test_1() (리뷰 삭제 테스트) 참조 +// 1. 리뷰 생성 +MvcTestResult createResult = mockMvcTester.post().uri("/api/v1/reviews")...exchange(); +ReviewCreateResponse created = extractData(createResult, ReviewCreateResponse.class); + +// 2. 생성된 리뷰 삭제 +MvcTestResult deleteResult = mockMvcTester.delete() + .uri("/api/v1/reviews/{reviewId}", created.getId())...exchange(); + +// 3. 목록에서 삭제 확인 +MvcTestResult listResult = mockMvcTester.get() + .uri("/api/v1/reviews/{alcoholId}", alcoholId)...exchange(); +``` + +### 9.6 @Nested 클래스의 @BeforeEach 주의 + +`ReviewIntegrationTest`의 `update` 클래스처럼 `@BeforeEach`에서 데이터를 설정하는 경우, `getTokenUserId()` 호출 시점에 주의. + +```java +@Nested +class update { + @BeforeEach + void setUp() { + // getTokenUserId()는 IntegrationTestSupport에서 제공 + final Long tokenUserId = getTokenUserId(); + Review review = ReviewObjectFixture.getReviewFixture(1L, tokenUserId, "content1"); + reviewRepository.save(review); + } +} +``` + +이 패턴은 MockMvcTester 마이그레이션과 무관하게 유지됨. + +### 9.7 Hamcrest matchers 의존성 + +일부 테스트에서 Hamcrest `hasSize()` 등을 사용 중. + +```java +import static org.hamcrest.Matchers.hasSize; + +// Before +.andExpect(jsonPath("$.errors", hasSize(2))) + +// After - AssertJ로 변환 권장 +result.assertThat() + .bodyJson() + .extractingPath("$.errors") + .asArray() + .hasSize(2); +``` + +### 9.8 파일별 특이사항 + +| 파일 | 특이사항 | +|------|----------| +| `ReviewIntegrationTest` | 에러 검증 케이스 다수, 연속 API 호출, `@BeforeEach` 사용 | +| `UserQueryIntegrationTest` | 에러 검증 케이스 다수, 다중 쿼리 파라미터 | +| `RatingIntegrationTest` | 테스트 내 데이터 직접 생성 후 조회 | +| `PicksIntegrationTest` | 가장 단순, 마이그레이션 연습용으로 적합 | + +--- + +## 10. 마이그레이션 순서 권장 + +복잡도 낮은 것부터 시작하여 패턴 익히기: + +1. **PicksIntegrationTest** (2개 메서드, 단순 CRUD) - 연습용 +2. **LikesIntegrationTest** (유사 패턴) +3. **RatingIntegrationTest** (데이터 설정 패턴 학습) +4. **BannerIntegrationTest**, **TastingTagIntegrationTest** (단순) +5. **나머지 파일들** (복잡도 증가순) +6. **ReviewIntegrationTest**, **UserQueryIntegrationTest** (마지막 - 에러 케이스 다수) From d6277374ebc655ae49400dd3c1a1c50658da43bb Mon Sep 17 00:00:00 2001 From: hgkim Date: Thu, 22 Jan 2026 15:11:07 +0900 Subject: [PATCH 95/95] deps: version sync --- git.environment-variables | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index 0ae2274c4..bd29491a5 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 0ae2274c4fd6c7323dce877ac70b446993c4fccb +Subproject commit bd29491a5e2422d11a66311d03a63885e64fdcb0