diff --git a/src/docs/asciidoc/admin/admin.adoc b/src/docs/asciidoc/admin/admin.adoc deleted file mode 100644 index bc8b8f8d..00000000 --- a/src/docs/asciidoc/admin/admin.adoc +++ /dev/null @@ -1,141 +0,0 @@ -[[admin-api]] -== Admin API - -[[admin-login]] -=== 어드민 로그인 - -어드민 계정으로 로그인합니다. - -operation::admin/login[snippets='http-request,request-fields,http-response,response-fields'] - -[[admin-refresh]] -=== 어드민 토큰 갱신 - -Refresh Token을 사용하여 새로운 Access Token을 발급받습니다. - -==== 유효한 토큰 - -Refresh Token의 유효기간이 10일 초과 남은 경우, Access Token만 재발급됩니다. - -operation::admin/refresh-valid-token[snippets='http-request,request-fields,http-response,response-fields'] - -==== 만료 임박 토큰 - -Refresh Token의 유효기간이 10일 이하로 남은 경우, Access Token과 Refresh Token이 모두 재발급됩니다. - -operation::admin/refresh-expiring-soon[snippets='http-request,request-fields,http-response,response-fields'] - -==== 만료된 토큰 - -operation::admin/refresh-expired-token[snippets='http-request,request-fields,http-response,response-fields'] - -==== 유효하지 않은 토큰 - -operation::admin/refresh-invalid-token[snippets='http-request,request-fields,http-response,response-fields'] - -[[admin-logout]] -=== 어드민 로그아웃 - -어드민 계정을 로그아웃하고 Refresh Token을 삭제합니다. - -* Authorization 헤더에 Access Token이 필요합니다. - -operation::admin/logout[snippets='http-request,request-headers,http-response,response-fields'] - -[[admin-management]] -=== 관리자 관리 - -[[admin-invite]] -==== 관리자 초대 - -새로운 관리자를 초대합니다. - -* SUPER_ADMIN 권한이 필요합니다. -* ADMIN 또는 VIEWER 역할만 초대 가능합니다. -* SUPER_ADMIN 역할은 초대할 수 없습니다. - -operation::admin/invite-admin[snippets='http-request,request-headers,request-fields,http-response,response-fields'] - -[[admin-list]] -==== 관리자 목록 조회 - -관리자 목록을 페이징하여 조회합니다. - -* SUPER_ADMIN 권한이 필요합니다. -* SUPER_ADMIN 역할은 목록에 표시되지 않습니다. -* 페이지 번호는 1부터 시작합니다. -* 기본 페이지 크기는 10이며, 최대 30까지 가능합니다. - -operation::admin/get-admins[snippets='http-request,request-headers,query-parameters,http-response,response-fields'] - -[[admin-delete]] -==== 관리자 삭제 - -관리자를 삭제합니다. - -* SUPER_ADMIN 권한이 필요합니다. - -operation::admin/delete-admin[snippets='http-request,request-headers,path-parameters,http-response,response-fields'] - -[[country-list]] -==== 나라 목록 조회 - -나라 목록을 조회합니다. - -* SUPER_ADMIN 또는 ADMIN 또는 VIEWER 권한이 필요합니다. - -operation::admin/get-countries[snippets='http-request,request-headers,http-response,response-fields'] - -[[city-list]] -==== 도시 목록 조회 - -나라별 도시 목록을 페이징하여 조회합니다. - -* SUPER_ADMIN 또는 ADMIN 또는 VIEWER 권한이 필요합니다. -* keyword 입력 시 한글명, 영문명으로 검색됩니다. -* 우선순위 오름차순, 우선순위 없는 도시는 이름 오름차순으로 정렬됩니다. -* 페이지 번호는 1부터 시작합니다. -* 기본 페이지 크기는 10이며, 최대 30까지 가능합니다. - -operation::admin/get-cities[snippets='http-request,request-headers,query-parameters,http-response,response-fields'] - -operation::admin/get-cities[snippets='http-request,request-headers,query-parameters,http-response,response-fields'] - -[[city-create]] -==== 도시 추가 - -도시를 추가합니다. - -* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. - -operation::admin/create-city[snippets='http-request,request-headers,request-fields,http-response,response-fields'] - -[[city-delete]] -==== 도시 삭제 - -도시를 삭제합니다. - -* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. - -operation::admin/delete-city[snippets='http-request,request-headers,path-parameters,http-response,response-fields'] - -[[city-priority]] -==== 도시 우선순위 설정 - -도시의 검색 우선순위를 설정합니다. - -* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. -* priority 미입력 시 우선순위가 초기화됩니다. -* 우선순위는 1 이상이어야 합니다. -* 같은 국가 내에서 우선순위가 중복되지 않도록 자동으로 조정됩니다. - -operation::admin/update-city-priority[snippets='http-request,request-headers,path-parameters,query-parameters,http-response,response-fields'] - -[[city-priority-reset]] -==== 도시 우선순위 초기화 - -도시의 검색 우선순위를 초기화합니다. - -* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. - -operation::admin/reset-city-priority[snippets='http-request,request-headers,path-parameters,http-response,response-fields'] diff --git a/src/docs/asciidoc/admin/auth.adoc b/src/docs/asciidoc/admin/auth.adoc new file mode 100644 index 00000000..74c025af --- /dev/null +++ b/src/docs/asciidoc/admin/auth.adoc @@ -0,0 +1,39 @@ +[[admin-api]] +== Admin API + +[[admin-login]] +=== 어드민 로그인 + +어드민 계정으로 로그인합니다. + +operation::admin/login[snippets='http-request,request-fields,http-response,response-fields'] + +[[admin-refresh]] +=== 어드민 토큰 갱신 + +Refresh Token을 사용하여 새로운 Access Token을 발급받습니다. + +==== 유효한 토큰 + +operation::admin/refresh-valid-token[snippets='http-request,request-fields,http-response,response-fields'] + +==== 만료 임박 토큰 + +operation::admin/refresh-expiring-soon[snippets='http-request,request-fields,http-response,response-fields'] + +==== 만료된 토큰 + +operation::admin/refresh-expired-token[snippets='http-request,request-fields,http-response,response-fields'] + +==== 유효하지 않은 토큰 + +operation::admin/refresh-invalid-token[snippets='http-request,request-fields,http-response,response-fields'] + +[[admin-logout]] +=== 어드민 로그아웃 + +어드민 계정을 로그아웃하고 Refresh Token을 삭제합니다. + +* Authorization 헤더에 Access Token이 필요합니다. + +operation::admin/logout[snippets='http-request,request-headers,http-response,response-fields'] diff --git a/src/docs/asciidoc/admin/index.adoc b/src/docs/asciidoc/admin/index.adoc index 28d09f97..603fd3f4 100644 --- a/src/docs/asciidoc/admin/index.adoc +++ b/src/docs/asciidoc/admin/index.adoc @@ -12,4 +12,7 @@ endif::[] include::../shared/overview.adoc[] -include::admin.adoc[] +include::auth.adoc[] +include::management.adoc[] +include::location.adoc[] +include::notice.adoc[] diff --git a/src/docs/asciidoc/admin/location.adoc b/src/docs/asciidoc/admin/location.adoc new file mode 100644 index 00000000..795b8288 --- /dev/null +++ b/src/docs/asciidoc/admin/location.adoc @@ -0,0 +1,63 @@ +[[admin-location]] +== 지역 관리 + +[[country-list]] +=== 나라 목록 조회 + +나라 목록을 조회합니다. + +* SUPER_ADMIN 또는 ADMIN 또는 VIEWER 권한이 필요합니다. + +operation::admin/get-countries[snippets='http-request,request-headers,http-response,response-fields'] + +[[city-list]] +=== 도시 목록 조회 + +나라별 도시 목록을 페이징하여 조회합니다. + +* SUPER_ADMIN 또는 ADMIN 또는 VIEWER 권한이 필요합니다. +* keyword 입력 시 한글명, 영문명으로 검색됩니다. +* 우선순위 오름차순, 우선순위 없는 도시는 이름 오름차순으로 정렬됩니다. +* 페이지 번호는 1부터 시작합니다. +* 기본 페이지 크기는 10이며, 최대 30까지 가능합니다. + +operation::admin/get-cities[snippets='http-request,request-headers,query-parameters,http-response,response-fields'] + +[[city-create]] +=== 도시 추가 + +도시를 추가합니다. + +* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. + +operation::admin/create-city[snippets='http-request,request-headers,request-fields,http-response,response-fields'] + +[[city-delete]] +=== 도시 삭제 + +도시를 삭제합니다. + +* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. + +operation::admin/delete-city[snippets='http-request,request-headers,path-parameters,http-response,response-fields'] + +[[city-priority]] +=== 도시 우선순위 설정 + +도시의 검색 우선순위를 설정합니다. + +* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. +* priority 미입력 시 우선순위가 초기화됩니다. +* 우선순위는 1 이상이어야 합니다. +* 같은 국가 내에서 우선순위가 중복되지 않도록 자동으로 조정됩니다. + +operation::admin/update-city-priority[snippets='http-request,request-headers,path-parameters,query-parameters,http-response,response-fields'] + +[[city-priority-reset]] +=== 도시 우선순위 초기화 + +도시의 검색 우선순위를 초기화합니다. + +* SUPER_ADMIN 또는 ADMIN 권한이 필요합니다. + +operation::admin/reset-city-priority[snippets='http-request,request-headers,path-parameters,http-response,response-fields'] diff --git a/src/docs/asciidoc/admin/management.adoc b/src/docs/asciidoc/admin/management.adoc new file mode 100644 index 00000000..13e30de2 --- /dev/null +++ b/src/docs/asciidoc/admin/management.adoc @@ -0,0 +1,34 @@ +[[admin-management]] +== 관리자 관리 + +[[admin-invite]] +=== 관리자 초대 + +새로운 관리자를 초대합니다. + +* SUPER_ADMIN 권한이 필요합니다. +* ADMIN 또는 VIEWER 역할만 초대 가능합니다. +* SUPER_ADMIN 역할은 초대할 수 없습니다. + +operation::admin/invite-admin[snippets='http-request,request-headers,request-fields,http-response,response-fields'] + +[[admin-list]] +=== 관리자 목록 조회 + +관리자 목록을 페이징하여 조회합니다. + +* SUPER_ADMIN 권한이 필요합니다. +* SUPER_ADMIN 역할은 목록에 표시되지 않습니다. +* 페이지 번호는 1부터 시작합니다. +* 기본 페이지 크기는 10이며, 최대 30까지 가능합니다. + +operation::admin/get-admins[snippets='http-request,request-headers,query-parameters,http-response,response-fields'] + +[[admin-delete]] +=== 관리자 삭제 + +관리자를 삭제합니다. + +* SUPER_ADMIN 권한이 필요합니다. + +operation::admin/delete-admin[snippets='http-request,request-headers,path-parameters,http-response,response-fields'] diff --git a/src/docs/asciidoc/admin/notice.adoc b/src/docs/asciidoc/admin/notice.adoc new file mode 100644 index 00000000..2bd62502 --- /dev/null +++ b/src/docs/asciidoc/admin/notice.adoc @@ -0,0 +1,72 @@ +[[admin-notice-api]] +== 공지사항 관리 + +[[admin-notice-register]] +=== 공지사항 등록 + +공지사항을 등록합니다. + +* ADMIN 권한이 필요합니다. +* multipart/form-data 형식으로 전송됩니다. +* 파일은 선택사항입니다. + +operation::admin/notice/register[snippets='http-request,request-parts,request-part-notice-fields,http-response,response-fields'] + +[[admin-notice-update]] +=== 공지사항 수정 + +공지사항을 수정합니다. + +* ADMIN 권한이 필요합니다. +* multipart/form-data 형식으로 전송됩니다. +* deleteFileIds: 삭제할 파일 ID 목록 (JSON array) +* newFiles: 새로 추가할 파일 + +operation::admin/notice/update[snippets='http-request,path-parameters,request-parts,request-part-notice-fields,http-response,response-fields'] + +[[admin-notice-activate]] +=== 공지사항 활성화 + +공지사항을 활성화합니다. + +* ADMIN 권한이 필요합니다. + +operation::admin/notice/activate[snippets='http-request,path-parameters,http-response,response-fields'] + +[[admin-notice-deactivate]] +=== 공지사항 비활성화 + +공지사항을 비활성화합니다. + +* ADMIN 권한이 필요합니다. + +operation::admin/notice/deactivate[snippets='http-request,path-parameters,http-response,response-fields'] + +[[admin-notice-delete]] +=== 공지사항 삭제 + +공지사항을 삭제합니다. + +* ADMIN 권한이 필요합니다. +* 공지사항과 관련된 모든 파일도 함께 삭제됩니다. + +operation::admin/notice/delete[snippets='http-request,path-parameters,http-response,response-fields'] + +[[admin-notice-get-all]] +=== 전체 공지사항 목록 조회 + +전체 공지사항 목록을 조회합니다. + +* VIEWER 이상의 권한이 필요합니다. +* ACTIVE, INACTIVE 상태 모두 조회됩니다. + +operation::admin/notice/get-all[snippets='http-request,http-response,response-fields'] + +[[admin-notice-get-by-id]] +=== 공지사항 상세 조회 + +공지사항 상세 정보를 조회합니다. + +* VIEWER 이상의 권한이 필요합니다. + +operation::admin/notice/get-by-id[snippets='http-request,path-parameters,http-response,response-fields'] diff --git a/src/docs/asciidoc/app/index.adoc b/src/docs/asciidoc/app/index.adoc index 0f0ec70c..2095e924 100644 --- a/src/docs/asciidoc/app/index.adoc +++ b/src/docs/asciidoc/app/index.adoc @@ -22,3 +22,4 @@ include::ai.adoc[] include::location.adoc[] include::geocoding.adoc[] include::search.adoc[] +include::notice.adoc[] diff --git a/src/docs/asciidoc/app/notice.adoc b/src/docs/asciidoc/app/notice.adoc new file mode 100644 index 00000000..d8cf6c31 --- /dev/null +++ b/src/docs/asciidoc/app/notice.adoc @@ -0,0 +1,21 @@ +[[notice-api]] +== Notice API + +[[notice-get-all-active]] +=== 활성 공지사항 목록 조회 + +활성화된 공지사항 목록을 조회합니다. + +* ACTIVE 상태의 공지사항만 조회됩니다. +* 인증이 필요하지 않습니다. + +operation::notice/get-all-active[snippets='http-request,http-response,response-fields'] + +[[notice-get-by-id]] +=== 공지사항 상세 조회 + +공지사항 상세 정보를 조회합니다. + +* 인증이 필요하지 않습니다. + +operation::notice/get-by-id[snippets='http-request,path-parameters,http-response,response-fields'] diff --git "a/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" "b/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" index 5af3c393..70b1dec0 100644 --- "a/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" +++ "b/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" @@ -39,6 +39,63 @@ _Entity_ --- +### 파일 (File) +_Entity_ + +#### 속성 +- `id`: `Long` +- `entityType`: 엔티티 타입 +- `entityId`: 엔티티 ID +- `storageKey`: 스토리지 저장 경로 +- `originalName`: 원본 파일명 +- `fileSize`: 파일 크기 +- `contentType`: MIME 타입 +- `displayOrder`: 표시 순서 +- `createdAt`: 등록일 +- `updatedAt`: 수정일 + +#### 행위 +- `static register()`: 파일 등록 + +#### 규칙 +- 모든 필드는 필수 (null 불가) +- displayOrder가 지정되지 않으면 자동으로 마지막 순서 + 1로 설정 +- 동일한 (entityType, entityId, displayOrder) 조합은 유일해야 함 + +#### 외부 의존성 +- **NCP Object Storage**: 실제 파일 저장소 +- 허용 확장자: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp` +- 최대 파일 크기: 50MB +- URL 만료 시간: 1시간 + +--- + +### 공지사항 (Notice) +_Entity_ + +#### 속성 +- `id`: `Long` +- `title`: 제목 +- `content`: 내용 +- `authorId`: 작성자 ID (관리자) +- `status`: 상태 (ACTIVE/INACTIVE) +- `createdAt`: 등록일 +- `updatedAt`: 수정일 + +#### 행위 +- `static register(NoticeRegisterRequest)`: 공지사항 등록 +- `update(NoticeUpdateRequest)`: 공지사항 수정 (제목, 내용, 상태) +- `activate()`: 활성화 +- `deactivate()`: 비활성화 +- `isActive()`: 활성 상태 확인 + +#### 규칙 +- 모든 필드는 필수 (null 불가) +- 관리자만 작성/수정 가능 +- 상태는 ACTIVE 또는 INACTIVE + +--- + ### 좌표 (Coordinate) _Value Object_ diff --git "a/src/docs/\354\232\251\354\226\264\354\202\254\354\240\204.md" "b/src/docs/\354\232\251\354\226\264\354\202\254\354\240\204.md" index 92091d24..1ae74591 100644 --- "a/src/docs/\354\232\251\354\226\264\354\202\254\354\240\204.md" +++ "b/src/docs/\354\232\251\354\226\264\354\202\254\354\240\204.md" @@ -1,11 +1,12 @@ # Souzip 용어 사전 -| **한국어** | **영어** | **설명** | -|---------|--------------|-------------------------------------------| -| 여행지 | Destination | 여행할 수 있는 목적지. 국가, 도시, 특정 장소를 포함. | -| 도시 | City | 특정 국가에 속한 도시. 여행지의 기본 단위. | -| 국가 | Country | 도시가 속한 국가 정보. | -| 위치 | Location | 도시, 국가, 장소 등을 통칭하는 지리적 위치 정보. | -| 좌표 | Coordinate | 위도(latitude)와 경도(longitude)로 표현되는 지리적 좌표. | -| 장소 | Place | Google Places API를 통해 검색되는 구체적인 장소. | -| 주소 | Address | 상세한 위치 정보를 나타내는 주소. | +| **한국어** | **영어** | **설명** | +|-----------|-----------------|--------------------------------------------------| +| 여행지 | Destination | 여행할 수 있는 목적지. 국가, 도시, 특정 장소를 포함. | +| 도시 | City | 특정 국가에 속한 도시. 여행지의 기본 단위. | +| 국가 | Country | 도시가 속한 국가 정보. | +| 위치 | Location | 도시, 국가, 장소 등을 통칭하는 지리적 위치 정보. | +| 좌표 | Coordinate | 위도(latitude)와 경도(longitude)로 표현되는 지리적 좌표. | +| 장소 | Place | Google Places API를 통해 검색되는 구체적인 장소. | +| 주소 | Address | 상세한 위치 정보를 나타내는 주소. | +| 파일 | File | 이미지, 문서 등의 첨부 파일. 여러 엔티티(기념품, 공지사항 등)에 연결 가능. | diff --git a/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java b/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java index 44f6d2ac..44735ac0 100644 --- a/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java +++ b/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java @@ -62,8 +62,8 @@ private String buildApiUrl(Coordinate coordinate) { return String.format( "%s?latlng=%s,%s&language=%s&key=%s", baseUrl, - coordinate.latitude().doubleValue(), - coordinate.longitude().doubleValue(), + coordinate.getLatitude().doubleValue(), + coordinate.getLongitude().doubleValue(), language, apiKey ); diff --git a/src/main/java/com/souzip/domain/file/InvalidFileException.java b/src/main/java/com/souzip/adapter/storage/file/InvalidFileException.java similarity index 95% rename from src/main/java/com/souzip/domain/file/InvalidFileException.java rename to src/main/java/com/souzip/adapter/storage/file/InvalidFileException.java index 5f2e724a..3b5c493e 100644 --- a/src/main/java/com/souzip/domain/file/InvalidFileException.java +++ b/src/main/java/com/souzip/adapter/storage/file/InvalidFileException.java @@ -1,4 +1,4 @@ -package com.souzip.domain.file; +package com.souzip.adapter.storage.file; public class InvalidFileException extends RuntimeException { diff --git a/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java b/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java index 209a2535..c54fa132 100644 --- a/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java +++ b/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java @@ -4,7 +4,6 @@ import com.amazonaws.services.s3.model.ObjectMetadata; import com.souzip.adapter.config.ObjectStorageProperties; import com.souzip.application.file.required.FileStorage; -import com.souzip.domain.file.InvalidFileException; import com.souzip.global.exception.BusinessException; import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java new file mode 100644 index 00000000..6b7c940a --- /dev/null +++ b/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java @@ -0,0 +1,105 @@ +package com.souzip.adapter.webapi.admin; + +import com.souzip.adapter.webapi.admin.dto.NoticeRequest; +import com.souzip.application.notice.dto.NoticeResponse; +import com.souzip.application.notice.provided.NoticeFinder; +import com.souzip.application.notice.provided.NoticeRegister; +import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; +import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; +import com.souzip.domain.notice.Notice; +import com.souzip.global.common.dto.SuccessResponse; +import jakarta.validation.Valid; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.UUID; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RequestMapping("/api/admin/notices") +@RequiredArgsConstructor +@RestController +public class AdminNoticeApi { + + private final NoticeRegister noticeRegister; + private final NoticeFinder noticeFinder; + + @AdminAccess + @PostMapping + public SuccessResponse register( + @CurrentAdminId UUID adminId, + @Valid @RequestPart("notice") NoticeRequest request, + @RequestPart(value = "files", required = false) Optional> files + ) { + Notice notice = noticeRegister.register( + request.toDomain(adminId), + files.orElse(List.of()) + ); + + NoticeResponse response = noticeFinder.findByIdWithFiles(notice.getId()); + + return SuccessResponse.of(response, "공지사항이 등록되었습니다."); + } + + @AdminAccess + @PutMapping("/{noticeId}") + public SuccessResponse update( + @PathVariable Long noticeId, + @Valid @RequestPart("notice") NoticeRequest request, + @RequestPart(value = "deleteFileIds", required = false) Optional> deleteFileIds, + @RequestPart(value = "newFiles", required = false) Optional> newFiles + ) { + Notice notice = noticeRegister.update( + noticeId, + request.toDomain(), + deleteFileIds.orElse(List.of()), + newFiles.orElse(List.of()) + ); + + NoticeResponse response = noticeFinder.findByIdWithFiles(notice.getId()); + + return SuccessResponse.of(response, "공지사항이 수정되었습니다."); + } + + @AdminAccess + @PatchMapping("/{noticeId}/activate") + public SuccessResponse activate(@PathVariable Long noticeId) { + noticeRegister.activate(noticeId); + + return SuccessResponse.of("공지사항이 활성화되었습니다."); + } + + @AdminAccess + @PatchMapping("/{noticeId}/deactivate") + public SuccessResponse deactivate(@PathVariable Long noticeId) { + noticeRegister.deactivate(noticeId); + + return SuccessResponse.of("공지사항이 비활성화되었습니다."); + } + + @AdminAccess + @DeleteMapping("/{noticeId}") + public SuccessResponse delete(@PathVariable Long noticeId) { + noticeRegister.delete(noticeId); + + return SuccessResponse.of("공지사항이 삭제되었습니다."); + } + + @ViewerAccess + @GetMapping + public SuccessResponse> getAll() { + List response = noticeFinder.findAllWithFiles(); + + return SuccessResponse.of(response); + } + + @ViewerAccess + @GetMapping("/{noticeId}") + public SuccessResponse getById(@PathVariable Long noticeId) { + NoticeResponse response = noticeFinder.findByIdWithFiles(noticeId); + + return SuccessResponse.of(response); + } +} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/NoticeRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/NoticeRequest.java new file mode 100644 index 00000000..a752160f --- /dev/null +++ b/src/main/java/com/souzip/adapter/webapi/admin/dto/NoticeRequest.java @@ -0,0 +1,40 @@ +package com.souzip.adapter.webapi.admin.dto; + +import com.souzip.domain.notice.NoticeRegisterRequest; +import com.souzip.domain.notice.NoticeStatus; +import com.souzip.domain.notice.NoticeUpdateRequest; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record NoticeRequest( + + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + @Size(max = 10000, message = "내용은 10000자를 초과할 수 없습니다.") + String content, + + @NotNull(message = "상태는 필수입니다.") + NoticeStatus status +) { + public NoticeRegisterRequest toDomain(UUID adminId) { + return NoticeRegisterRequest.of( + title.trim(), + content.trim(), + adminId, + status + ); + } + + public NoticeUpdateRequest toDomain() { + return NoticeUpdateRequest.of( + title.trim(), + content.trim(), + status + ); + } +} diff --git a/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java b/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java index 09aaf933..c3d50139 100644 --- a/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java +++ b/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java @@ -52,8 +52,8 @@ private static SearchResponse from(SearchPlace place) { place.name(), null, place.address(), - place.coordinate().latitude(), - place.coordinate().longitude() + place.coordinate().getLatitude(), + place.coordinate().getLongitude() ); } } diff --git a/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java b/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java new file mode 100644 index 00000000..57254c86 --- /dev/null +++ b/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java @@ -0,0 +1,31 @@ +package com.souzip.adapter.webapi.user; + +import com.souzip.application.notice.dto.NoticeResponse; +import com.souzip.application.notice.provided.NoticeFinder; +import com.souzip.global.common.dto.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequestMapping("/api/notices") +@RequiredArgsConstructor +@RestController +public class NoticeApi { + + private final NoticeFinder noticeFinder; + + @GetMapping + public SuccessResponse> getAllActive() { + List response = noticeFinder.findAllActiveWithFiles(); + + return SuccessResponse.of(response); + } + + @GetMapping("/{noticeId}") + public SuccessResponse getById(@PathVariable Long noticeId) { + NoticeResponse response = noticeFinder.findActiveByIdWithFiles(noticeId); + + return SuccessResponse.of(response); + } +} diff --git a/src/main/java/com/souzip/application/file/FileModifyService.java b/src/main/java/com/souzip/application/file/FileModifyService.java index 45fc3e0c..f737d986 100644 --- a/src/main/java/com/souzip/application/file/FileModifyService.java +++ b/src/main/java/com/souzip/application/file/FileModifyService.java @@ -3,8 +3,8 @@ import com.souzip.application.file.provided.FileModifier; import com.souzip.application.file.required.FileRepository; import com.souzip.application.file.required.FileStorage; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; -import com.souzip.domain.file.FileNotFoundException; import com.souzip.domain.file.FileRegisterRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,7 +24,7 @@ public class FileModifyService implements FileModifier { @Override public File register( String userId, - String entityType, + EntityType entityType, Long entityId, MultipartFile file, Integer displayOrder @@ -54,7 +54,7 @@ public void delete(Long fileId) { } @Override - public void deleteByEntity(String entityType, Long entityId) { + public void deleteByEntity(EntityType entityType, Long entityId) { List files = fileRepository .findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(entityType, entityId); @@ -71,7 +71,7 @@ private void deleteFileWithStorage(File file) { fileRepository.delete(file); } - private Integer resolveDisplayOrder(String entityType, Long entityId, Integer displayOrder) { + private Integer resolveDisplayOrder(EntityType entityType, Long entityId, Integer displayOrder) { if (displayOrder != null) { return displayOrder; } diff --git a/src/main/java/com/souzip/domain/file/FileNotFoundException.java b/src/main/java/com/souzip/application/file/FileNotFoundException.java similarity index 58% rename from src/main/java/com/souzip/domain/file/FileNotFoundException.java rename to src/main/java/com/souzip/application/file/FileNotFoundException.java index 98cb2773..ed1c96b1 100644 --- a/src/main/java/com/souzip/domain/file/FileNotFoundException.java +++ b/src/main/java/com/souzip/application/file/FileNotFoundException.java @@ -1,17 +1,15 @@ -package com.souzip.domain.file; +package com.souzip.application.file; -public class FileNotFoundException extends RuntimeException { +import com.souzip.domain.file.EntityType; - public FileNotFoundException(String message) { - super(message); - } +public class FileNotFoundException extends RuntimeException { public FileNotFoundException(Long fileId) { super("파일을 찾을 수 없습니다. id: " + fileId); } - public FileNotFoundException(String entityType, Long entityId) { - super(String.format("파일을 찾을 수 없습니다. entityType: %s, entityId: %d", + public FileNotFoundException(EntityType entityType, Long entityId) { + super(String.format("파일을 찾을 수 없습니다. entityType: %s, entityId: %s", entityType, entityId)); } } diff --git a/src/main/java/com/souzip/application/file/FileQueryService.java b/src/main/java/com/souzip/application/file/FileQueryService.java index 28a859c7..c3bb207e 100644 --- a/src/main/java/com/souzip/application/file/FileQueryService.java +++ b/src/main/java/com/souzip/application/file/FileQueryService.java @@ -1,55 +1,108 @@ package com.souzip.application.file; +import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.provided.FileFinder; import com.souzip.application.file.required.FileRepository; +import com.souzip.application.file.required.FileStorage; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; -import com.souzip.domain.file.FileNotFoundException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - @Transactional(readOnly = true) @RequiredArgsConstructor @Service public class FileQueryService implements FileFinder { private final FileRepository fileRepository; + private final FileStorage fileStorage; @Override - public List findByEntity(String entityType, Long entityId) { - return fileRepository - .findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(entityType, entityId); + public List findByEntity(EntityType entityType, Long entityId) { + return fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(entityType, entityId); } @Override - public File findFirst(String entityType, Long entityId) { + public File findFirst(EntityType entityType, Long entityId) { return fileRepository .findFirstByEntityTypeAndEntityIdOrderByDisplayOrderAsc(entityType, entityId) .orElseThrow(() -> new FileNotFoundException(entityType, entityId)); } @Override - public Map findThumbnailsByEntityIds(String entityType, List entityIds) { - if (entityIds == null || entityIds.isEmpty()) { + public Map findThumbnailsByEntityIds(EntityType entityType, List entityIds) { + if (isEmptyEntityIds(entityIds)) { + return Map.of(); + } + + List files = fileRepository.findByEntityTypeAndEntityIdInAndDisplayOrderOrderByDisplayOrder( + entityType, + entityIds, + 1 + ); + + return mapThumbnailsByEntityId(files); + } + + @Override + public List findFileResponsesByEntity(EntityType entityType, Long entityId) { + List files = findByEntity(entityType, entityId); + return convertToFileResponses(files); + } + + @Override + public Map> findFilesByEntityIds(EntityType entityType, List entityIds) { + if (isEmptyEntityIds(entityIds)) { return Map.of(); } - List files = fileRepository - .findByEntityTypeAndEntityIdInAndDisplayOrderOrderByDisplayOrder( - entityType, - entityIds, - 1 - ); - - return files.stream() - .collect(Collectors.toMap( - File::getEntityId, - file -> file, - (existing, replacement) -> existing - )); + List files = fileRepository.findByEntityTypeAndEntityIdIn(entityType, entityIds); + return groupFilesByEntityId(files); + } + + private Map mapThumbnailsByEntityId(List files) { + Map thumbnails = new HashMap<>(); + + for (File file : files) { + thumbnails.putIfAbsent(file.getEntityId(), file); + } + + return thumbnails; + } + + private List convertToFileResponses(List files) { + List responses = new ArrayList<>(); + + for (File file : files) { + responses.add(toFileResponse(file)); + } + + return responses; + } + + private Map> groupFilesByEntityId(List files) { + Map> result = new HashMap<>(); + + for (File file : files) { + FileResponse response = toFileResponse(file); + result.computeIfAbsent(file.getEntityId(), k -> new ArrayList<>()) + .add(response); + } + + return result; + } + + private FileResponse toFileResponse(File file) { + String url = fileStorage.generateUrl(file.getStorageKey()); + return FileResponse.of(file, url); + } + + private boolean isEmptyEntityIds(List entityIds) { + return entityIds == null || entityIds.isEmpty(); } } diff --git a/src/main/java/com/souzip/application/file/provided/FileFinder.java b/src/main/java/com/souzip/application/file/provided/FileFinder.java index 797b4b4e..4778758e 100644 --- a/src/main/java/com/souzip/application/file/provided/FileFinder.java +++ b/src/main/java/com/souzip/application/file/provided/FileFinder.java @@ -1,17 +1,20 @@ package com.souzip.application.file.provided; +import com.souzip.application.file.dto.FileResponse; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import java.util.List; import java.util.Map; -/** - * 파일을 조회한다 - */ public interface FileFinder { - List findByEntity(String entityType, Long entityId); + List findByEntity(EntityType entityType, Long entityId); - File findFirst(String entityType, Long entityId); + File findFirst(EntityType entityType, Long entityId); - Map findThumbnailsByEntityIds(String entityType, List entityIds); + Map findThumbnailsByEntityIds(EntityType entityType, List entityIds); + + List findFileResponsesByEntity(EntityType entityType, Long entityId); + + Map> findFilesByEntityIds(EntityType entityType, List entityIds); } diff --git a/src/main/java/com/souzip/application/file/provided/FileModifier.java b/src/main/java/com/souzip/application/file/provided/FileModifier.java index 826cfc0f..173289e0 100644 --- a/src/main/java/com/souzip/application/file/provided/FileModifier.java +++ b/src/main/java/com/souzip/application/file/provided/FileModifier.java @@ -1,17 +1,15 @@ package com.souzip.application.file.provided; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import org.springframework.web.multipart.MultipartFile; -/** - * 파일 등록 및 삭제 기능을 제공한다 - */ public interface FileModifier { - File register(String userId, String entityType, Long entityId, + File register(String userId, EntityType entityType, Long entityId, MultipartFile file, Integer displayOrder); void delete(Long fileId); - void deleteByEntity(String entityType, Long entityId); + void deleteByEntity(EntityType entityType, Long entityId); } diff --git a/src/main/java/com/souzip/application/file/required/FileRepository.java b/src/main/java/com/souzip/application/file/required/FileRepository.java index 1293c6f0..9ccbb83f 100644 --- a/src/main/java/com/souzip/application/file/required/FileRepository.java +++ b/src/main/java/com/souzip/application/file/required/FileRepository.java @@ -1,28 +1,35 @@ package com.souzip.application.file.required; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; -/** - * 파일 정보를 저장하거나 조회한다 - */ public interface FileRepository extends Repository { File save(File file); Optional findById(Long id); - List findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(String entityType, Long entityId); + List findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType entityType, Long entityId); - Optional findFirstByEntityTypeAndEntityIdOrderByDisplayOrderAsc(String entityType, Long entityId); + Optional findFirstByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType entityType, Long entityId); void delete(File file); + @Query("SELECT f FROM File f WHERE f.entityType = :entityType AND f.entityId IN :entityIds AND f.displayOrder = :displayOrder ORDER BY f.displayOrder") List findByEntityTypeAndEntityIdInAndDisplayOrderOrderByDisplayOrder( - String entityType, - List entityIds, - Integer displayOrder + @Param("entityType") EntityType entityType, + @Param("entityIds") List entityIds, + @Param("displayOrder") Integer displayOrder + ); + + @Query("SELECT f FROM File f WHERE f.entityType = :entityType AND f.entityId IN :entityIds ORDER BY f.entityId, f.displayOrder") + List findByEntityTypeAndEntityIdIn( + @Param("entityType") EntityType entityType, + @Param("entityIds") List entityIds ); } diff --git a/src/main/java/com/souzip/application/file/required/FileStorage.java b/src/main/java/com/souzip/application/file/required/FileStorage.java index 3d7bc3ec..2caf6b8e 100644 --- a/src/main/java/com/souzip/application/file/required/FileStorage.java +++ b/src/main/java/com/souzip/application/file/required/FileStorage.java @@ -2,9 +2,6 @@ import org.springframework.web.multipart.MultipartFile; -/** - * 파일 스토리지에 파일을 업로드/삭제/URL 생성한다 - */ public interface FileStorage { String upload(String userId, MultipartFile file); diff --git a/src/main/java/com/souzip/application/notice/NoticeModifyService.java b/src/main/java/com/souzip/application/notice/NoticeModifyService.java new file mode 100644 index 00000000..0d245e3d --- /dev/null +++ b/src/main/java/com/souzip/application/notice/NoticeModifyService.java @@ -0,0 +1,105 @@ +package com.souzip.application.notice; + +import com.souzip.application.file.provided.FileFinder; +import com.souzip.application.file.provided.FileModifier; +import com.souzip.application.notice.provided.NoticeFinder; +import com.souzip.application.notice.provided.NoticeRegister; +import com.souzip.application.notice.required.NoticeRepository; +import com.souzip.domain.file.EntityType; +import com.souzip.domain.file.File; +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeRegisterRequest; +import com.souzip.domain.notice.NoticeUpdateRequest; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Transactional +@RequiredArgsConstructor +@Service +public class NoticeModifyService implements NoticeRegister { + + private final NoticeRepository noticeRepository; + private final NoticeFinder noticeFinder; + private final FileModifier fileModifier; + private final FileFinder fileFinder; + + @Override + public Notice register(NoticeRegisterRequest registerRequest, List files) { + Notice notice = Notice.register(registerRequest); + Notice savedNotice = noticeRepository.save(notice); + + uploadFiles(savedNotice.getId(), registerRequest.authorId().toString(), files); + + return savedNotice; + } + + @Override + public Notice update( + Long noticeId, + NoticeUpdateRequest updateRequest, + List deleteFileIds, + List newFiles + ) { + Notice notice = noticeFinder.findById(noticeId); + notice.update(updateRequest); + + deleteFiles(noticeId, deleteFileIds); + uploadFiles(noticeId, notice.getAuthorId().toString(), newFiles); + + return notice; + } + + @Override + public void activate(Long noticeId) { + Notice notice = noticeFinder.findById(noticeId); + notice.activate(); + } + + @Override + public void deactivate(Long noticeId) { + Notice notice = noticeFinder.findById(noticeId); + notice.deactivate(); + } + + @Override + public void delete(Long noticeId) { + Notice notice = noticeFinder.findById(noticeId); + + fileModifier.deleteByEntity(EntityType.NOTICE, noticeId); + noticeRepository.delete(notice); + } + + private void deleteFiles(Long noticeId, List deleteFileIds) { + if (isEmpty(deleteFileIds)) { + return; + } + + List noticeFiles = fileFinder.findByEntity(EntityType.NOTICE, noticeId); + Set validFileIds = noticeFiles.stream() + .map(File::getId) + .collect(Collectors.toSet()); + + deleteFileIds.stream() + .filter(validFileIds::contains) + .forEach(fileModifier::delete); + } + + private void uploadFiles(Long noticeId, String userId, List files) { + if (isEmpty(files)) { + return; + } + + files.forEach(file -> + fileModifier.register(userId, EntityType.NOTICE, noticeId, file, null) + ); + } + + private boolean isEmpty(List list) { + return list == null || list.isEmpty(); + } +} diff --git a/src/main/java/com/souzip/application/notice/NoticeNotFoundException.java b/src/main/java/com/souzip/application/notice/NoticeNotFoundException.java new file mode 100644 index 00000000..94aed01c --- /dev/null +++ b/src/main/java/com/souzip/application/notice/NoticeNotFoundException.java @@ -0,0 +1,8 @@ +package com.souzip.application.notice; + +public class NoticeNotFoundException extends RuntimeException { + + public NoticeNotFoundException(Long noticeId) { + super("공지사항을 찾을 수 없습니다. id: " + noticeId); + } +} diff --git a/src/main/java/com/souzip/application/notice/NoticeQueryService.java b/src/main/java/com/souzip/application/notice/NoticeQueryService.java new file mode 100644 index 00000000..93446cfe --- /dev/null +++ b/src/main/java/com/souzip/application/notice/NoticeQueryService.java @@ -0,0 +1,98 @@ +package com.souzip.application.notice; + +import com.souzip.application.file.dto.FileResponse; +import com.souzip.application.file.provided.FileFinder; +import com.souzip.application.notice.dto.NoticeResponse; +import com.souzip.application.notice.provided.NoticeFinder; +import com.souzip.application.notice.required.NoticeRepository; +import com.souzip.domain.file.EntityType; +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeStatus; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class NoticeQueryService implements NoticeFinder { + + private final NoticeRepository noticeRepository; + private final FileFinder fileFinder; + + @Override + public Notice findById(Long noticeId) { + return noticeRepository.findById(noticeId) + .orElseThrow(() -> new NoticeNotFoundException(noticeId)); + } + + @Override + public List findAllActive() { + return noticeRepository.findByStatusOrderByCreatedAtDesc(NoticeStatus.ACTIVE); + } + + @Override + public List findAll() { + return noticeRepository.findAllByOrderByCreatedAtDesc(); + } + + @Override + public NoticeResponse findByIdWithFiles(Long noticeId) { + Notice notice = findById(noticeId); + List files = fileFinder.findFileResponsesByEntity(EntityType.NOTICE, noticeId); + return NoticeResponse.from(notice, files); + } + + @Override + public NoticeResponse findActiveByIdWithFiles(Long noticeId) { + Notice notice = findById(noticeId); + + if (!notice.isActive()) { + throw new NoticeNotFoundException(noticeId); + } + + List files = fileFinder.findFileResponsesByEntity(EntityType.NOTICE, noticeId); + return NoticeResponse.from(notice, files); + } + + @Override + public List findAllActiveWithFiles() { + return findNoticesWithFiles(findAllActive()); + } + + @Override + public List findAllWithFiles() { + return findNoticesWithFiles(findAll()); + } + + private List findNoticesWithFiles(List notices) { + if (notices.isEmpty()) { + return List.of(); + } + + Map> filesMap = fetchFilesForNotices(notices); + return combineNoticesWithFiles(notices, filesMap); + } + + private Map> fetchFilesForNotices(List notices) { + List noticeIds = extractNoticeIds(notices); + return fileFinder.findFilesByEntityIds(EntityType.NOTICE, noticeIds); + } + + private List extractNoticeIds(List notices) { + return notices.stream() + .map(Notice::getId) + .toList(); + } + + private List combineNoticesWithFiles( + List notices, + Map> filesMap + ) { + return notices.stream() + .map(notice -> NoticeResponse.from(notice, filesMap.getOrDefault(notice.getId(), List.of()))) + .toList(); + } +} diff --git a/src/main/java/com/souzip/application/notice/dto/NoticeResponse.java b/src/main/java/com/souzip/application/notice/dto/NoticeResponse.java new file mode 100644 index 00000000..8e1bc605 --- /dev/null +++ b/src/main/java/com/souzip/application/notice/dto/NoticeResponse.java @@ -0,0 +1,39 @@ +package com.souzip.application.notice.dto; + +import com.souzip.application.file.dto.FileResponse; +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record NoticeResponse( + Long id, + String title, + String content, + UUID authorId, + NoticeStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List files +) { + + public static NoticeResponse from(Notice notice, List files) { + return new NoticeResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getAuthorId(), + notice.getStatus(), + notice.getCreatedAt(), + notice.getUpdatedAt(), + files + ); + } + + public static List fromList(List notices) { + return notices.stream() + .map(notice -> from(notice, List.of())) + .toList(); + } +} diff --git a/src/main/java/com/souzip/application/notice/provided/NoticeFinder.java b/src/main/java/com/souzip/application/notice/provided/NoticeFinder.java new file mode 100644 index 00000000..8f11ad9a --- /dev/null +++ b/src/main/java/com/souzip/application/notice/provided/NoticeFinder.java @@ -0,0 +1,22 @@ +package com.souzip.application.notice.provided; + +import com.souzip.application.notice.dto.NoticeResponse; +import com.souzip.domain.notice.Notice; +import java.util.List; + +public interface NoticeFinder { + + Notice findById(Long noticeId); + + List findAllActive(); + + List findAll(); + + NoticeResponse findByIdWithFiles(Long noticeId); + + NoticeResponse findActiveByIdWithFiles(Long noticeId); + + List findAllActiveWithFiles(); + + List findAllWithFiles(); +} diff --git a/src/main/java/com/souzip/application/notice/provided/NoticeRegister.java b/src/main/java/com/souzip/application/notice/provided/NoticeRegister.java new file mode 100644 index 00000000..9d8cc201 --- /dev/null +++ b/src/main/java/com/souzip/application/notice/provided/NoticeRegister.java @@ -0,0 +1,25 @@ +package com.souzip.application.notice.provided; + +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeRegisterRequest; +import com.souzip.domain.notice.NoticeUpdateRequest; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public interface NoticeRegister { + + Notice register(NoticeRegisterRequest noticeRegisterRequest, List files); + + Notice update( + Long noticeId, + NoticeUpdateRequest noticeUpdateRequest, + List deleteFileIds, + List newFiles + ); + + void activate(Long noticeId); + + void deactivate(Long noticeId); + + void delete(Long noticeId); +} diff --git a/src/main/java/com/souzip/application/notice/required/NoticeRepository.java b/src/main/java/com/souzip/application/notice/required/NoticeRepository.java new file mode 100644 index 00000000..87ca3ebd --- /dev/null +++ b/src/main/java/com/souzip/application/notice/required/NoticeRepository.java @@ -0,0 +1,21 @@ +package com.souzip.application.notice.required; + +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeStatus; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; + +public interface NoticeRepository extends Repository { + + Notice save(Notice notice); + + Optional findById(Long id); + + List findAllByOrderByCreatedAtDesc(); + + List findByStatusOrderByCreatedAtDesc(NoticeStatus status); + + void delete(Notice notice); +} diff --git a/src/main/java/com/souzip/domain/file/EntityType.java b/src/main/java/com/souzip/domain/file/EntityType.java new file mode 100644 index 00000000..3eea1588 --- /dev/null +++ b/src/main/java/com/souzip/domain/file/EntityType.java @@ -0,0 +1,25 @@ +package com.souzip.domain.file; + +import java.util.Arrays; + +public enum EntityType { + NOTICE("Notice"), + SOUVENIR("Souvenir"); + + private final String value; + + EntityType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static EntityType from(String value) { + return Arrays.stream(values()) + .filter(type -> type.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 엔티티 타입입니다.")); + } +} diff --git a/src/main/java/com/souzip/domain/file/EntityTypeConverter.java b/src/main/java/com/souzip/domain/file/EntityTypeConverter.java new file mode 100644 index 00000000..c5c7c7ca --- /dev/null +++ b/src/main/java/com/souzip/domain/file/EntityTypeConverter.java @@ -0,0 +1,24 @@ +package com.souzip.domain.file; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class EntityTypeConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(EntityType attribute) { + if (attribute == null) { + return null; + } + return attribute.getValue(); + } + + @Override + public EntityType convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + return EntityType.from(dbData); // "Notice" → EntityType.NOTICE + } +} diff --git a/src/main/java/com/souzip/domain/file/File.java b/src/main/java/com/souzip/domain/file/File.java index 4c2e88cf..f515ad1d 100644 --- a/src/main/java/com/souzip/domain/file/File.java +++ b/src/main/java/com/souzip/domain/file/File.java @@ -11,7 +11,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class File extends BaseEntity { - private String entityType; + private EntityType entityType; private Long entityId; diff --git a/src/main/java/com/souzip/domain/file/FileRegisterRequest.java b/src/main/java/com/souzip/domain/file/FileRegisterRequest.java index f1bf63b7..246018b6 100644 --- a/src/main/java/com/souzip/domain/file/FileRegisterRequest.java +++ b/src/main/java/com/souzip/domain/file/FileRegisterRequest.java @@ -1,7 +1,7 @@ package com.souzip.domain.file; public record FileRegisterRequest( - String entityType, + EntityType entityType, Long entityId, String storageKey, String originalName, @@ -10,7 +10,7 @@ public record FileRegisterRequest( Integer displayOrder ) { public static FileRegisterRequest of( - String entityType, + EntityType entityType, Long entityId, String storageKey, String originalName, diff --git a/src/main/java/com/souzip/domain/notice/Notice.java b/src/main/java/com/souzip/domain/notice/Notice.java new file mode 100644 index 00000000..e0d2836e --- /dev/null +++ b/src/main/java/com/souzip/domain/notice/Notice.java @@ -0,0 +1,51 @@ +package com.souzip.domain.notice; + +import com.souzip.domain.shared.BaseEntity; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static java.util.Objects.requireNonNull; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notice extends BaseEntity { + + private String title; + + private String content; + + private UUID authorId; + + private NoticeStatus status; + + public static Notice register(NoticeRegisterRequest request) { + Notice notice = new Notice(); + + notice.title = requireNonNull(request.title(), "제목은 필수입니다."); + notice.content = requireNonNull(request.content(), "내용은 필수입니다."); + notice.authorId = requireNonNull(request.authorId(), "작성자는 필수입니다."); + notice.status = requireNonNull(request.status(), "상태는 필수입니다."); + + return notice; + } + + public void update(NoticeUpdateRequest request) { + this.title = requireNonNull(request.title(), "제목은 필수입니다."); + this.content = requireNonNull(request.content(), "내용은 필수입니다."); + this.status = requireNonNull(request.status(), "상태는 필수입니다."); + } + + public void activate() { + this.status = NoticeStatus.ACTIVE; + } + + public void deactivate() { + this.status = NoticeStatus.INACTIVE; + } + + public boolean isActive() { + return this.status == NoticeStatus.ACTIVE; + } +} diff --git a/src/main/java/com/souzip/domain/notice/NoticeRegisterRequest.java b/src/main/java/com/souzip/domain/notice/NoticeRegisterRequest.java new file mode 100644 index 00000000..52c7dff8 --- /dev/null +++ b/src/main/java/com/souzip/domain/notice/NoticeRegisterRequest.java @@ -0,0 +1,65 @@ +package com.souzip.domain.notice; + +import java.util.UUID; + +import static java.util.Objects.requireNonNull; + +public record NoticeRegisterRequest( + String title, + String content, + UUID authorId, + NoticeStatus status +) { + private static final int MAX_TITLE_LENGTH = 200; + private static final int MAX_CONTENT_LENGTH = 10000; + + public NoticeRegisterRequest { + title = validateTitle(title); + content = validateContent(content); + requireNonNull(authorId, "작성자는 필수입니다."); + requireNonNull(status, "상태는 필수입니다."); + } + + public static NoticeRegisterRequest of( + String title, + String content, + UUID authorId, + NoticeStatus status + ) { + return new NoticeRegisterRequest(title, content, authorId, status); + } + + private static String validateTitle(String title) { + requireNonNull(title, "제목은 필수입니다."); + + String trimmed = title.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("제목은 비어있을 수 없습니다."); + } + + if (trimmed.length() > MAX_TITLE_LENGTH) { + throw new IllegalArgumentException( + String.format("제목은 %d자를 초과할 수 없습니다.", MAX_TITLE_LENGTH) + ); + } + + return trimmed; + } + + private static String validateContent(String content) { + requireNonNull(content, "내용은 필수입니다."); + + String trimmed = content.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("내용은 비어있을 수 없습니다."); + } + + if (trimmed.length() > MAX_CONTENT_LENGTH) { + throw new IllegalArgumentException( + String.format("내용은 %d자를 초과할 수 없습니다.", MAX_CONTENT_LENGTH) + ); + } + + return trimmed; + } +} diff --git a/src/main/java/com/souzip/domain/notice/NoticeStatus.java b/src/main/java/com/souzip/domain/notice/NoticeStatus.java new file mode 100644 index 00000000..af191dd0 --- /dev/null +++ b/src/main/java/com/souzip/domain/notice/NoticeStatus.java @@ -0,0 +1,6 @@ +package com.souzip.domain.notice; + +public enum NoticeStatus { + ACTIVE, + INACTIVE +} diff --git a/src/main/java/com/souzip/domain/notice/NoticeUpdateRequest.java b/src/main/java/com/souzip/domain/notice/NoticeUpdateRequest.java new file mode 100644 index 00000000..b00ec5b7 --- /dev/null +++ b/src/main/java/com/souzip/domain/notice/NoticeUpdateRequest.java @@ -0,0 +1,11 @@ +package com.souzip.domain.notice; + +public record NoticeUpdateRequest( + String title, + String content, + NoticeStatus status +) { + public static NoticeUpdateRequest of(String title, String content, NoticeStatus status) { + return new NoticeUpdateRequest(title, content, status); + } +} diff --git a/src/main/java/com/souzip/domain/package-info.java b/src/main/java/com/souzip/domain/package-info.java new file mode 100644 index 00000000..99c662e2 --- /dev/null +++ b/src/main/java/com/souzip/domain/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package com.souzip.domain; + +import org.springframework.lang.NonNullApi; diff --git a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java index 9a51ea1a..85a1df0d 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java @@ -6,6 +6,7 @@ import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; import com.souzip.domain.category.entity.Category; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.repository.AiRecommendationRepositoryCustom; @@ -211,12 +212,12 @@ private List mapToRecommendedSouve } private String getThumbnailUrl(Long souvenirId) { - File file = fileQueryService.findFirst("Souvenir", souvenirId); + File file = fileQueryService.findFirst(EntityType.SOUVENIR, souvenirId); return fileStorage.generateUrl(file.getStorageKey()); } private Map getThumbnails(List souvenirIds) { - Map fileMap = fileQueryService.findThumbnailsByEntityIds("Souvenir", souvenirIds); + Map fileMap = fileQueryService.findThumbnailsByEntityIds(EntityType.SOUVENIR, souvenirIds); return fileMap.entrySet().stream() .collect(Collectors.toMap( diff --git a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java index f0bde378..e74ca2e8 100644 --- a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java @@ -3,6 +3,7 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import com.souzip.domain.recommend.general.dto.CountryRecommendationDto; import com.souzip.domain.recommend.general.dto.GeneralRecommendationDto; @@ -137,7 +138,7 @@ public List getTop3CountriesBySouvenirCount() { } private Map getThumbnails(List souvenirIds) { - Map fileMap = fileQueryService.findThumbnailsByEntityIds("Souvenir", souvenirIds); + Map fileMap = fileQueryService.findThumbnailsByEntityIds(EntityType.SOUVENIR, souvenirIds); return fileMap.entrySet().stream() .collect(Collectors.toMap( diff --git a/src/main/java/com/souzip/domain/shared/Coordinate.java b/src/main/java/com/souzip/domain/shared/Coordinate.java index 7450b39c..a9be6135 100644 --- a/src/main/java/com/souzip/domain/shared/Coordinate.java +++ b/src/main/java/com/souzip/domain/shared/Coordinate.java @@ -1,21 +1,32 @@ package com.souzip.domain.shared; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.math.BigDecimal; import static java.util.Objects.requireNonNull; import static org.springframework.util.Assert.state; -public record Coordinate( - BigDecimal latitude, - BigDecimal longitude -) { +@Getter +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Coordinate { + + private BigDecimal latitude; + private BigDecimal longitude; - public Coordinate { + private Coordinate(BigDecimal latitude, BigDecimal longitude) { requireNonNull(latitude, "위도는 필수입니다."); requireNonNull(longitude, "경도는 필수입니다."); validateLatitude(latitude); validateLongitude(longitude); + + this.latitude = latitude; + this.longitude = longitude; } public static Coordinate of(BigDecimal latitude, BigDecimal longitude) { diff --git a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java index 73d60cc8..337fe4c7 100644 --- a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java +++ b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java @@ -8,6 +8,7 @@ import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.exchangerate.dto.ExchangeCalculatedPrice; import com.souzip.domain.exchangerate.service.ExchangeRateService; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import com.souzip.domain.souvenir.dto.PriceData; import com.souzip.domain.souvenir.dto.PriceResponse; @@ -42,7 +43,6 @@ @Service public class SouvenirService { - public static final String ENTITY_TYPE_SOUVENIR = "Souvenir"; private static final String BEARER_PREFIX = "Bearer "; private static final int BEARER_PREFIX_LENGTH = 7; @@ -97,7 +97,7 @@ public void deleteSouvenir(Long id, Long userId) { Souvenir souvenir = findSouvenirWithOwnershipCheck(id, userId); - fileModifyService.deleteByEntity(ENTITY_TYPE_SOUVENIR, id); + fileModifyService.deleteByEntity(EntityType.SOUVENIR, id); souvenir.delete(); } @@ -297,7 +297,7 @@ private boolean isNotOwner(Souvenir souvenir, Long userId) { } private List getFiles(Long souvenirId) { - List files = fileQueryService.findByEntity(ENTITY_TYPE_SOUVENIR, souvenirId); + List files = fileQueryService.findByEntity(EntityType.SOUVENIR, souvenirId); return files.stream() .map(file -> FileResponse.of(file, fileStorage.generateUrl(file.getStorageKey()))) @@ -336,7 +336,7 @@ private FileResponse uploadSingleFile( ) { File uploadedFile = fileModifyService.register( userUuid, - ENTITY_TYPE_SOUVENIR, + EntityType.SOUVENIR, souvenirId, file, null diff --git a/src/main/java/com/souzip/domain/user/service/UserService.java b/src/main/java/com/souzip/domain/user/service/UserService.java index 1a725253..04087f71 100644 --- a/src/main/java/com/souzip/domain/user/service/UserService.java +++ b/src/main/java/com/souzip/domain/user/service/UserService.java @@ -7,6 +7,7 @@ import com.souzip.domain.auth.repository.RefreshTokenRepository; import com.souzip.domain.category.dto.CategoryDto; import com.souzip.domain.category.entity.Category; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import com.souzip.domain.souvenir.dto.MySouvenirListResponse; import com.souzip.domain.souvenir.dto.MySouvenirResponse; @@ -184,7 +185,7 @@ private void deleteUserAgreementIfExists(User user) { } private Map getThumbnails(List souvenirIds) { - Map fileMap = fileQueryService.findThumbnailsByEntityIds("Souvenir", souvenirIds); + Map fileMap = fileQueryService.findThumbnailsByEntityIds(EntityType.SOUVENIR, souvenirIds); return fileMap.entrySet().stream() .collect(Collectors.toMap( diff --git a/src/main/java/com/souzip/global/security/config/SecurityConfig.java b/src/main/java/com/souzip/global/security/config/SecurityConfig.java index 0774191d..9ee9188d 100644 --- a/src/main/java/com/souzip/global/security/config/SecurityConfig.java +++ b/src/main/java/com/souzip/global/security/config/SecurityConfig.java @@ -50,19 +50,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/login/**", - "/api/admin/auth/login", - "/api/admin/auth/refresh", "/api/auth/refresh", - "/docs/**", - "/api/test/**", + "/api/notices/**", "/api/location/search", "/api/countries/**", "/api/categories", "/api/search/**", "/api/souvenirs/nearby", "/api/discovery/**", - "/actuator/**", - "/api/migration/apple/prepare" + "/api/admin/auth/login", + "/api/admin/auth/refresh", + "/docs/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/souvenirs/*").permitAll() .anyRequest().authenticated() diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index dc57805b..ead65aed 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -48,7 +48,8 @@ - + + @@ -71,6 +72,25 @@ + + + + + + + + + + + + + + + STRING + + + + diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 658117f3..ce23510a 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -17,7 +17,6 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect default_schema: public flyway: diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 12412f7d..6f27257b 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -10,7 +10,6 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect default_schema: public flyway: diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 924038d0..0a7d021a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -13,7 +13,6 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect default_schema: public flyway: diff --git a/src/main/resources/db/migration/V13__create_notice_table.sql b/src/main/resources/db/migration/V13__create_notice_table.sql new file mode 100644 index 00000000..41e6f83e --- /dev/null +++ b/src/main/resources/db/migration/V13__create_notice_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS notice ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + author_id UUID NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + +CREATE INDEX idx_notice_status ON notice(status); +CREATE INDEX idx_notice_created_at ON notice(created_at DESC); +CREATE INDEX idx_notice_author_id ON notice(author_id); diff --git a/src/test/java/com/souzip/adapter/integration/googleplaces/GooglePlacesTest.java b/src/test/java/com/souzip/adapter/integration/googleplaces/GooglePlacesTest.java index f510b44e..ea3d0e98 100644 --- a/src/test/java/com/souzip/adapter/integration/googleplaces/GooglePlacesTest.java +++ b/src/test/java/com/souzip/adapter/integration/googleplaces/GooglePlacesTest.java @@ -54,8 +54,8 @@ void searchByKeyword_Success() { assertThat(places).hasSize(1); assertThat(places.getFirst().name()).isEqualTo("에펠탑 기념품샵"); assertThat(places.getFirst().address()).isEqualTo("프랑스 파리 샹드마르스 에펠탑"); - assertThat(places.getFirst().coordinate().latitude()).isEqualTo(BigDecimal.valueOf(48.8584)); - assertThat(places.getFirst().coordinate().longitude()).isEqualTo(BigDecimal.valueOf(2.2945)); + assertThat(places.getFirst().coordinate().getLatitude()).isEqualTo(BigDecimal.valueOf(48.8584)); + assertThat(places.getFirst().coordinate().getLongitude()).isEqualTo(BigDecimal.valueOf(2.2945)); } @DisplayName("상위 10개만 반환한다") diff --git a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java index fcbe2187..85a0c4b8 100644 --- a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java +++ b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java @@ -4,7 +4,6 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.souzip.adapter.config.ObjectStorageProperties; -import com.souzip.domain.file.InvalidFileException; import com.souzip.global.exception.BusinessException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/adapter/webapi/admin/AdminNoticeApiTest.java b/src/test/java/com/souzip/adapter/webapi/admin/AdminNoticeApiTest.java new file mode 100644 index 00000000..bfdb36be --- /dev/null +++ b/src/test/java/com/souzip/adapter/webapi/admin/AdminNoticeApiTest.java @@ -0,0 +1,419 @@ +package com.souzip.adapter.webapi.admin; + +import com.souzip.adapter.webapi.admin.dto.NoticeRequest; +import com.souzip.application.file.dto.FileResponse; +import com.souzip.application.notice.dto.NoticeResponse; +import com.souzip.application.notice.provided.NoticeFinder; +import com.souzip.application.notice.provided.NoticeRegister; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeRegisterRequest; +import com.souzip.domain.notice.NoticeStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.util.ReflectionTestUtils; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +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; + +class AdminNoticeApiTest extends RestDocsSupport { + + private final NoticeFinder noticeFinder = mock(NoticeFinder.class); + private final NoticeRegister noticeRegister = mock(NoticeRegister.class); + + @Override + protected Object initController() { + return new AdminNoticeApi(noticeRegister, noticeFinder); + } + + @DisplayName("공지사항을 등록할 수 있다") + @Test + void register() throws Exception { + // given + Notice mockNotice = createNotice("공지사항 제목", "공지사항 내용", NoticeStatus.ACTIVE); + + NoticeRequest requestDto = new NoticeRequest( + "공지사항 제목", + "공지사항 내용", + NoticeStatus.ACTIVE + ); + + MockMultipartFile noticePart = new MockMultipartFile( + "notice", + "notice.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) + ); + + MockMultipartFile filePart = new MockMultipartFile( + "files", + "test.jpg", + "image/jpeg", + "test image".getBytes() + ); + + List mockFiles = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "test.jpg", 1) + ); + + NoticeResponse mockResponse = new NoticeResponse( + 1L, "공지사항 제목", "공지사항 내용", TEST_ADMIN_ID, NoticeStatus.ACTIVE, + LocalDateTime.now(), LocalDateTime.now(), mockFiles + ); + + given(noticeRegister.register(any(NoticeRegisterRequest.class), anyList())) + .willReturn(mockNotice); + given(noticeFinder.findByIdWithFiles(eq(1L))) + .willReturn(mockResponse); + + // when & then + mockMvc.perform(multipart("/api/admin/notices") + .file(noticePart) + .file(filePart) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.title").value("공지사항 제목")) + .andExpect(jsonPath("$.data.content").value("공지사항 내용")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.message").value("공지사항이 등록되었습니다.")) + .andDo(document("admin/notice/register", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("notice").description("공지사항 정보(JSON) (필수)"), + partWithName("files").description("첨부 파일").optional() + ), + requestPartFields("notice", + fieldWithPath("title").description("공지사항 제목"), + fieldWithPath("content").description("공지사항 내용"), + fieldWithPath("status").description("공지사항 상태 (ACTIVE, INACTIVE)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("공지사항 ID"), + fieldWithPath("data.title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("data.content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("data.authorId").type(JsonFieldType.STRING).description("작성자 ID (UUID)"), + fieldWithPath("data.status").type(JsonFieldType.STRING).description("상태 (ACTIVE/INACTIVE)"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + @DisplayName("공지사항을 수정할 수 있다") + @Test + void update() throws Exception { + // given + Notice mockNotice = createNotice("수정된 제목", "수정된 내용", NoticeStatus.INACTIVE); + + NoticeRequest requestDto = new NoticeRequest( + "수정된 제목", + "수정된 내용", + NoticeStatus.INACTIVE + ); + + MockMultipartFile noticePart = new MockMultipartFile( + "notice", + "notice.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) + ); + + MockMultipartFile deleteFileIdsPart = new MockMultipartFile( + "deleteFileIds", + "deleteFileIds.json", + "application/json", + objectMapper.writeValueAsBytes(List.of(10L, 20L)) + ); + + MockMultipartFile newFilePart = new MockMultipartFile( + "newFiles", + "new.jpg", + "image/jpeg", + "new image".getBytes() + ); + + List mockFiles = List.of( + new FileResponse(3L, "https://example.com/new.jpg", "new.jpg", 1) + ); + + NoticeResponse mockResponse = new NoticeResponse( + 1L, "수정된 제목", "수정된 내용", TEST_ADMIN_ID, NoticeStatus.INACTIVE, + LocalDateTime.now(), LocalDateTime.now(), mockFiles + ); + + given(noticeRegister.update(anyLong(), any(), anyList(), anyList())) + .willReturn(mockNotice); + given(noticeFinder.findByIdWithFiles(eq(1L))) + .willReturn(mockResponse); + + // when & then + mockMvc.perform(multipart("/api/admin/notices/{noticeId}", 1L) + .file(noticePart) + .file(deleteFileIdsPart) + .file(newFilePart) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("수정된 제목")) + .andExpect(jsonPath("$.data.content").value("수정된 내용")) + .andExpect(jsonPath("$.data.status").value("INACTIVE")) + .andExpect(jsonPath("$.message").value("공지사항이 수정되었습니다.")) + .andDo(document("admin/notice/update", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("noticeId").description("공지사항 ID") + ), + requestParts( + partWithName("notice").description("공지사항 정보(JSON) (필수)"), + partWithName("deleteFileIds").description("삭제할 파일 ID 목록(JSON array)").optional(), + partWithName("newFiles").description("새로 추가할 파일").optional() + ), + requestPartFields("notice", + fieldWithPath("title").description("공지사항 제목"), + fieldWithPath("content").description("공지사항 내용"), + fieldWithPath("status").description("공지사항 상태 (ACTIVE, INACTIVE)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("공지사항 ID"), + fieldWithPath("data.title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("data.content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("data.authorId").type(JsonFieldType.STRING).description("작성자 ID (UUID)"), + fieldWithPath("data.status").type(JsonFieldType.STRING).description("상태 (ACTIVE/INACTIVE)"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + @DisplayName("공지사항을 활성화할 수 있다") + @Test + void activate() throws Exception { + // given + willDoNothing().given(noticeRegister).activate(anyLong()); + + // when & then + mockMvc.perform(patch("/api/admin/notices/{noticeId}/activate", 1L)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("공지사항이 활성화되었습니다.")) + .andDo(document("admin/notice/activate", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("noticeId").description("공지사항 ID") + ), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + @DisplayName("공지사항을 비활성화할 수 있다") + @Test + void deactivate() throws Exception { + // given + willDoNothing().given(noticeRegister).deactivate(anyLong()); + + // when & then + mockMvc.perform(patch("/api/admin/notices/{noticeId}/deactivate", 1L)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("공지사항이 비활성화되었습니다.")) + .andDo(document("admin/notice/deactivate", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("noticeId").description("공지사항 ID") + ), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + @DisplayName("공지사항을 삭제할 수 있다") + @Test + void deleteNotice() throws Exception { + // given + willDoNothing().given(noticeRegister).delete(anyLong()); + + // when & then + mockMvc.perform(delete("/api/admin/notices/{noticeId}", 1L)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("공지사항이 삭제되었습니다.")) + .andDo(document("admin/notice/delete", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("noticeId").description("공지사항 ID") + ), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + } + + @DisplayName("전체 공지사항 목록을 조회할 수 있다") + @Test + void getAll() throws Exception { + // given + List mockFiles = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 2) + ); + + List mockResponses = List.of( + new NoticeResponse(1L, "공지사항 1", "내용 1", TEST_ADMIN_ID, NoticeStatus.ACTIVE, + LocalDateTime.now(), LocalDateTime.now(), mockFiles), + new NoticeResponse(2L, "공지사항 2", "내용 2", TEST_ADMIN_ID, NoticeStatus.INACTIVE, + LocalDateTime.now(), LocalDateTime.now(), List.of()) + ); + + given(noticeFinder.findAllWithFiles()).willReturn(mockResponses); + + // when & then + mockMvc.perform(get("/api/admin/notices")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].title").value("공지사항 1")) + .andExpect(jsonPath("$.data[1].id").value(2)) + .andDo(document("admin/notice/get-all", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("공지사항 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("공지사항 ID"), + fieldWithPath("data[].title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("data[].content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("data[].authorId").type(JsonFieldType.STRING) + .description("작성자 ID (UUID)"), + fieldWithPath("data[].status").type(JsonFieldType.STRING) + .description("상태 (ACTIVE/INACTIVE)"), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data[].files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("data[].files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("data[].files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data[].files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data[].files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @DisplayName("공지사항 상세 정보를 조회할 수 있다") + @Test + void getById() throws Exception { + // given + List mockFiles = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1) + ); + + NoticeResponse mockResponse = new NoticeResponse( + 1L, "공지사항 제목", "공지사항 내용", TEST_ADMIN_ID, NoticeStatus.ACTIVE, + LocalDateTime.now(), LocalDateTime.now(), mockFiles + ); + + given(noticeFinder.findByIdWithFiles(anyLong())).willReturn(mockResponse); + + // when & then + mockMvc.perform(get("/api/admin/notices/{noticeId}", 1L)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.title").value("공지사항 제목")) + .andExpect(jsonPath("$.data.content").value("공지사항 내용")) + .andDo(document("admin/notice/get-by-id", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("noticeId").description("공지사항 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("공지사항 ID"), + fieldWithPath("data.title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("data.content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("data.authorId").type(JsonFieldType.STRING).description("작성자 ID (UUID)"), + fieldWithPath("data.status").type(JsonFieldType.STRING) + .description("상태 (ACTIVE/INACTIVE)"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + private Notice createNotice(String title, String content, NoticeStatus status) { + NoticeRegisterRequest request = NoticeRegisterRequest.of( + title, + content, + TEST_ADMIN_ID, + status + ); + Notice notice = Notice.register(request); + ReflectionTestUtils.setField(notice, "id", 1L); + ReflectionTestUtils.setField(notice, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notice, "updatedAt", LocalDateTime.now()); + return notice; + } +} diff --git a/src/test/java/com/souzip/adapter/webapi/user/NoticeApiTest.java b/src/test/java/com/souzip/adapter/webapi/user/NoticeApiTest.java new file mode 100644 index 00000000..6df55234 --- /dev/null +++ b/src/test/java/com/souzip/adapter/webapi/user/NoticeApiTest.java @@ -0,0 +1,127 @@ +package com.souzip.adapter.webapi.user; + +import com.souzip.application.file.dto.FileResponse; +import com.souzip.application.notice.dto.NoticeResponse; +import com.souzip.application.notice.provided.NoticeFinder; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.notice.NoticeStatus; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.JsonFieldType; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +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; + +class NoticeApiTest extends RestDocsSupport { + + private final NoticeFinder noticeFinder = mock(NoticeFinder.class); + + @Override + protected Object initController() { + return new NoticeApi(noticeFinder); + } + + @DisplayName("활성화된 공지사항 목록을 조회할 수 있다") + @Test + void getAllActive() throws Exception { + List mockFiles = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1) + ); + + List mockResponses = List.of( + new NoticeResponse(1L, "공지사항 1", "내용 1", TEST_ADMIN_ID, NoticeStatus.ACTIVE, + LocalDateTime.now(), LocalDateTime.now(), mockFiles), + new NoticeResponse(2L, "공지사항 2", "내용 2", TEST_ADMIN_ID, NoticeStatus.ACTIVE, + LocalDateTime.now(), LocalDateTime.now(), List.of()) + ); + + given(noticeFinder.findAllActiveWithFiles()).willReturn(mockResponses); + + mockMvc.perform(get("/api/notices")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].status").value("ACTIVE")) + .andExpect(jsonPath("$.data[1].status").value("ACTIVE")) + .andDo(document("notice/get-all-active", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("활성 공지사항 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("공지사항 ID"), + fieldWithPath("data[].title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("data[].content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("data[].authorId").type(JsonFieldType.STRING).description("작성자 ID (UUID)"), + fieldWithPath("data[].status").type(JsonFieldType.STRING).description("상태 (ACTIVE)"), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data[].files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("data[].files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("data[].files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data[].files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data[].files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @DisplayName("공지사항 상세 정보를 조회할 수 있다") + @Test + void getById() throws Exception { + List mockFiles = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1) + ); + + NoticeResponse mockResponse = new NoticeResponse( + 1L, "공지사항 제목", "공지사항 내용", TEST_ADMIN_ID, NoticeStatus.ACTIVE, + LocalDateTime.now(), LocalDateTime.now(), mockFiles + ); + + given(noticeFinder.findActiveByIdWithFiles(anyLong())).willReturn(mockResponse); + + mockMvc.perform(get("/api/notices/{noticeId}", 1L)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.title").value("공지사항 제목")) + .andExpect(jsonPath("$.data.content").value("공지사항 내용")) + .andDo(document("notice/get-by-id", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("noticeId").description("공지사항 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("공지사항 ID"), + fieldWithPath("data.title").type(JsonFieldType.STRING).description("제목"), + fieldWithPath("data.content").type(JsonFieldType.STRING).description("내용"), + fieldWithPath("data.authorId").type(JsonFieldType.STRING).description("작성자 ID (UUID)"), + fieldWithPath("data.status").type(JsonFieldType.STRING).description("상태 (ACTIVE/INACTIVE)"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("첨부 파일 목록"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } +} diff --git a/src/test/java/com/souzip/application/file/FileModifyServiceTest.java b/src/test/java/com/souzip/application/file/FileModifyServiceTest.java index 4c3fd776..20464aaa 100644 --- a/src/test/java/com/souzip/application/file/FileModifyServiceTest.java +++ b/src/test/java/com/souzip/application/file/FileModifyServiceTest.java @@ -2,8 +2,8 @@ import com.souzip.application.file.required.FileRepository; import com.souzip.application.file.required.FileStorage; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; -import com.souzip.domain.file.FileNotFoundException; import com.souzip.domain.file.FileRegisterRequest; import java.util.List; import java.util.Optional; @@ -46,10 +46,10 @@ void register_withDisplayOrder() { given(fileRepository.save(any(File.class))).willAnswer(invocation -> invocation.getArgument(0)); // when - File result = fileModifyService.register("user123", "NOTICE", 1L, multipartFile, 5); + File result = fileModifyService.register("user123", EntityType.NOTICE, 1L, multipartFile, 5); // then - assertThat(result.getEntityType()).isEqualTo("NOTICE"); + assertThat(result.getEntityType()).isEqualTo(EntityType.NOTICE); assertThat(result.getEntityId()).isEqualTo(1L); assertThat(result.getStorageKey()).isEqualTo(storageKey); assertThat(result.getDisplayOrder()).isEqualTo(5); @@ -66,12 +66,12 @@ void register_autoDisplayOrder_first() { String storageKey = "user123/uuid.jpg"; given(fileStorage.upload("user123", multipartFile)).willReturn(storageKey); - given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc("NOTICE", 1L)) + given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) .willReturn(List.of()); given(fileRepository.save(any(File.class))).willAnswer(invocation -> invocation.getArgument(0)); // when - File result = fileModifyService.register("user123", "NOTICE", 1L, multipartFile, null); + File result = fileModifyService.register("user123", EntityType.NOTICE, 1L, multipartFile, null); // then assertThat(result.getDisplayOrder()).isEqualTo(1); @@ -83,15 +83,15 @@ void register_autoDisplayOrder_notFirst() { // given MultipartFile multipartFile = createMockMultipartFile(); String storageKey = "user123/uuid.jpg"; - File existingFile = createFile(1L, "NOTICE", 1L, 3); + File existingFile = createFile(1L, EntityType.NOTICE, 1L, 3); given(fileStorage.upload("user123", multipartFile)).willReturn(storageKey); - given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc("NOTICE", 1L)) + given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) .willReturn(List.of(existingFile)); given(fileRepository.save(any(File.class))).willAnswer(invocation -> invocation.getArgument(0)); // when - File result = fileModifyService.register("user123", "NOTICE", 1L, multipartFile, null); + File result = fileModifyService.register("user123", EntityType.NOTICE, 1L, multipartFile, null); // then assertThat(result.getDisplayOrder()).isEqualTo(4); @@ -101,7 +101,7 @@ void register_autoDisplayOrder_notFirst() { @Test void delete() { // given - File file = createFile(1L, "NOTICE", 1L, 1); + File file = createFile(1L, EntityType.NOTICE, 1L, 1); given(fileRepository.findById(1L)).willReturn(Optional.of(file)); @@ -129,15 +129,15 @@ void delete_notFound() { @Test void deleteByEntity() { // given - File file1 = createFile(1L, "NOTICE", 1L, 1); - File file2 = createFile(2L, "NOTICE", 1L, 2); + File file1 = createFile(1L, EntityType.NOTICE, 1L, 1); + File file2 = createFile(2L, EntityType.NOTICE, 1L, 2); List files = List.of(file1, file2); - given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc("NOTICE", 1L)) + given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) .willReturn(files); // when - fileModifyService.deleteByEntity("NOTICE", 1L); + fileModifyService.deleteByEntity(EntityType.NOTICE, 1L); // then then(fileStorage).should().delete(file1.getStorageKey()); @@ -153,7 +153,7 @@ private MultipartFile createMockMultipartFile() { return file; } - private File createFile(Long id, String entityType, Long entityId, Integer displayOrder) { + private File createFile(Long id, EntityType entityType, Long entityId, Integer displayOrder) { FileRegisterRequest request = FileRegisterRequest.of( entityType, entityId, diff --git a/src/test/java/com/souzip/application/file/FileQueryServiceTest.java b/src/test/java/com/souzip/application/file/FileQueryServiceTest.java index 98f44a19..9c4ca50b 100644 --- a/src/test/java/com/souzip/application/file/FileQueryServiceTest.java +++ b/src/test/java/com/souzip/application/file/FileQueryServiceTest.java @@ -1,9 +1,14 @@ package com.souzip.application.file; +import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileRepository; +import com.souzip.application.file.required.FileStorage; +import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; -import com.souzip.domain.file.FileNotFoundException; import com.souzip.domain.file.FileRegisterRequest; +import java.util.List; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,10 +17,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; -import java.util.List; -import java.util.Map; -import java.util.Optional; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.eq; @@ -27,24 +28,24 @@ class FileQueryServiceTest { @Mock private FileRepository fileRepository; + @Mock + private FileStorage fileStorage; + @InjectMocks private FileQueryService fileQueryService; @DisplayName("엔티티에 속한 모든 파일을 조회한다") @Test void findByEntity() { - // given - File file1 = createFile(1L, "NOTICE", 1L, 1); - File file2 = createFile(2L, "NOTICE", 1L, 2); + File file1 = createFile(1L, EntityType.NOTICE, 1L, 1); + File file2 = createFile(2L, EntityType.NOTICE, 1L, 2); List files = List.of(file1, file2); - given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc("NOTICE", 1L)) + given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) .willReturn(files); - // when - List result = fileQueryService.findByEntity("NOTICE", 1L); + List result = fileQueryService.findByEntity(EntityType.NOTICE, 1L); - // then assertThat(result).hasSize(2); assertThat(result).containsExactly(file1, file2); assertThat(result.get(0).getId()).isEqualTo(1L); @@ -54,16 +55,13 @@ void findByEntity() { @DisplayName("엔티티의 첫 번째 파일을 조회한다") @Test void findFirst() { - // given - File file = createFile(1L, "NOTICE", 1L, 1); + File file = createFile(1L, EntityType.NOTICE, 1L, 1); - given(fileRepository.findFirstByEntityTypeAndEntityIdOrderByDisplayOrderAsc("NOTICE", 1L)) + given(fileRepository.findFirstByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) .willReturn(Optional.of(file)); - // when - File result = fileQueryService.findFirst("NOTICE", 1L); + File result = fileQueryService.findFirst(EntityType.NOTICE, 1L); - // then assertThat(result.getId()).isEqualTo(1L); assertThat(result.getDisplayOrder()).isEqualTo(1); } @@ -71,12 +69,10 @@ void findFirst() { @DisplayName("첫 번째 파일이 없으면 예외가 발생한다") @Test void findFirst_notFound() { - // given - given(fileRepository.findFirstByEntityTypeAndEntityIdOrderByDisplayOrderAsc("NOTICE", 1L)) + given(fileRepository.findFirstByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) .willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> fileQueryService.findFirst("NOTICE", 1L)) + assertThatThrownBy(() -> fileQueryService.findFirst(EntityType.NOTICE, 1L)) .isInstanceOf(FileNotFoundException.class) .hasMessageContaining("NOTICE") .hasMessageContaining("1"); @@ -85,20 +81,17 @@ void findFirst_notFound() { @DisplayName("여러 엔티티의 썸네일을 일괄 조회한다") @Test void findThumbnailsByEntityIds() { - // given - File file1 = createFile(1L, "NOTICE", 1L, 1); - File file2 = createFile(2L, "NOTICE", 2L, 1); + File file1 = createFile(1L, EntityType.NOTICE, 1L, 1); + File file2 = createFile(2L, EntityType.NOTICE, 2L, 1); List files = List.of(file1, file2); List entityIds = List.of(1L, 2L); given(fileRepository.findByEntityTypeAndEntityIdInAndDisplayOrderOrderByDisplayOrder( - eq("NOTICE"), eq(entityIds), eq(1) + eq(EntityType.NOTICE), eq(entityIds), eq(1) )).willReturn(files); - // when - Map result = fileQueryService.findThumbnailsByEntityIds("NOTICE", entityIds); + Map result = fileQueryService.findThumbnailsByEntityIds(EntityType.NOTICE, entityIds); - // then assertThat(result).hasSize(2); assertThat(result.get(1L).getId()).isEqualTo(1L); assertThat(result.get(2L).getId()).isEqualTo(2L); @@ -107,24 +100,156 @@ void findThumbnailsByEntityIds() { @DisplayName("엔티티 ID 목록이 비어있으면 빈 Map을 반환한다") @Test void findThumbnailsByEntityIds_emptyList() { - // when - Map result = fileQueryService.findThumbnailsByEntityIds("NOTICE", List.of()); + Map result = fileQueryService.findThumbnailsByEntityIds(EntityType.NOTICE, List.of()); - // then assertThat(result).isEmpty(); } @DisplayName("엔티티 ID 목록이 null이면 빈 Map을 반환한다") @Test void findThumbnailsByEntityIds_nullList() { - // when - Map result = fileQueryService.findThumbnailsByEntityIds("NOTICE", null); + Map result = fileQueryService.findThumbnailsByEntityIds(EntityType.NOTICE, null); + + assertThat(result).isEmpty(); + } + + @DisplayName("URL과 함께 파일 응답 목록을 조회한다") + @Test + void findFileResponsesByEntity() { + File file1 = createFile(1L, EntityType.NOTICE, 1L, 1); + File file2 = createFile(2L, EntityType.NOTICE, 1L, 2); + List files = List.of(file1, file2); + + given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) + .willReturn(files); + given(fileStorage.generateUrl("storage-key-1")) + .willReturn("https://example.com/file1.jpg"); + given(fileStorage.generateUrl("storage-key-2")) + .willReturn("https://example.com/file2.jpg"); + + List result = fileQueryService.findFileResponsesByEntity(EntityType.NOTICE, 1L); + + assertThat(result).hasSize(2); + assertThat(result.getFirst().id()).isEqualTo(1L); + assertThat(result.getFirst().url()).isEqualTo("https://example.com/file1.jpg"); + assertThat(result.get(0).originalName()).isEqualTo("file1.jpg"); + assertThat(result.get(0).displayOrder()).isEqualTo(1); + + assertThat(result.get(1).id()).isEqualTo(2L); + assertThat(result.get(1).url()).isEqualTo("https://example.com/file2.jpg"); + assertThat(result.get(1).originalName()).isEqualTo("file2.jpg"); + assertThat(result.get(1).displayOrder()).isEqualTo(2); + } + + @DisplayName("파일이 없으면 빈 응답 목록을 반환한다") + @Test + void findFileResponsesByEntity_emptyFiles() { + given(fileRepository.findByEntityTypeAndEntityIdOrderByDisplayOrderAsc(EntityType.NOTICE, 1L)) + .willReturn(List.of()); + + List result = fileQueryService.findFileResponsesByEntity(EntityType.NOTICE, 1L); + + assertThat(result).isEmpty(); + } + + @DisplayName("여러 엔티티의 파일을 일괄 조회한다") + @Test + void findFilesByEntityIds() { + File file1 = createFile(1L, EntityType.NOTICE, 1L, 1); + File file2 = createFile(2L, EntityType.NOTICE, 1L, 2); + File file3 = createFile(3L, EntityType.NOTICE, 2L, 1); + List files = List.of(file1, file2, file3); + List entityIds = List.of(1L, 2L); + + given(fileRepository.findByEntityTypeAndEntityIdIn(EntityType.NOTICE, entityIds)) + .willReturn(files); + given(fileStorage.generateUrl("storage-key-1")) + .willReturn("https://example.com/file1.jpg"); + given(fileStorage.generateUrl("storage-key-2")) + .willReturn("https://example.com/file2.jpg"); + given(fileStorage.generateUrl("storage-key-3")) + .willReturn("https://example.com/file3.jpg"); + + Map> result = fileQueryService.findFilesByEntityIds( + EntityType.NOTICE, + entityIds + ); + + assertThat(result).hasSize(2); + + assertThat(result.get(1L)).hasSize(2); + assertThat(result.get(1L).get(0).id()).isEqualTo(1L); + assertThat(result.get(1L).get(0).url()).isEqualTo("https://example.com/file1.jpg"); + assertThat(result.get(1L).get(1).id()).isEqualTo(2L); + assertThat(result.get(1L).get(1).url()).isEqualTo("https://example.com/file2.jpg"); + + assertThat(result.get(2L)).hasSize(1); + assertThat(result.get(2L).get(0).id()).isEqualTo(3L); + assertThat(result.get(2L).get(0).url()).isEqualTo("https://example.com/file3.jpg"); + } + + @DisplayName("파일이 없는 엔티티는 결과에 포함되지 않는다") + @Test + void findFilesByEntityIds_someEntitiesHaveNoFiles() { + File file1 = createFile(1L, EntityType.NOTICE, 1L, 1); + List files = List.of(file1); + List entityIds = List.of(1L, 2L, 3L); + + given(fileRepository.findByEntityTypeAndEntityIdIn(EntityType.NOTICE, entityIds)) + .willReturn(files); + given(fileStorage.generateUrl("storage-key-1")) + .willReturn("https://example.com/file1.jpg"); + + Map> result = fileQueryService.findFilesByEntityIds( + EntityType.NOTICE, + entityIds + ); + + assertThat(result).hasSize(1); + assertThat(result).containsKey(1L); + assertThat(result).doesNotContainKey(2L); + assertThat(result).doesNotContainKey(3L); + } + + @DisplayName("엔티티 ID 목록이 비어있으면 빈 Map을 반환한다 - findFilesByEntityIds") + @Test + void findFilesByEntityIds_emptyList() { + Map> result = fileQueryService.findFilesByEntityIds( + EntityType.NOTICE, + List.of() + ); + + assertThat(result).isEmpty(); + } + + @DisplayName("엔티티 ID 목록이 null이면 빈 Map을 반환한다 - findFilesByEntityIds") + @Test + void findFilesByEntityIds_nullList() { + Map> result = fileQueryService.findFilesByEntityIds( + EntityType.NOTICE, + null + ); + + assertThat(result).isEmpty(); + } + + @DisplayName("모든 엔티티에 파일이 없으면 빈 Map을 반환한다") + @Test + void findFilesByEntityIds_noFiles() { + List entityIds = List.of(1L, 2L, 3L); + + given(fileRepository.findByEntityTypeAndEntityIdIn(EntityType.NOTICE, entityIds)) + .willReturn(List.of()); + + Map> result = fileQueryService.findFilesByEntityIds( + EntityType.NOTICE, + entityIds + ); - // then assertThat(result).isEmpty(); } - private File createFile(Long id, String entityType, Long entityId, Integer displayOrder) { + private File createFile(Long id, EntityType entityType, Long entityId, Integer displayOrder) { FileRegisterRequest request = FileRegisterRequest.of( entityType, entityId, diff --git a/src/test/java/com/souzip/application/notice/NoticeModifyServiceTest.java b/src/test/java/com/souzip/application/notice/NoticeModifyServiceTest.java new file mode 100644 index 00000000..a6da987d --- /dev/null +++ b/src/test/java/com/souzip/application/notice/NoticeModifyServiceTest.java @@ -0,0 +1,308 @@ +package com.souzip.application.notice; + +import com.souzip.application.file.provided.FileFinder; +import com.souzip.application.file.provided.FileModifier; +import com.souzip.application.notice.provided.NoticeFinder; +import com.souzip.application.notice.required.NoticeRepository; +import com.souzip.domain.file.EntityType; +import com.souzip.domain.file.File; +import com.souzip.domain.file.FileRegisterRequest; +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeRegisterRequest; +import com.souzip.domain.notice.NoticeStatus; +import com.souzip.domain.notice.NoticeUpdateRequest; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class NoticeModifyServiceTest { + + private static final UUID TEST_ADMIN_ID = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + + @Mock + private NoticeRepository noticeRepository; + + @Mock + private NoticeFinder noticeFinder; + + @Mock + private FileModifier fileModifier; + + @Mock + private FileFinder fileFinder; + + @InjectMocks + private NoticeModifyService noticeModifyService; + + @DisplayName("공지사항을 등록한다") + @Test + void register() { + NoticeRegisterRequest request = NoticeRegisterRequest.of( + "제목", + "내용", + TEST_ADMIN_ID, + NoticeStatus.ACTIVE + ); + + MultipartFile file = new MockMultipartFile( + "file", + "test.jpg", + "image/jpeg", + "test".getBytes() + ); + + given(noticeRepository.save(any(Notice.class))) + .willAnswer(invocation -> { + Notice notice = invocation.getArgument(0); + ReflectionTestUtils.setField(notice, "id", 1L); + return notice; + }); + + Notice result = noticeModifyService.register(request, List.of(file)); + + assertThat(result.getTitle()).isEqualTo("제목"); + assertThat(result.getContent()).isEqualTo("내용"); + assertThat(result.getStatus()).isEqualTo(NoticeStatus.ACTIVE); + + then(fileModifier).should(times(1)) + .register( + eq(TEST_ADMIN_ID.toString()), + eq(EntityType.NOTICE), + eq(1L), + eq(file), + eq(null) + ); + } + + @DisplayName("파일 없이 공지사항을 등록한다") + @Test + void registerWithoutFiles() { + NoticeRegisterRequest request = NoticeRegisterRequest.of( + "제목", + "내용", + TEST_ADMIN_ID, + NoticeStatus.ACTIVE + ); + + given(noticeRepository.save(any(Notice.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + Notice result = noticeModifyService.register(request, null); + + assertThat(result.getTitle()).isEqualTo("제목"); + then(fileModifier).shouldHaveNoInteractions(); + } + + @DisplayName("공지사항을 수정한다") + @Test + void update() { + Notice notice = createNotice(NoticeStatus.INACTIVE); + NoticeUpdateRequest request = NoticeUpdateRequest.of( + "수정된 제목", + "수정된 내용", + NoticeStatus.ACTIVE + ); + + given(noticeFinder.findById(1L)).willReturn(notice); + + Notice result = noticeModifyService.update(1L, request, null, null); + + assertThat(result.getTitle()).isEqualTo("수정된 제목"); + assertThat(result.getContent()).isEqualTo("수정된 내용"); + assertThat(result.getStatus()).isEqualTo(NoticeStatus.ACTIVE); + } + + @DisplayName("공지사항 수정 시 파일을 삭제하고 추가한다") + @Test + void updateWithFiles() { + Notice notice = createNotice(NoticeStatus.ACTIVE); + NoticeUpdateRequest request = NoticeUpdateRequest.of( + "수정된 제목", + "수정된 내용", + NoticeStatus.ACTIVE + ); + + List deleteFileIds = List.of(10L, 20L); + MultipartFile newFile = new MockMultipartFile( + "file", + "new.jpg", + "image/jpeg", + "new".getBytes() + ); + + File file1 = createFile(10L, 1L); + File file2 = createFile(20L, 1L); + + given(noticeFinder.findById(1L)).willReturn(notice); + given(fileFinder.findByEntity(EntityType.NOTICE, 1L)) + .willReturn(List.of(file1, file2)); // ← 추가 + + noticeModifyService.update(1L, request, deleteFileIds, List.of(newFile)); + + then(fileModifier).should(times(1)).delete(10L); + then(fileModifier).should(times(1)).delete(20L); + then(fileModifier).should(times(1)) + .register( + eq(TEST_ADMIN_ID.toString()), + eq(EntityType.NOTICE), + eq(1L), + eq(newFile), + eq(null) + ); + } + + @DisplayName("공지사항에 속한 파일만 삭제한다") + @Test + void updateWithFiles_onlyDeleteOwnFiles() { + Notice notice = createNotice(NoticeStatus.ACTIVE); + NoticeUpdateRequest request = NoticeUpdateRequest.of( + "수정된 제목", + "수정된 내용", + NoticeStatus.ACTIVE + ); + + List deleteFileIds = List.of(10L, 20L, 30L); + + File file1 = createFile(10L, 1L); + File file2 = createFile(20L, 1L); + + given(noticeFinder.findById(1L)).willReturn(notice); + given(fileFinder.findByEntity(EntityType.NOTICE, 1L)) + .willReturn(List.of(file1, file2)); + + noticeModifyService.update(1L, request, deleteFileIds, null); + + then(fileModifier).should(times(1)).delete(10L); + then(fileModifier).should(times(1)).delete(20L); + then(fileModifier).should(never()).delete(30L); + } + + @DisplayName("다른 공지사항의 파일은 삭제하지 않는다") + @Test + void updateWithFiles_doNotDeleteOtherNoticeFiles() { + Notice notice = createNotice(NoticeStatus.ACTIVE); + NoticeUpdateRequest request = NoticeUpdateRequest.of( + "수정된 제목", + "수정된 내용", + NoticeStatus.ACTIVE + ); + + List deleteFileIds = List.of(10L, 99L); + + File file1 = createFile(10L, 1L); + + given(noticeFinder.findById(1L)).willReturn(notice); + given(fileFinder.findByEntity(EntityType.NOTICE, 1L)) + .willReturn(List.of(file1)); + + noticeModifyService.update(1L, request, deleteFileIds, null); + + then(fileModifier).should(times(1)).delete(10L); + then(fileModifier).should(never()).delete(99L); + } + + @DisplayName("파일이 없는 공지사항 수정 시 파일 삭제 요청을 무시한다") + @Test + void updateWithFiles_noFilesToDelete() { + Notice notice = createNotice(NoticeStatus.ACTIVE); + NoticeUpdateRequest request = NoticeUpdateRequest.of( + "수정된 제목", + "수정된 내용", + NoticeStatus.ACTIVE + ); + + List deleteFileIds = List.of(10L, 20L); + + given(noticeFinder.findById(1L)).willReturn(notice); + given(fileFinder.findByEntity(EntityType.NOTICE, 1L)) + .willReturn(List.of()); + + noticeModifyService.update(1L, request, deleteFileIds, null); + + then(fileModifier).should(never()).delete(10L); + then(fileModifier).should(never()).delete(20L); + } + + @DisplayName("공지사항을 활성화한다") + @Test + void activate() { + Notice notice = createNotice(NoticeStatus.INACTIVE); + + given(noticeFinder.findById(1L)).willReturn(notice); + + noticeModifyService.activate(1L); + + assertThat(notice.getStatus()).isEqualTo(NoticeStatus.ACTIVE); + assertThat(notice.isActive()).isTrue(); + } + + @DisplayName("공지사항을 비활성화한다") + @Test + void deactivate() { + Notice notice = createNotice(NoticeStatus.ACTIVE); + + given(noticeFinder.findById(1L)).willReturn(notice); + + noticeModifyService.deactivate(1L); + + assertThat(notice.getStatus()).isEqualTo(NoticeStatus.INACTIVE); + assertThat(notice.isActive()).isFalse(); + } + + @DisplayName("공지사항을 삭제한다") + @Test + void delete() { + Notice notice = createNotice(NoticeStatus.ACTIVE); + + given(noticeFinder.findById(1L)).willReturn(notice); + + noticeModifyService.delete(1L); + + then(fileModifier).should(times(1)).deleteByEntity(EntityType.NOTICE, 1L); + then(noticeRepository).should(times(1)).delete(notice); + } + + private Notice createNotice(NoticeStatus status) { + NoticeRegisterRequest request = NoticeRegisterRequest.of( + "제목", + "내용", + TEST_ADMIN_ID, + status + ); + Notice notice = Notice.register(request); + ReflectionTestUtils.setField(notice, "id", 1L); + return notice; + } + + private File createFile(Long fileId, Long noticeId) { + FileRegisterRequest request = FileRegisterRequest.of( + EntityType.NOTICE, + noticeId, + "storage-key-" + fileId, + "file.jpg", + 1024L, + "image/jpeg", + 1 + ); + File file = File.register(request); + ReflectionTestUtils.setField(file, "id", fileId); + return file; + } +} diff --git a/src/test/java/com/souzip/application/notice/NoticeQueryServiceTest.java b/src/test/java/com/souzip/application/notice/NoticeQueryServiceTest.java new file mode 100644 index 00000000..b8a0ba2e --- /dev/null +++ b/src/test/java/com/souzip/application/notice/NoticeQueryServiceTest.java @@ -0,0 +1,250 @@ +package com.souzip.application.notice; + +import com.souzip.application.file.dto.FileResponse; +import com.souzip.application.file.provided.FileFinder; +import com.souzip.application.notice.dto.NoticeResponse; +import com.souzip.application.notice.required.NoticeRepository; +import com.souzip.domain.file.EntityType; +import com.souzip.domain.notice.Notice; +import com.souzip.domain.notice.NoticeRegisterRequest; +import com.souzip.domain.notice.NoticeStatus; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class NoticeQueryServiceTest { + + private static final UUID TEST_ADMIN_ID = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + + @Mock + private NoticeRepository noticeRepository; + + @Mock + private FileFinder fileFinder; + + @InjectMocks + private NoticeQueryService noticeQueryService; + + @DisplayName("ID로 공지사항을 조회한다") + @Test + void findById() { + Notice notice = createNotice(1L, "제목", "내용", NoticeStatus.ACTIVE); + + given(noticeRepository.findById(1L)).willReturn(Optional.of(notice)); + + Notice result = noticeQueryService.findById(1L); + + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getTitle()).isEqualTo("제목"); + assertThat(result.getContent()).isEqualTo("내용"); + } + + @DisplayName("존재하지 않는 공지사항 조회 시 예외가 발생한다") + @Test + void findById_notFound() { + given(noticeRepository.findById(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> noticeQueryService.findById(1L)) + .isInstanceOf(NoticeNotFoundException.class) + .hasMessageContaining("1"); + } + + @DisplayName("활성화된 공지사항 목록을 조회한다") + @Test + void findAllActive() { + Notice notice1 = createNotice(1L, "제목1", "내용1", NoticeStatus.ACTIVE); + Notice notice2 = createNotice(2L, "제목2", "내용2", NoticeStatus.ACTIVE); + List notices = List.of(notice1, notice2); + + given(noticeRepository.findByStatusOrderByCreatedAtDesc(NoticeStatus.ACTIVE)) + .willReturn(notices); + + List result = noticeQueryService.findAllActive(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getStatus()).isEqualTo(NoticeStatus.ACTIVE); + assertThat(result.get(1).getStatus()).isEqualTo(NoticeStatus.ACTIVE); + } + + @DisplayName("전체 공지사항 목록을 조회한다") + @Test + void findAll() { + Notice notice1 = createNotice(1L, "제목1", "내용1", NoticeStatus.ACTIVE); + Notice notice2 = createNotice(2L, "제목2", "내용2", NoticeStatus.INACTIVE); + List notices = List.of(notice1, notice2); + + given(noticeRepository.findAllByOrderByCreatedAtDesc()).willReturn(notices); + + List result = noticeQueryService.findAll(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getId()).isEqualTo(1L); + assertThat(result.get(1).getId()).isEqualTo(2L); + } + + @DisplayName("파일과 함께 공지사항을 조회한다") + @Test + void findByIdWithFiles() { + Notice notice = createNotice(1L, "제목", "내용", NoticeStatus.ACTIVE); + List files = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 2) + ); + + given(noticeRepository.findById(1L)).willReturn(Optional.of(notice)); + given(fileFinder.findFileResponsesByEntity(eq(EntityType.NOTICE), eq(1L))) + .willReturn(files); + + NoticeResponse result = noticeQueryService.findByIdWithFiles(1L); + + assertThat(result.id()).isEqualTo(1L); + assertThat(result.title()).isEqualTo("제목"); + assertThat(result.files()).hasSize(2); + assertThat(result.files().get(0).id()).isEqualTo(1L); + assertThat(result.files().get(1).id()).isEqualTo(2L); + } + + @DisplayName("파일이 없는 공지사항을 조회한다") + @Test + void findByIdWithFiles_noFiles() { + Notice notice = createNotice(1L, "제목", "내용", NoticeStatus.ACTIVE); + + given(noticeRepository.findById(1L)).willReturn(Optional.of(notice)); + given(fileFinder.findFileResponsesByEntity(eq(EntityType.NOTICE), eq(1L))) + .willReturn(List.of()); + + NoticeResponse result = noticeQueryService.findByIdWithFiles(1L); + + assertThat(result.id()).isEqualTo(1L); + assertThat(result.files()).isEmpty(); + } + + @DisplayName("활성 상태의 공지사항만 파일과 함께 조회한다") + @Test + void findActiveByIdWithFiles() { + Notice notice = createNotice(1L, "제목", "내용", NoticeStatus.ACTIVE); + List files = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1) + ); + + given(noticeRepository.findById(1L)).willReturn(Optional.of(notice)); + given(fileFinder.findFileResponsesByEntity(eq(EntityType.NOTICE), eq(1L))) + .willReturn(files); + + NoticeResponse result = noticeQueryService.findActiveByIdWithFiles(1L); + + assertThat(result.id()).isEqualTo(1L); + assertThat(result.title()).isEqualTo("제목"); + assertThat(result.status()).isEqualTo(NoticeStatus.ACTIVE); + assertThat(result.files()).hasSize(1); + } + + @DisplayName("비활성 상태의 공지사항 조회 시 예외가 발생한다") + @Test + void findActiveByIdWithFiles_inactiveNotice() { + Notice notice = createNotice(1L, "제목", "내용", NoticeStatus.INACTIVE); + + given(noticeRepository.findById(1L)).willReturn(Optional.of(notice)); + + assertThatThrownBy(() -> noticeQueryService.findActiveByIdWithFiles(1L)) + .isInstanceOf(NoticeNotFoundException.class) + .hasMessageContaining("1"); + } + + @DisplayName("파일과 함께 활성화된 공지사항 목록을 조회한다") + @Test + void findAllActiveWithFiles() { + Notice notice1 = createNotice(1L, "제목1", "내용1", NoticeStatus.ACTIVE); + Notice notice2 = createNotice(2L, "제목2", "내용2", NoticeStatus.ACTIVE); + List notices = List.of(notice1, notice2); + + List files1 = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1) + ); + Map> filesMap = Map.of( + 1L, files1, + 2L, List.of() + ); + + given(noticeRepository.findByStatusOrderByCreatedAtDesc(NoticeStatus.ACTIVE)) + .willReturn(notices); + given(fileFinder.findFilesByEntityIds(eq(EntityType.NOTICE), eq(List.of(1L, 2L)))) + .willReturn(filesMap); + + List result = noticeQueryService.findAllActiveWithFiles(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).id()).isEqualTo(1L); + assertThat(result.get(0).files()).hasSize(1); + assertThat(result.get(1).id()).isEqualTo(2L); + assertThat(result.get(1).files()).isEmpty(); + } + + @DisplayName("파일과 함께 전체 공지사항 목록을 조회한다") + @Test + void findAllWithFiles() { + Notice notice1 = createNotice(1L, "제목1", "내용1", NoticeStatus.ACTIVE); + Notice notice2 = createNotice(2L, "제목2", "내용2", NoticeStatus.INACTIVE); + List notices = List.of(notice1, notice2); + + List files1 = List.of( + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 1) + ); + List files2 = List.of( + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + ); + Map> filesMap = Map.of( + 1L, files1, + 2L, files2 + ); + + given(noticeRepository.findAllByOrderByCreatedAtDesc()).willReturn(notices); + given(fileFinder.findFilesByEntityIds(eq(EntityType.NOTICE), eq(List.of(1L, 2L)))) + .willReturn(filesMap); + + List result = noticeQueryService.findAllWithFiles(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).id()).isEqualTo(1L); + assertThat(result.get(0).files()).hasSize(1); + assertThat(result.get(1).id()).isEqualTo(2L); + assertThat(result.get(1).files()).hasSize(1); + } + + @DisplayName("공지사항 목록이 비어있으면 빈 리스트를 반환한다") + @Test + void findAllActiveWithFiles_emptyList() { + given(noticeRepository.findByStatusOrderByCreatedAtDesc(NoticeStatus.ACTIVE)) + .willReturn(List.of()); + + List result = noticeQueryService.findAllActiveWithFiles(); + + assertThat(result).isEmpty(); + } + + private Notice createNotice(Long id, String title, String content, NoticeStatus status) { + NoticeRegisterRequest request = NoticeRegisterRequest.of( + title, + content, + TEST_ADMIN_ID, + status + ); + Notice notice = Notice.register(request); + ReflectionTestUtils.setField(notice, "id", id); + return notice; + } +} diff --git a/src/test/java/com/souzip/docs/RestDocsSupport.java b/src/test/java/com/souzip/docs/RestDocsSupport.java index 6282f154..52646a1b 100644 --- a/src/test/java/com/souzip/docs/RestDocsSupport.java +++ b/src/test/java/com/souzip/docs/RestDocsSupport.java @@ -3,14 +3,22 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; import com.souzip.global.exception.GlobalExceptionHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.core.MethodParameter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.UUID; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; @@ -19,18 +27,38 @@ public abstract class RestDocsSupport { protected MockMvc mockMvc; protected ObjectMapper objectMapper = new ObjectMapper() - .registerModule(new JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + protected static final UUID TEST_ADMIN_ID = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); @BeforeEach void setUp(RestDocumentationContextProvider provider) { this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setControllerAdvice(new GlobalExceptionHandler()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .apply(documentationConfiguration(provider)) - .build(); + .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new CurrentAdminIdArgumentResolver()) + .apply(documentationConfiguration(provider)) + .build(); } - protected abstract Object initController(); + + private static class CurrentAdminIdArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentAdminId.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return TEST_ADMIN_ID; + } + } } diff --git a/src/test/java/com/souzip/domain/file/EntityTypeTest.java b/src/test/java/com/souzip/domain/file/EntityTypeTest.java new file mode 100644 index 00000000..2c7bc488 --- /dev/null +++ b/src/test/java/com/souzip/domain/file/EntityTypeTest.java @@ -0,0 +1,60 @@ +package com.souzip.domain.file; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EntityTypeTest { + + @DisplayName("문자열로부터 EntityType을 정상적으로 변환한다") + @ParameterizedTest + @CsvSource({ + "Notice, NOTICE", + "Souvenir, SOUVENIR" + }) + void from_success(String value, EntityType expected) { + // when + EntityType result = EntityType.from(value); + + // then + assertThat(result).isEqualTo(expected); + } + + @DisplayName("유효하지 않은 문자열은 예외가 발생한다") + @ParameterizedTest + @CsvSource({ + "INVALID", + "notice", + "NOTICE", + "Unknown", + "''" + }) + void from_invalid(String value) { + // when & then + assertThatThrownBy(() -> EntityType.from(value)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("getValue()는 저장된 문자열을 반환한다") + @Test + void getValue() { + // when & then + assertThat(EntityType.NOTICE.getValue()).isEqualTo("Notice"); + assertThat(EntityType.SOUVENIR.getValue()).isEqualTo("Souvenir"); + } + + @DisplayName("모든 EntityType이 정의되어 있다") + @Test + void all_types_defined() { + // when + EntityType[] values = EntityType.values(); + + // then + assertThat(values).hasSize(2); + assertThat(values).containsExactly(EntityType.NOTICE, EntityType.SOUVENIR); + } +} diff --git a/src/test/java/com/souzip/domain/file/FileTest.java b/src/test/java/com/souzip/domain/file/FileTest.java index 4fc3184d..f44c6123 100644 --- a/src/test/java/com/souzip/domain/file/FileTest.java +++ b/src/test/java/com/souzip/domain/file/FileTest.java @@ -24,7 +24,7 @@ void register() { File file = File.register(request); // then - assertThat(file.getEntityType()).isEqualTo("NOTICE"); + assertThat(file.getEntityType()).isEqualTo(EntityType.NOTICE); assertThat(file.getEntityId()).isEqualTo(1L); assertThat(file.getStorageKey()).isEqualTo("user123/uuid-1234.jpg"); assertThat(file.getOriginalName()).isEqualTo("photo.jpg"); @@ -56,32 +56,32 @@ private static Stream provideNullFieldCases() { ), arguments( "엔티티 ID가 null", - FileRegisterRequest.of("NOTICE", null, "key", "name", 1024L, "type", 1), + FileRegisterRequest.of(EntityType.NOTICE, null, "key", "name", 1024L, "type", 1), "엔티티 ID는 필수입니다." ), arguments( "스토리지 키가 null", - FileRegisterRequest.of("NOTICE", 1L, null, "name", 1024L, "type", 1), + FileRegisterRequest.of(EntityType.NOTICE, 1L, null, "name", 1024L, "type", 1), "스토리지 키는 필수입니다." ), arguments( "파일명이 null", - FileRegisterRequest.of("NOTICE", 1L, "key", null, 1024L, "type", 1), + FileRegisterRequest.of(EntityType.NOTICE, 1L, "key", null, 1024L, "type", 1), "파일명은 필수입니다." ), arguments( "파일 크기가 null", - FileRegisterRequest.of("NOTICE", 1L, "key", "name", null, "type", 1), + FileRegisterRequest.of(EntityType.NOTICE, 1L, "key", "name", null, "type", 1), "파일 크기는 필수입니다." ), arguments( "파일 타입이 null", - FileRegisterRequest.of("NOTICE", 1L, "key", "name", 1024L, null, 1), + FileRegisterRequest.of(EntityType.NOTICE, 1L, "key", "name", 1024L, null, 1), "파일 타입은 필수입니다." ), arguments( "정렬 순서가 null", - FileRegisterRequest.of("NOTICE", 1L, "key", "name", 1024L, "type", null), + FileRegisterRequest.of(EntityType.NOTICE, 1L, "key", "name", 1024L, "type", null), "정렬 순서는 필수입니다." ) ); @@ -89,7 +89,7 @@ private static Stream provideNullFieldCases() { private FileRegisterRequest createValidRequest() { return FileRegisterRequest.of( - "NOTICE", + EntityType.NOTICE, 1L, "user123/uuid-1234.jpg", "photo.jpg", diff --git a/src/test/java/com/souzip/domain/location/LocationTest.java b/src/test/java/com/souzip/domain/location/LocationTest.java index 8d4203e6..4e47cfae 100644 --- a/src/test/java/com/souzip/domain/location/LocationTest.java +++ b/src/test/java/com/souzip/domain/location/LocationTest.java @@ -22,8 +22,8 @@ void create() { assertThat(location.getName()).isEqualTo("강남역"); assertThat(location.getAddress()).isEqualTo("서울특별시 강남구 역삼동 825"); assertThat(location.getCoordinate()).isNotNull(); - assertThat(location.getCoordinate().latitude()).isEqualTo(BigDecimal.valueOf(37.4979)); - assertThat(location.getCoordinate().longitude()).isEqualTo(BigDecimal.valueOf(127.0276)); + assertThat(location.getCoordinate().getLatitude()).isEqualTo(BigDecimal.valueOf(37.4979)); + assertThat(location.getCoordinate().getLongitude()).isEqualTo(BigDecimal.valueOf(127.0276)); } @Test diff --git a/src/test/java/com/souzip/domain/notice/NoticeTest.java b/src/test/java/com/souzip/domain/notice/NoticeTest.java new file mode 100644 index 00000000..d4220679 --- /dev/null +++ b/src/test/java/com/souzip/domain/notice/NoticeTest.java @@ -0,0 +1,99 @@ +package com.souzip.domain.notice; + +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +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 java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class NoticeTest { + + private static final UUID TEST_ADMIN_ID = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + + @DisplayName("공지사항을 정상적으로 등록한다") + @Test + void register() { + // given + NoticeRegisterRequest request = NoticeRegisterRequest.of( + "공지사항 제목", + "공지사항 내용", + TEST_ADMIN_ID, + NoticeStatus.ACTIVE + ); + + // when + Notice notice = Notice.register(request); + + // then + assertThat(notice.getTitle()).isEqualTo("공지사항 제목"); + assertThat(notice.getContent()).isEqualTo("공지사항 내용"); + assertThat(notice.getAuthorId()).isEqualTo(TEST_ADMIN_ID); + assertThat(notice.getStatus()).isEqualTo(NoticeStatus.ACTIVE); + } + + @DisplayName("공지사항을 수정한다") + @Test + void update() { + // given + Notice notice = createNotice(); + NoticeUpdateRequest request = NoticeUpdateRequest.of( + "수정된 제목", + "수정된 내용", + NoticeStatus.ACTIVE + ); + + // when + notice.update(request); + + // then + assertThat(notice.getTitle()).isEqualTo("수정된 제목"); + assertThat(notice.getContent()).isEqualTo("수정된 내용"); + assertThat(notice.getStatus()).isEqualTo(NoticeStatus.ACTIVE); + } + + @DisplayName("공지사항을 활성화한다") + @Test + void activate() { + // given + Notice notice = createNotice(); + + // when + notice.activate(); + + // then + assertThat(notice.getStatus()).isEqualTo(NoticeStatus.ACTIVE); + assertThat(notice.isActive()).isTrue(); + } + + @DisplayName("공지사항을 비활성화한다") + @Test + void deactivate() { + // given + Notice notice = createNotice(); + notice.activate(); + + // when + notice.deactivate(); + + // then + assertThat(notice.getStatus()).isEqualTo(NoticeStatus.INACTIVE); + assertThat(notice.isActive()).isFalse(); + } + + private Notice createNotice() { + NoticeRegisterRequest request = NoticeRegisterRequest.of( + "공지사항 제목", + "공지사항 내용", + TEST_ADMIN_ID, + NoticeStatus.INACTIVE + ); + return Notice.register(request); + } +}