From 9545fdb67060afd3eafdd7274ba565671fdfc21b Mon Sep 17 00:00:00 2001 From: Minseok Kim <77945998+msk226@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:35:41 +0900 Subject: [PATCH 001/126] =?UTF-8?q?[Docs]=20README.md=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..f90b891b --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# ๐Ÿ“šย ์Šคํ„ฐ๋”” ๋งค์นญ ๋ฐ ๊ด€๋ฆฌ ํ”Œ๋žซํผ, ์ŠคํŒŸ + +> **๊ธฐ๊ฐ„**: 2025.09 - ์ง„ํ–‰ ์ค‘ +> + +> **ํŒ€ ๊ตฌ์„ฑ**: P.O 1๋ช…, ๋””์ž์ธ 1๋ช…, AOS 2๋ช…, ๋ฐฑ์—”๋“œ 1๋ช… +> + +> **์—ญํ• **: ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ, ์ธํ”„๋ผ ๊ด€๋ฆฌ (๊ธฐ์—ฌ๋„ 100%) +> + +--- + +Logo + + +--- + +# ๐Ÿ“ ํ”„๋กœ์ ํŠธ ๊ฐœ์š” + +- ์Šคํ„ฐ๋”” ๋งค์นญ ๋ฐ ๊ด€๋ฆฌ ํ”Œ๋žซํผ +- ํ˜„์žฌ Ver.1 ์ถœ์‹œ ์™„๋ฃŒ, Ver.2 ๊ฐœ๋ฐœ ์ง„ํ–‰ ์ค‘ + +--- + +# ๐Ÿ›  ์ฃผ์š” ๊ธฐ์ˆ  ์Šคํƒ ๋ฐ ์•„ํ‚คํ…์ฒ˜ + +### ๋ชจ๋“ˆ๋Ÿฌ ๋ชจ๋†€๋ฆฌ์‹ (Modular Monolith) ์•„ํ‚คํ…์ฒ˜ + +**โ–ผ ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ฐœ์š”** + +image + + +**โ–ผ ๋ชจ๋“ˆ ๊ฐ„ ์†Œํ†ต ๋ฐฉ๋ฒ•** + +image + + +- ๋„๋ฉ”์ธ ๊ฒฉ๋ฆฌ: + - ๋ชจ๋“ˆ ๊ฐ„ ํด๋ž˜์Šค/์—”ํ‹ฐํ‹ฐ ์ง์ ‘ ์ฐธ์กฐ ๊ธˆ์ง€ +- DB ๊ฒฉ๋ฆฌ: + - ๋„๋ฉ”์ธ ๊ฐ„ JOIN ๊ธˆ์ง€, ํƒ€ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ๋Š” ID(PK)๋กœ๋งŒ ๊ฐ„์ ‘ ์ฐธ์กฐ + +--- + +### ์ธํ”„๋ผ ์•„ํ‚คํ…์ฒ˜ ๋‹ค์ด์–ด๊ทธ๋žจ +แ„‰แ…ณแ„แ…ณแ„…แ…ตแ†ซแ„‰แ…ฃแ†บ 2025-12-26 แ„‹แ…ฉแ„’แ…ฎ 4 43 29 + From 3b98320b1c8eb02c75f23ada6f331d1e57a5fac2 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 14:49:30 +0900 Subject: [PATCH 002/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/kr/spot/domain/Schedule.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/domain/Schedule.java diff --git a/modules/study/src/main/java/kr/spot/domain/Schedule.java b/modules/study/src/main/java/kr/spot/domain/Schedule.java new file mode 100644 index 00000000..f0aafc31 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/domain/Schedule.java @@ -0,0 +1,42 @@ +package kr.spot.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE schedule SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Schedule extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + @Column(nullable = false) + private String title; + + private String locationMemo; + + private LocalDateTime startAt; + + private LocalDateTime endAt; + + public static Schedule of(Long id, Long studyId, String title, String locationMemo, + LocalDateTime startAt, LocalDateTime endAt + ) { + return new Schedule(id, studyId, title, locationMemo, startAt, endAt + ); + } +} From 01a63bc8bd7a688d5f23f6c664b2cdb9b09e330c Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 14:55:05 +0900 Subject: [PATCH 003/126] =?UTF-8?q?[Refactor]=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EB=AA=A8=EB=93=88=20=EB=82=B4,=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=8F=84=EB=A9=94=EC=9D=B8=EB=93=A4=EA=B3=BC=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/{ => schedule}/domain/Schedule.java | 3 +- .../command/ApplyStudyService.java | 16 +++++----- .../command/CreateStudyService.java | 32 +++++++++---------- .../application/event/StudyCreatedEvent.java | 2 +- .../event/StudyImageUploadListener.java | 6 ++-- .../application/mapper/StudyDTOMapper.java | 8 ++--- .../query/GetMyStudyInfoService.java | 20 ++++++------ .../query/GetStudyApplicationService.java | 20 ++++++------ .../query/port/CategoryCatalogService.java | 4 +-- .../validator/StudyAccessValidator.java | 10 +++--- .../kr/spot/{ => study}/domain/Region.java | 2 +- .../kr/spot/{ => study}/domain/Study.java | 12 +++---- .../domain/associations/StudyCategory.java | 4 +-- .../domain/associations/StudyMember.java | 6 ++-- .../domain/associations/StudyRegion.java | 2 +- .../domain/associations/StudyStats.java | 2 +- .../domain/associations/StudyStyle.java | 4 +-- .../{ => study}/domain/enums/Category.java | 2 +- .../{ => study}/domain/enums/Decision.java | 2 +- .../{ => study}/domain/enums/FeeCategory.java | 2 +- .../domain/enums/RecruitingStatus.java | 2 +- .../spot/{ => study}/domain/enums/SortBy.java | 2 +- .../domain/enums/StudyMemberStatus.java | 2 +- .../spot/{ => study}/domain/enums/Style.java | 2 +- .../kr/spot/{ => study}/domain/vo/Fee.java | 4 +-- .../infrastructure/jpa/RegionRepository.java | 6 ++-- .../jpa/StudyMemberRepositoryCustom.java | 8 ++--- .../infrastructure/jpa/StudyRepository.java | 4 +-- .../associations/StudyCategoryRepository.java | 4 +-- .../associations/StudyMemberRepository.java | 6 ++-- .../associations/StudyRegionRepository.java | 6 ++-- .../associations/StudyStatsRepository.java | 6 ++-- .../associations/StudyStyleRepository.java | 6 ++-- .../StudyMemberRepositoryCustomImpl.java | 10 +++--- .../jpa/querydsl/StudyQueryRepository.java | 16 +++++----- .../querydsl/dto/StudyApplicationInfo.java | 2 +- .../command/StudyApplyCommandController.java | 8 ++--- .../command/StudyCommandController.java | 6 ++-- .../dto/request/ApplyStudyRequest.java | 2 +- .../dto/request/CreateStudyRequest.java | 6 ++-- .../query/StudyApplyQueryController.java | 8 ++--- .../query/StudyQueryController.java | 16 +++++----- .../request/StudySearchRequestForEqual.java | 8 ++--- .../request/StudySearchRequestForRange.java | 6 ++-- .../dto/response/GetAppliesResponse.java | 2 +- .../response/GetMyAppliedStudyResponse.java | 2 +- .../response/GetStudyOverviewResponse.java | 2 +- .../command/ApplyStudyServiceTest.java | 15 +++++---- .../command/CreateStudyServiceTest.java | 23 ++++++------- .../event/StudyCreatedEventTest.java | 1 + .../event/StudyImageUploadListenerTest.java | 8 +++-- .../query/GetMyStudyInfoServiceTest.java | 17 +++++----- .../query/GetStudyApplicationServiceTest.java | 16 +++++----- .../spot/common/ApplyStudyRequestFixture.java | 2 +- .../common/StudyApplicationInfoFixture.java | 2 +- .../java/kr/spot/common/StudyFixture.java | 4 +-- .../kr/spot/common/StudyMemberFixture.java | 6 ++-- .../java/kr/spot/domain/StudyMemberTest.java | 6 ++-- .../test/java/kr/spot/domain/StudyTest.java | 11 ++++--- .../test/java/kr/spot/domain/vo/FeeTest.java | 1 + 60 files changed, 216 insertions(+), 207 deletions(-) rename modules/study/src/main/java/kr/spot/{ => schedule}/domain/Schedule.java (93%) rename modules/study/src/main/java/kr/spot/{ => study}/application/command/ApplyStudyService.java (83%) rename modules/study/src/main/java/kr/spot/{ => study}/application/command/CreateStudyService.java (72%) rename modules/study/src/main/java/kr/spot/{ => study}/application/event/StudyCreatedEvent.java (90%) rename modules/study/src/main/java/kr/spot/{ => study}/application/event/StudyImageUploadListener.java (91%) rename modules/study/src/main/java/kr/spot/{ => study}/application/mapper/StudyDTOMapper.java (72%) rename modules/study/src/main/java/kr/spot/{ => study}/application/query/GetMyStudyInfoService.java (88%) rename modules/study/src/main/java/kr/spot/{ => study}/application/query/GetStudyApplicationService.java (77%) rename modules/study/src/main/java/kr/spot/{ => study}/application/query/port/CategoryCatalogService.java (80%) rename modules/study/src/main/java/kr/spot/{ => study}/application/validator/StudyAccessValidator.java (77%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/Region.java (96%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/Study.java (92%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyCategory.java (91%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyMember.java (93%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyRegion.java (94%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyStats.java (94%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyStyle.java (91%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/Category.java (96%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/Decision.java (55%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/FeeCategory.java (95%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/RecruitingStatus.java (66%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/SortBy.java (58%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/StudyMemberStatus.java (94%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/Style.java (93%) rename modules/study/src/main/java/kr/spot/{ => study}/domain/vo/Fee.java (92%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/RegionRepository.java (63%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/StudyMemberRepositoryCustom.java (56%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/StudyRepository.java (83%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyCategoryRepository.java (57%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyMemberRepository.java (78%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyRegionRepository.java (57%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyStatsRepository.java (57%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyStyleRepository.java (57%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java (84%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/querydsl/StudyQueryRepository.java (95%) rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java (81%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/StudyApplyCommandController.java (94%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/StudyCommandController.java (93%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/dto/request/ApplyStudyRequest.java (51%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/dto/request/CreateStudyRequest.java (64%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/StudyApplyQueryController.java (90%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/StudyQueryController.java (92%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/request/StudySearchRequestForEqual.java (50%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/request/StudySearchRequestForRange.java (58%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/response/GetAppliesResponse.java (91%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/response/GetMyAppliedStudyResponse.java (91%) rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/response/GetStudyOverviewResponse.java (95%) diff --git a/modules/study/src/main/java/kr/spot/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java similarity index 93% rename from modules/study/src/main/java/kr/spot/domain/Schedule.java rename to modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java index f0aafc31..d7431104 100644 --- a/modules/study/src/main/java/kr/spot/domain/Schedule.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java @@ -1,9 +1,10 @@ -package kr.spot.domain; +package kr.spot.schedule.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import java.time.LocalDateTime; +import kr.spot.domain.BaseEntity; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/application/command/ApplyStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java similarity index 83% rename from modules/study/src/main/java/kr/spot/application/command/ApplyStudyService.java rename to modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java index bfa6f100..df7136dc 100644 --- a/modules/study/src/main/java/kr/spot/application/command/ApplyStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java @@ -1,16 +1,16 @@ -package kr.spot.application.command; +package kr.spot.study.application.command; import java.util.List; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.Study; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; -import kr.spot.presentation.command.dto.request.ApplyStudyRequest; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/modules/study/src/main/java/kr/spot/application/command/CreateStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java similarity index 72% rename from modules/study/src/main/java/kr/spot/application/command/CreateStudyService.java rename to modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java index 423039f1..7a7f03ef 100644 --- a/modules/study/src/main/java/kr/spot/application/command/CreateStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java @@ -1,21 +1,21 @@ -package kr.spot.application.command; +package kr.spot.study.application.command; import kr.spot.IdGenerator; -import kr.spot.domain.Study; -import kr.spot.domain.associations.StudyCategory; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.associations.StudyRegion; -import kr.spot.domain.associations.StudyStats; -import kr.spot.domain.associations.StudyStyle; -import kr.spot.domain.vo.Fee; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyCategoryRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; -import kr.spot.infrastructure.jpa.associations.StudyRegionRepository; -import kr.spot.infrastructure.jpa.associations.StudyStatsRepository; -import kr.spot.infrastructure.jpa.associations.StudyStyleRepository; -import kr.spot.application.event.StudyCreatedEvent; -import kr.spot.presentation.command.dto.request.CreateStudyRequest; +import kr.spot.study.application.event.StudyCreatedEvent; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyCategory; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.associations.StudyRegion; +import kr.spot.study.domain.associations.StudyStats; +import kr.spot.study.domain.associations.StudyStyle; +import kr.spot.study.domain.vo.Fee; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyCategoryRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyRegionRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStyleRepository; +import kr.spot.study.presentation.command.dto.request.CreateStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; diff --git a/modules/study/src/main/java/kr/spot/application/event/StudyCreatedEvent.java b/modules/study/src/main/java/kr/spot/study/application/event/StudyCreatedEvent.java similarity index 90% rename from modules/study/src/main/java/kr/spot/application/event/StudyCreatedEvent.java rename to modules/study/src/main/java/kr/spot/study/application/event/StudyCreatedEvent.java index 162ea21b..61000796 100644 --- a/modules/study/src/main/java/kr/spot/application/event/StudyCreatedEvent.java +++ b/modules/study/src/main/java/kr/spot/study/application/event/StudyCreatedEvent.java @@ -1,4 +1,4 @@ -package kr.spot.application.event; +package kr.spot.study.application.event; import org.springframework.web.multipart.MultipartFile; diff --git a/modules/study/src/main/java/kr/spot/application/event/StudyImageUploadListener.java b/modules/study/src/main/java/kr/spot/study/application/event/StudyImageUploadListener.java similarity index 91% rename from modules/study/src/main/java/kr/spot/application/event/StudyImageUploadListener.java rename to modules/study/src/main/java/kr/spot/study/application/event/StudyImageUploadListener.java index 9680fd0c..22c9de29 100644 --- a/modules/study/src/main/java/kr/spot/application/event/StudyImageUploadListener.java +++ b/modules/study/src/main/java/kr/spot/study/application/event/StudyImageUploadListener.java @@ -1,9 +1,9 @@ -package kr.spot.application.event; +package kr.spot.study.application.event; -import kr.spot.domain.Study; -import kr.spot.infrastructure.jpa.StudyRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.dto.UploadResult; +import kr.spot.study.domain.Study; +import kr.spot.study.infrastructure.jpa.StudyRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; diff --git a/modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java similarity index 72% rename from modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java rename to modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java index eb483cf3..97c37fc9 100644 --- a/modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java +++ b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java @@ -1,9 +1,9 @@ -package kr.spot.application.mapper; +package kr.spot.study.application.mapper; import java.util.List; -import kr.spot.domain.Study; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse.StudyOverview; +import kr.spot.study.domain.Study; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse.StudyOverview; public class StudyDTOMapper { diff --git a/modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java similarity index 88% rename from modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java rename to modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java index 18084b09..d5e4f127 100644 --- a/modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -1,17 +1,17 @@ -package kr.spot.application.query; +package kr.spot.study.application.query; import java.util.List; -import kr.spot.application.mapper.StudyDTOMapper; -import kr.spot.domain.Study; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.FeeCategory; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.SortBy; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.querydsl.StudyQueryRepository; import kr.spot.ports.GetPreferredCategoryPort; import kr.spot.ports.GetPreferredRegionPort; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse; +import kr.spot.study.application.mapper.StudyDTOMapper; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.SortBy; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.querydsl.StudyQueryRepository; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/modules/study/src/main/java/kr/spot/application/query/GetStudyApplicationService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyApplicationService.java similarity index 77% rename from modules/study/src/main/java/kr/spot/application/query/GetStudyApplicationService.java rename to modules/study/src/main/java/kr/spot/study/application/query/GetStudyApplicationService.java index 00b5fc25..19d997f3 100644 --- a/modules/study/src/main/java/kr/spot/application/query/GetStudyApplicationService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyApplicationService.java @@ -1,18 +1,18 @@ -package kr.spot.application.query; +package kr.spot.study.application.query; import java.util.List; import java.util.Map; -import kr.spot.application.validator.StudyAccessValidator; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.StudyMemberRepositoryCustom; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; import kr.spot.ports.GetMemberInfoPort; import kr.spot.ports.dto.MemberInfoResponse; -import kr.spot.presentation.query.dto.response.GetAppliesResponse; -import kr.spot.presentation.query.dto.response.GetAppliesResponse.Apply; -import kr.spot.presentation.query.dto.response.GetMyAppliedStudyResponse; -import kr.spot.presentation.query.dto.response.GetMyAppliedStudyResponse.MyAppliedStudy; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.presentation.query.dto.response.GetAppliesResponse; +import kr.spot.study.presentation.query.dto.response.GetAppliesResponse.Apply; +import kr.spot.study.presentation.query.dto.response.GetMyAppliedStudyResponse; +import kr.spot.study.presentation.query.dto.response.GetMyAppliedStudyResponse.MyAppliedStudy; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/modules/study/src/main/java/kr/spot/application/query/port/CategoryCatalogService.java b/modules/study/src/main/java/kr/spot/study/application/query/port/CategoryCatalogService.java similarity index 80% rename from modules/study/src/main/java/kr/spot/application/query/port/CategoryCatalogService.java rename to modules/study/src/main/java/kr/spot/study/application/query/port/CategoryCatalogService.java index 28536120..64b81d03 100644 --- a/modules/study/src/main/java/kr/spot/application/query/port/CategoryCatalogService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/port/CategoryCatalogService.java @@ -1,7 +1,7 @@ -package kr.spot.application.query.port; +package kr.spot.study.application.query.port; -import kr.spot.domain.enums.Category; import kr.spot.ports.CategoryCatalogPort; +import kr.spot.study.domain.enums.Category; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/modules/study/src/main/java/kr/spot/application/validator/StudyAccessValidator.java b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java similarity index 77% rename from modules/study/src/main/java/kr/spot/application/validator/StudyAccessValidator.java rename to modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java index e3a8eaba..c5f4b40c 100644 --- a/modules/study/src/main/java/kr/spot/application/validator/StudyAccessValidator.java +++ b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java @@ -1,12 +1,12 @@ -package kr.spot.application.validator; +package kr.spot.study.application.validator; import java.util.List; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.Study; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/modules/study/src/main/java/kr/spot/domain/Region.java b/modules/study/src/main/java/kr/spot/study/domain/Region.java similarity index 96% rename from modules/study/src/main/java/kr/spot/domain/Region.java rename to modules/study/src/main/java/kr/spot/study/domain/Region.java index efe867aa..98dc1fb1 100644 --- a/modules/study/src/main/java/kr/spot/domain/Region.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Region.java @@ -1,4 +1,4 @@ -package kr.spot.domain; +package kr.spot.study.domain; import jakarta.persistence.Entity; import jakarta.persistence.Id; diff --git a/modules/study/src/main/java/kr/spot/domain/Study.java b/modules/study/src/main/java/kr/spot/study/domain/Study.java similarity index 92% rename from modules/study/src/main/java/kr/spot/domain/Study.java rename to modules/study/src/main/java/kr/spot/study/domain/Study.java index fc973d79..522ad61d 100644 --- a/modules/study/src/main/java/kr/spot/domain/Study.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Study.java @@ -1,4 +1,4 @@ -package kr.spot.domain; +package kr.spot.study.domain; import io.micrometer.common.util.StringUtils; import jakarta.persistence.Column; @@ -8,12 +8,12 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.domain.vo.Fee; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.domain.vo.Fee; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyCategory.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyCategory.java similarity index 91% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyCategory.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyCategory.java index 9e9cc183..0bb12d8e 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyCategory.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyCategory.java @@ -1,11 +1,11 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import kr.spot.domain.BaseEntity; -import kr.spot.domain.enums.Category; +import kr.spot.study.domain.enums.Category; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyMember.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java similarity index 93% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyMember.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java index b9af2495..93b743c6 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyMember.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java @@ -1,4 +1,4 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -6,9 +6,9 @@ import jakarta.persistence.Id; import kr.spot.code.status.ErrorStatus; import kr.spot.domain.BaseEntity; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.StudyMemberStatus; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyRegion.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyRegion.java similarity index 94% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyRegion.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyRegion.java index 973253fa..8e36bdab 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyRegion.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyRegion.java @@ -1,4 +1,4 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.Id; diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStats.java similarity index 94% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyStats.java index 29de3c49..f5773615 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStats.java @@ -1,4 +1,4 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.Id; diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyStyle.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStyle.java similarity index 91% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyStyle.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyStyle.java index dcd86133..b60e8802 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyStyle.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStyle.java @@ -1,11 +1,11 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import kr.spot.domain.BaseEntity; -import kr.spot.domain.enums.Style; +import kr.spot.study.domain.enums.Style; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/domain/enums/Category.java b/modules/study/src/main/java/kr/spot/study/domain/enums/Category.java similarity index 96% rename from modules/study/src/main/java/kr/spot/domain/enums/Category.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/Category.java index b826bf13..e7fc42e3 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/Category.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/Category.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; diff --git a/modules/study/src/main/java/kr/spot/domain/enums/Decision.java b/modules/study/src/main/java/kr/spot/study/domain/enums/Decision.java similarity index 55% rename from modules/study/src/main/java/kr/spot/domain/enums/Decision.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/Decision.java index b92d379e..710b542a 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/Decision.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/Decision.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum Decision { APPROVE, REJECT diff --git a/modules/study/src/main/java/kr/spot/domain/enums/FeeCategory.java b/modules/study/src/main/java/kr/spot/study/domain/enums/FeeCategory.java similarity index 95% rename from modules/study/src/main/java/kr/spot/domain/enums/FeeCategory.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/FeeCategory.java index ff6f139b..10be2cc8 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/FeeCategory.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/FeeCategory.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum FeeCategory { diff --git a/modules/study/src/main/java/kr/spot/domain/enums/RecruitingStatus.java b/modules/study/src/main/java/kr/spot/study/domain/enums/RecruitingStatus.java similarity index 66% rename from modules/study/src/main/java/kr/spot/domain/enums/RecruitingStatus.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/RecruitingStatus.java index eed20776..cd1b7549 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/RecruitingStatus.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/RecruitingStatus.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum RecruitingStatus { diff --git a/modules/study/src/main/java/kr/spot/domain/enums/SortBy.java b/modules/study/src/main/java/kr/spot/study/domain/enums/SortBy.java similarity index 58% rename from modules/study/src/main/java/kr/spot/domain/enums/SortBy.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/SortBy.java index ef59bb27..64e35a49 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/SortBy.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/SortBy.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum SortBy { diff --git a/modules/study/src/main/java/kr/spot/domain/enums/StudyMemberStatus.java b/modules/study/src/main/java/kr/spot/study/domain/enums/StudyMemberStatus.java similarity index 94% rename from modules/study/src/main/java/kr/spot/domain/enums/StudyMemberStatus.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/StudyMemberStatus.java index 31705f74..f531523c 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/StudyMemberStatus.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/StudyMemberStatus.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; diff --git a/modules/study/src/main/java/kr/spot/domain/enums/Style.java b/modules/study/src/main/java/kr/spot/study/domain/enums/Style.java similarity index 93% rename from modules/study/src/main/java/kr/spot/domain/enums/Style.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/Style.java index bb9f492d..58d21ec9 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/Style.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/Style.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum Style { diff --git a/modules/study/src/main/java/kr/spot/domain/vo/Fee.java b/modules/study/src/main/java/kr/spot/study/domain/vo/Fee.java similarity index 92% rename from modules/study/src/main/java/kr/spot/domain/vo/Fee.java rename to modules/study/src/main/java/kr/spot/study/domain/vo/Fee.java index 3d126fce..3cc781f9 100644 --- a/modules/study/src/main/java/kr/spot/domain/vo/Fee.java +++ b/modules/study/src/main/java/kr/spot/study/domain/vo/Fee.java @@ -1,11 +1,11 @@ -package kr.spot.domain.vo; +package kr.spot.study.domain.vo; import jakarta.persistence.Embeddable; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.enums.FeeCategory; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.enums.FeeCategory; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java similarity index 63% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java index 84cbf4c2..fa6e2772 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java @@ -1,8 +1,8 @@ -package kr.spot.infrastructure.jpa; +package kr.spot.study.infrastructure.jpa; -import kr.spot.domain.Region; +import kr.spot.study.domain.Region; import org.springframework.data.jpa.repository.JpaRepository; public interface RegionRepository extends JpaRepository { - + } diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyMemberRepositoryCustom.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyMemberRepositoryCustom.java similarity index 56% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyMemberRepositoryCustom.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyMemberRepositoryCustom.java index 9d7eee16..361468bf 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyMemberRepositoryCustom.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyMemberRepositoryCustom.java @@ -1,9 +1,9 @@ -package kr.spot.infrastructure.jpa; +package kr.spot.study.infrastructure.jpa; import java.util.List; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; public interface StudyMemberRepositoryCustom { diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java similarity index 83% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java index 99dda341..ec9bbb0f 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java @@ -1,8 +1,8 @@ -package kr.spot.infrastructure.jpa; +package kr.spot.study.infrastructure.jpa; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.Study; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.Study; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyRepository extends JpaRepository { diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java similarity index 57% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java index 5e8bb5ba..32040953 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java @@ -1,6 +1,6 @@ -package kr.spot.infrastructure.jpa.associations; +package kr.spot.study.infrastructure.jpa.associations; -import kr.spot.domain.associations.StudyCategory; +import kr.spot.study.domain.associations.StudyCategory; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyCategoryRepository extends JpaRepository { diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java similarity index 78% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java index 15450ee7..76649a6f 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -1,10 +1,10 @@ -package kr.spot.infrastructure.jpa.associations; +package kr.spot.study.infrastructure.jpa.associations; import java.util.List; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyMemberRepository extends JpaRepository { diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyRegionRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyRegionRepository.java similarity index 57% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyRegionRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyRegionRepository.java index 40924b8b..b73f272d 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyRegionRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyRegionRepository.java @@ -1,8 +1,8 @@ -package kr.spot.infrastructure.jpa.associations; +package kr.spot.study.infrastructure.jpa.associations; -import kr.spot.domain.associations.StudyRegion; +import kr.spot.study.domain.associations.StudyRegion; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyRegionRepository extends JpaRepository { - + } diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java similarity index 57% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java index a2a4ba68..42d3e873 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java @@ -1,8 +1,8 @@ -package kr.spot.infrastructure.jpa.associations; +package kr.spot.study.infrastructure.jpa.associations; -import kr.spot.domain.associations.StudyStats; +import kr.spot.study.domain.associations.StudyStats; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyStatsRepository extends JpaRepository { - + } diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStyleRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStyleRepository.java similarity index 57% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStyleRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStyleRepository.java index 87df8b93..bc24930d 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStyleRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStyleRepository.java @@ -1,8 +1,8 @@ -package kr.spot.infrastructure.jpa.associations; +package kr.spot.study.infrastructure.jpa.associations; -import kr.spot.domain.associations.StudyStyle; +import kr.spot.study.domain.associations.StudyStyle; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyStyleRepository extends JpaRepository { - + } diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java similarity index 84% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java index 7269a737..ca9ec0c6 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java @@ -1,14 +1,14 @@ -package kr.spot.infrastructure.jpa.querydsl; +package kr.spot.study.infrastructure.jpa.querydsl; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import kr.spot.domain.QStudy; import kr.spot.domain.associations.QStudyMember; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.StudyMemberRepositoryCustom; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java similarity index 95% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java index 5754bf44..9f94a210 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java @@ -1,27 +1,27 @@ -package kr.spot.infrastructure.jpa.querydsl; +package kr.spot.study.infrastructure.jpa.querydsl; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import kr.spot.domain.QStudy; -import kr.spot.domain.Study; import kr.spot.domain.associations.QStudyCategory; import kr.spot.domain.associations.QStudyMember; import kr.spot.domain.associations.QStudyRegion; import kr.spot.domain.associations.QStudyStats; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.FeeCategory; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.SortBy; -import kr.spot.domain.enums.StudyMemberStatus; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.SortBy; +import kr.spot.study.domain.enums.StudyMemberStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class StudyQueryRepository { - + private final JPAQueryFactory query; public List findMyStudies( diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java similarity index 81% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java index 1c76ed4f..a122b3f0 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java @@ -1,4 +1,4 @@ -package kr.spot.infrastructure.jpa.querydsl.dto; +package kr.spot.study.infrastructure.jpa.querydsl.dto; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/presentation/command/StudyApplyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java similarity index 94% rename from modules/study/src/main/java/kr/spot/presentation/command/StudyApplyCommandController.java rename to modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java index 18dc9a32..9531b206 100644 --- a/modules/study/src/main/java/kr/spot/presentation/command/StudyApplyCommandController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.command; +package kr.spot.study.presentation.command; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -7,10 +7,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; -import kr.spot.application.command.ApplyStudyService; import kr.spot.code.status.SuccessStatus; -import kr.spot.domain.enums.Decision; -import kr.spot.presentation.command.dto.request.ApplyStudyRequest; +import kr.spot.study.application.command.ApplyStudyService; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; diff --git a/modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java similarity index 93% rename from modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java rename to modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java index e2f7ba8c..41df1348 100644 --- a/modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.command; +package kr.spot.study.presentation.command; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -8,9 +8,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; -import kr.spot.application.command.CreateStudyService; import kr.spot.code.status.SuccessStatus; -import kr.spot.presentation.command.dto.request.CreateStudyRequest; +import kr.spot.study.application.command.CreateStudyService; +import kr.spot.study.presentation.command.dto.request.CreateStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; diff --git a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java similarity index 51% rename from modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java rename to modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java index 170e3659..68ef6d8a 100644 --- a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.command.dto.request; +package kr.spot.study.presentation.command.dto.request; public record ApplyStudyRequest( String message diff --git a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/CreateStudyRequest.java b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java similarity index 64% rename from modules/study/src/main/java/kr/spot/presentation/command/dto/request/CreateStudyRequest.java rename to modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java index 815d3de6..551eba53 100644 --- a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/CreateStudyRequest.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java @@ -1,8 +1,8 @@ -package kr.spot.presentation.command.dto.request; +package kr.spot.study.presentation.command.dto.request; import java.util.Set; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.Style; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.Style; public record CreateStudyRequest( String name, diff --git a/modules/study/src/main/java/kr/spot/presentation/query/StudyApplyQueryController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyApplyQueryController.java similarity index 90% rename from modules/study/src/main/java/kr/spot/presentation/query/StudyApplyQueryController.java rename to modules/study/src/main/java/kr/spot/study/presentation/query/StudyApplyQueryController.java index c7b97dcc..77bc60bf 100644 --- a/modules/study/src/main/java/kr/spot/presentation/query/StudyApplyQueryController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyApplyQueryController.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.query; +package kr.spot.study.presentation.query; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -9,10 +9,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; -import kr.spot.application.query.GetStudyApplicationService; import kr.spot.code.status.SuccessStatus; -import kr.spot.presentation.query.dto.response.GetAppliesResponse; -import kr.spot.presentation.query.dto.response.GetMyAppliedStudyResponse; +import kr.spot.study.application.query.GetStudyApplicationService; +import kr.spot.study.presentation.query.dto.response.GetAppliesResponse; +import kr.spot.study.presentation.query.dto.response.GetMyAppliedStudyResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/modules/study/src/main/java/kr/spot/presentation/query/StudyQueryController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java similarity index 92% rename from modules/study/src/main/java/kr/spot/presentation/query/StudyQueryController.java rename to modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java index 4ef425a9..be518511 100644 --- a/modules/study/src/main/java/kr/spot/presentation/query/StudyQueryController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.query; +package kr.spot.study.presentation.query; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -8,14 +8,14 @@ import java.util.List; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; -import kr.spot.application.query.GetMyStudyInfoService; import kr.spot.code.status.SuccessStatus; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.FeeCategory; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.SortBy; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse; +import kr.spot.study.application.query.GetMyStudyInfoService; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.SortBy; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForEqual.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForEqual.java similarity index 50% rename from modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForEqual.java rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForEqual.java index d0b251a5..3ba041ea 100644 --- a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForEqual.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForEqual.java @@ -1,9 +1,9 @@ -package kr.spot.presentation.query.dto.request; +package kr.spot.study.presentation.query.dto.request; import java.util.List; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.FeeCategory; -import kr.spot.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; public record StudySearchRequestForEqual( FeeCategory feeCategory, diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForRange.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForRange.java similarity index 58% rename from modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForRange.java rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForRange.java index 136474f3..15446c1d 100644 --- a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForRange.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForRange.java @@ -1,8 +1,8 @@ -package kr.spot.presentation.query.dto.request; +package kr.spot.study.presentation.query.dto.request; import java.util.List; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.RecruitingStatus; public record StudySearchRequestForRange( boolean hasFee, diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetAppliesResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetAppliesResponse.java similarity index 91% rename from modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetAppliesResponse.java rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetAppliesResponse.java index cc567932..b64a9014 100644 --- a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetAppliesResponse.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetAppliesResponse.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.query.dto.response; +package kr.spot.study.presentation.query.dto.response; import java.util.List; diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetMyAppliedStudyResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetMyAppliedStudyResponse.java similarity index 91% rename from modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetMyAppliedStudyResponse.java rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetMyAppliedStudyResponse.java index 1526242d..6634ad93 100644 --- a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetMyAppliedStudyResponse.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetMyAppliedStudyResponse.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.query.dto.response; +package kr.spot.study.presentation.query.dto.response; import java.util.List; diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetStudyOverviewResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java similarity index 95% rename from modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetStudyOverviewResponse.java rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java index 5cce5d6e..b6b84a48 100644 --- a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetStudyOverviewResponse.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.query.dto.response; +package kr.spot.study.presentation.query.dto.response; import java.util.List; diff --git a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java b/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java index bbcf35e1..aaf9e725 100644 --- a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java +++ b/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java @@ -18,14 +18,15 @@ import kr.spot.common.ApplyStudyRequestFixture; import kr.spot.common.StudyFixture; import kr.spot.common.StudyMemberFixture; -import kr.spot.domain.Study; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; -import kr.spot.presentation.command.dto.request.ApplyStudyRequest; +import kr.spot.study.application.command.ApplyStudyService; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java b/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java index 14bbdc65..507010b3 100644 --- a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java +++ b/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java @@ -7,17 +7,18 @@ import java.util.Set; import kr.spot.IdGenerator; -import kr.spot.application.event.StudyCreatedEvent; -import kr.spot.domain.Study; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.Style; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyCategoryRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; -import kr.spot.infrastructure.jpa.associations.StudyRegionRepository; -import kr.spot.infrastructure.jpa.associations.StudyStatsRepository; -import kr.spot.infrastructure.jpa.associations.StudyStyleRepository; -import kr.spot.presentation.command.dto.request.CreateStudyRequest; +import kr.spot.study.application.command.CreateStudyService; +import kr.spot.study.application.event.StudyCreatedEvent; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.Style; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyCategoryRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyRegionRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStyleRepository; +import kr.spot.study.presentation.command.dto.request.CreateStudyRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java b/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java index bee96991..5f658d9c 100644 --- a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java +++ b/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import kr.spot.study.application.event.StudyCreatedEvent; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java b/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java index ff643ca8..3bca88c5 100644 --- a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java +++ b/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java @@ -13,11 +13,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import kr.spot.domain.Study; -import kr.spot.domain.vo.Fee; -import kr.spot.infrastructure.jpa.StudyRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.dto.UploadResult; +import kr.spot.study.application.event.StudyCreatedEvent; +import kr.spot.study.application.event.StudyImageUploadListener; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.vo.Fee; +import kr.spot.study.infrastructure.jpa.StudyRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; diff --git a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java b/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java index 7d317947..8927a8aa 100644 --- a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java +++ b/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java @@ -10,16 +10,17 @@ import java.util.Collections; import java.util.List; -import kr.spot.domain.Study; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.FeeCategory; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.SortBy; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.querydsl.StudyQueryRepository; import kr.spot.ports.GetPreferredCategoryPort; import kr.spot.ports.GetPreferredRegionPort; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse; +import kr.spot.study.application.query.GetMyStudyInfoService; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.SortBy; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.querydsl.StudyQueryRepository; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java b/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java index 18958e59..6fd4382f 100644 --- a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java +++ b/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java @@ -11,19 +11,19 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import kr.spot.application.validator.StudyAccessValidator; import kr.spot.code.status.ErrorStatus; -import kr.spot.common.MemberInfoResponseFixture; import kr.spot.common.StudyMemberFixture; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; -import kr.spot.infrastructure.jpa.StudyMemberRepositoryCustom; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; import kr.spot.ports.GetMemberInfoPort; import kr.spot.ports.dto.MemberInfoResponse; -import kr.spot.presentation.query.dto.response.GetAppliesResponse; -import kr.spot.presentation.query.dto.response.GetMyAppliedStudyResponse; +import kr.spot.study.application.query.GetStudyApplicationService; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.presentation.query.dto.response.GetAppliesResponse; +import kr.spot.study.presentation.query.dto.response.GetMyAppliedStudyResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; diff --git a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java b/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java index 74441dc3..626e015c 100644 --- a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java +++ b/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java @@ -1,6 +1,6 @@ package kr.spot.common; -import kr.spot.presentation.command.dto.request.ApplyStudyRequest; +import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; public class ApplyStudyRequestFixture { diff --git a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java b/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java index 858784e6..175465a7 100644 --- a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java +++ b/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java @@ -2,7 +2,7 @@ import java.util.List; import java.util.stream.LongStream; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; public class StudyApplicationInfoFixture { diff --git a/modules/study/src/test/java/kr/spot/common/StudyFixture.java b/modules/study/src/test/java/kr/spot/common/StudyFixture.java index 1cdfc6ae..ae2f42fc 100644 --- a/modules/study/src/test/java/kr/spot/common/StudyFixture.java +++ b/modules/study/src/test/java/kr/spot/common/StudyFixture.java @@ -2,8 +2,8 @@ import java.util.List; import java.util.stream.LongStream; -import kr.spot.domain.Study; -import kr.spot.domain.vo.Fee; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.vo.Fee; public class StudyFixture { diff --git a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java b/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java index e3101492..02bddc82 100644 --- a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java +++ b/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java @@ -1,7 +1,7 @@ package kr.spot.common; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; public class StudyMemberFixture { @@ -29,7 +29,7 @@ public static StudyMember applied() { public static StudyMember awaitingSelfApproval(Long id, Long studyId, Long memberId) { StudyMember studyMember = StudyMember.apply(id, studyId, memberId, MESSAGE); - studyMember.decide(kr.spot.domain.enums.Decision.APPROVE); + studyMember.decide(Decision.APPROVE); return studyMember; } diff --git a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java b/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java index e4e12bc9..fddbf648 100644 --- a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java +++ b/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java @@ -11,10 +11,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.StudyMemberStatus; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/modules/study/src/test/java/kr/spot/domain/StudyTest.java b/modules/study/src/test/java/kr/spot/domain/StudyTest.java index ff8bf2d1..82792662 100644 --- a/modules/study/src/test/java/kr/spot/domain/StudyTest.java +++ b/modules/study/src/test/java/kr/spot/domain/StudyTest.java @@ -14,12 +14,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.domain.vo.Fee; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.domain.vo.Fee; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java b/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java index e9360bb8..1538e08a 100644 --- a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java +++ b/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java @@ -5,6 +5,7 @@ import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.vo.Fee; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From f9ca31924a3ca971bfbb0ddb3396bf86ef6d6f38 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 14:57:38 +0900 Subject: [PATCH 004/126] =?UTF-8?q?[Refactor]=20Q=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20import=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/kr/spot/study/domain/Region.java | 1 + .../src/main/java/kr/spot/study/domain/Study.java | 1 + .../jpa/querydsl/StudyMemberRepositoryCustomImpl.java | 4 ++-- .../jpa/querydsl/StudyQueryRepository.java | 10 +++++----- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/study/domain/Region.java b/modules/study/src/main/java/kr/spot/study/domain/Region.java index 98dc1fb1..5333852c 100644 --- a/modules/study/src/main/java/kr/spot/study/domain/Region.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Region.java @@ -2,6 +2,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/study/domain/Study.java b/modules/study/src/main/java/kr/spot/study/domain/Study.java index 522ad61d..4c74946b 100644 --- a/modules/study/src/main/java/kr/spot/study/domain/Study.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Study.java @@ -8,6 +8,7 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.BaseEntity; import kr.spot.exception.GeneralException; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.Decision; diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java index ca9ec0c6..abead239 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java @@ -3,8 +3,8 @@ import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; -import kr.spot.domain.QStudy; -import kr.spot.domain.associations.QStudyMember; +import kr.spot.study.domain.QStudy; +import kr.spot.study.domain.associations.QStudyMember; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.StudyMemberStatus; import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom; diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java index 9f94a210..831b78e9 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java @@ -4,12 +4,12 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; -import kr.spot.domain.QStudy; -import kr.spot.domain.associations.QStudyCategory; -import kr.spot.domain.associations.QStudyMember; -import kr.spot.domain.associations.QStudyRegion; -import kr.spot.domain.associations.QStudyStats; +import kr.spot.study.domain.QStudy; import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.QStudyCategory; +import kr.spot.study.domain.associations.QStudyMember; +import kr.spot.study.domain.associations.QStudyRegion; +import kr.spot.study.domain.associations.QStudyStats; import kr.spot.study.domain.enums.Category; import kr.spot.study.domain.enums.FeeCategory; import kr.spot.study.domain.enums.RecruitingStatus; From 9f65555b1437047ccc3f8a78c186a44ac355c50d Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 15:15:21 +0900 Subject: [PATCH 005/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 4 ++ .../command/ManageScheduleService.java | 31 ++++++++++++++ .../kr/spot/schedule/domain/Schedule.java | 13 ++++++ .../jpa/ScheduleRepository.java | 14 +++++++ .../command/ScheduleCommandController.java | 41 +++++++++++++++++++ .../command/dto/CreateScheduleRequest.java | 12 ++++++ .../query/ScheduleQueryController.java | 15 +++++++ 7 files changed, 130 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index 9584a90d..d0d5e295 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -60,6 +60,10 @@ public enum ErrorStatus implements BaseErrorCode { // ์ง€์—ญ ๊ด€๋ จ _REGION_NOT_FOUND(404, "REGION404", "์ง€์—ญ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), _NO_SUCH_REGION(400, "REGION4000", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ง€์—ญ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค."), + + // ์ผ์ • ๊ด€๋ จ + _SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "์ผ์ •์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + _SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ์ผ์ •์ด ์•„๋‹™๋‹ˆ๋‹ค."), ; private final int httpStatus; diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java new file mode 100644 index 00000000..e75dfa3a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java @@ -0,0 +1,31 @@ +package kr.spot.schedule.application.command; + +import kr.spot.IdGenerator; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageScheduleService { + + private final IdGenerator idGenerator; + private final ScheduleRepository scheduleRepository; + + public void createSchedule(CreateScheduleRequest request, long studyId) { + Schedule schedule = Schedule.of(idGenerator.nextId(), studyId, request.title(), + request.locationInfo(), + request.startAt(), request.endAt()); + + scheduleRepository.save(schedule); + } + + public void deleteSchedule(long studyId, long scheduleId) { + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.delete(studyId); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java index d7431104..0971c3c1 100644 --- a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java @@ -4,7 +4,9 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import java.time.LocalDateTime; +import kr.spot.code.status.ErrorStatus; import kr.spot.domain.BaseEntity; +import kr.spot.exception.GeneralException; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -40,4 +42,15 @@ public static Schedule of(Long id, Long studyId, String title, String locationMe return new Schedule(id, studyId, title, locationMemo, startAt, endAt ); } + + public void delete(long studyId) { + validateIsValidAccess(studyId); + super.delete(); + } + + private void validateIsValidAccess(long studyId) { + if (studyId != this.studyId) { + throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java new file mode 100644 index 00000000..4982c3c4 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java @@ -0,0 +1,14 @@ +package kr.spot.schedule.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Schedule; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScheduleRepository extends JpaRepository { + + default Schedule getById(long scheduleId) { + return findById(scheduleId).orElseThrow( + () -> new GeneralException(ErrorStatus._SCHEDULE_NOT_FOUND)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java new file mode 100644 index 00000000..7b73e73e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java @@ -0,0 +1,41 @@ +package kr.spot.schedule.presentation.command; + + +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.command.ManageScheduleService; +import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ •") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules") +@RequiredArgsConstructor +public class ScheduleCommandController { + + private final ManageScheduleService manageScheduleService; + + @PostMapping + public ResponseEntity> createSchedule( + @PathVariable Long studyId, + @RequestBody CreateScheduleRequest request) { + manageScheduleService.createSchedule(request, studyId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @DeleteMapping("/{scheduleId}") + public ResponseEntity> deleteSchedule( + @PathVariable Long studyId, + @PathVariable Long scheduleId) { + manageScheduleService.deleteSchedule(studyId, scheduleId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java new file mode 100644 index 00000000..055068bd --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java @@ -0,0 +1,12 @@ +package kr.spot.schedule.presentation.command.dto; + +import java.time.LocalDateTime; + +public record CreateScheduleRequest( + String title, + String locationInfo, + LocalDateTime startAt, + LocalDateTime endAt +) { + +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java new file mode 100644 index 00000000..db9bbb90 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java @@ -0,0 +1,15 @@ +package kr.spot.schedule.presentation.query; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ •") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules") +@RequiredArgsConstructor +public class ScheduleQueryController { + + // TODO: ์›” ๋‹จ์œ„ ์กฐํšŒ, ์ฃผ ๋‹จ์œ„ ์กฐํšŒ ํ•„์š” +} From a3beaaaf533c8a51f84c59e3ad062382dae72e1a Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 15:32:56 +0900 Subject: [PATCH 006/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageScheduleServiceTest.java | 153 ++++++++++++++++++ .../spot/schedule/common/ScheduleFixture.java | 31 ++++ .../kr/spot/schedule/domain/ScheduleTest.java | 92 +++++++++++ 3 files changed, 276 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java diff --git a/modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java new file mode 100644 index 00000000..17e82a22 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java @@ -0,0 +1,153 @@ +package kr.spot.schedule.application.command; + +import static kr.spot.schedule.common.ScheduleFixture.LOCATION_MEMO; +import static kr.spot.schedule.common.ScheduleFixture.STUDY_ID; +import static kr.spot.schedule.common.ScheduleFixture.TITLE; +import static kr.spot.schedule.common.ScheduleFixture.createScheduleRequest; +import static kr.spot.schedule.common.ScheduleFixture.schedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManageScheduleServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + ScheduleRepository scheduleRepository; + + @Captor + ArgumentCaptor scheduleCaptor; + + ManageScheduleService manageScheduleService; + + @BeforeEach + void setUp() { + manageScheduleService = new ManageScheduleService(idGenerator, scheduleRepository); + } + + @Nested + @DisplayName("์ผ์ • ์ƒ์„ฑ (createSchedule)") + class CreateSchedule { + + @Test + @DisplayName("์ผ์ •์„ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_schedule_successfully() { + // given + Long generatedId = 1L; + CreateScheduleRequest request = createScheduleRequest(); + + when(idGenerator.nextId()).thenReturn(generatedId); + when(scheduleRepository.save(any(Schedule.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageScheduleService.createSchedule(request, STUDY_ID); + + // then + verify(scheduleRepository).save(scheduleCaptor.capture()); + + Schedule capturedSchedule = scheduleCaptor.getValue(); + assertThat(capturedSchedule.getId()).isEqualTo(generatedId); + assertThat(capturedSchedule.getStudyId()).isEqualTo(STUDY_ID); + assertThat(capturedSchedule.getTitle()).isEqualTo(TITLE); + assertThat(capturedSchedule.getLocationMemo()).isEqualTo(LOCATION_MEMO); + assertThat(capturedSchedule.getStartAt()).isEqualTo(request.startAt()); + assertThat(capturedSchedule.getEndAt()).isEqualTo(request.endAt()); + } + + @Test + @DisplayName("์œ„์น˜ ์ •๋ณด ์—†์ด ์ผ์ •์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_schedule_without_location() { + // given + Long generatedId = 1L; + CreateScheduleRequest request = new CreateScheduleRequest( + TITLE, null, createScheduleRequest().startAt(), createScheduleRequest().endAt() + ); + + when(idGenerator.nextId()).thenReturn(generatedId); + when(scheduleRepository.save(any(Schedule.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageScheduleService.createSchedule(request, STUDY_ID); + + // then + verify(scheduleRepository).save(scheduleCaptor.capture()); + + Schedule capturedSchedule = scheduleCaptor.getValue(); + assertThat(capturedSchedule.getLocationMemo()).isNull(); + } + } + + @Nested + @DisplayName("์ผ์ • ์‚ญ์ œ (deleteSchedule)") + class DeleteSchedule { + + @Test + @DisplayName("์ผ์ •์„ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_schedule_successfully() { + // given + long scheduleId = 1L; + Schedule schedule = schedule(scheduleId, STUDY_ID); + + when(scheduleRepository.getById(anyLong())).thenReturn(schedule); + + // when + manageScheduleService.deleteSchedule(STUDY_ID, scheduleId); + + // then + verify(scheduleRepository).getById(scheduleId); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ์ผ์ •์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_other_study_schedule() { + // given + long scheduleId = 1L; + long otherStudyId = 999L; + Schedule schedule = schedule(scheduleId, STUDY_ID); + + when(scheduleRepository.getById(anyLong())).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> manageScheduleService.deleteSchedule(otherStudyId, scheduleId)) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ผ์ •์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_schedule_not_found() { + // given + long scheduleId = 999L; + + when(scheduleRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._SCHEDULE_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> manageScheduleService.deleteSchedule(STUDY_ID, scheduleId)) + .isInstanceOf(GeneralException.class); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java b/modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java new file mode 100644 index 00000000..b0c9225c --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java @@ -0,0 +1,31 @@ +package kr.spot.schedule.common; + +import java.time.LocalDateTime; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest; + +public class ScheduleFixture { + + public static final Long ID = 1L; + public static final Long STUDY_ID = 100L; + public static final String TITLE = "Weekly Meeting"; + public static final String LOCATION_MEMO = "๊ฐ•๋‚จ์—ญ ์Šคํ„ฐ๋””์นดํŽ˜"; + public static final LocalDateTime START_AT = LocalDateTime.of(2025, 1, 15, 14, 0); + public static final LocalDateTime END_AT = LocalDateTime.of(2025, 1, 15, 16, 0); + + public static Schedule schedule() { + return Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, START_AT, END_AT); + } + + public static Schedule schedule(Long id, Long studyId) { + return Schedule.of(id, studyId, TITLE, LOCATION_MEMO, START_AT, END_AT); + } + + public static CreateScheduleRequest createScheduleRequest() { + return new CreateScheduleRequest(TITLE, LOCATION_MEMO, START_AT, END_AT); + } + + public static CreateScheduleRequest createScheduleRequest(String title) { + return new CreateScheduleRequest(title, LOCATION_MEMO, START_AT, END_AT); + } +} \ No newline at end of file diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java new file mode 100644 index 00000000..e80c1e9a --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java @@ -0,0 +1,92 @@ +package kr.spot.schedule.domain; + +import static kr.spot.schedule.common.ScheduleFixture.END_AT; +import static kr.spot.schedule.common.ScheduleFixture.ID; +import static kr.spot.schedule.common.ScheduleFixture.LOCATION_MEMO; +import static kr.spot.schedule.common.ScheduleFixture.START_AT; +import static kr.spot.schedule.common.ScheduleFixture.STUDY_ID; +import static kr.spot.schedule.common.ScheduleFixture.TITLE; +import static kr.spot.schedule.common.ScheduleFixture.schedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ScheduleTest { + + @Nested + @DisplayName("์ผ์ • ์ƒ์„ฑ (of)") + class CreateSchedule { + + @Test + @DisplayName("์ผ์ • ๊ฐ์ฒด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_schedule_successfully() { + // when + Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, START_AT, END_AT); + + // then + assertThat(schedule).isNotNull(); + assertThat(schedule.getId()).isEqualTo(ID); + assertThat(schedule.getStudyId()).isEqualTo(STUDY_ID); + assertThat(schedule.getTitle()).isEqualTo(TITLE); + assertThat(schedule.getLocationMemo()).isEqualTo(LOCATION_MEMO); + assertThat(schedule.getStartAt()).isEqualTo(START_AT); + assertThat(schedule.getEndAt()).isEqualTo(END_AT); + } + + @Test + @DisplayName("์œ„์น˜ ์ •๋ณด ์—†์ด ์ผ์ • ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_schedule_without_location() { + // when + Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, null, START_AT, END_AT); + + // then + assertThat(schedule).isNotNull(); + assertThat(schedule.getLocationMemo()).isNull(); + } + + @Test + @DisplayName("์‹œ์ž‘/์ข…๋ฃŒ ์‹œ๊ฐ„ ์—†์ด ์ผ์ • ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_schedule_without_time() { + // when + Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, null, null); + + // then + assertThat(schedule).isNotNull(); + assertThat(schedule.getStartAt()).isNull(); + assertThat(schedule.getEndAt()).isNull(); + } + } + + @Nested + @DisplayName("์ผ์ • ์‚ญ์ œ (delete)") + class DeleteSchedule { + + @Test + @DisplayName("ํ•ด๋‹น ์Šคํ„ฐ๋””์˜ ์ผ์ •์„ ์‚ญ์ œํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค") + void should_delete_schedule_successfully() { + // given + Schedule schedule = schedule(); + + // when & then (์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์œผ๋ฉด ์„ฑ๊ณต) + schedule.delete(STUDY_ID); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ์ผ์ •์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_with_different_study_id() { + // given + Schedule schedule = schedule(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> schedule.delete(otherStudyId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + } +} From c8ea59fbd7cd1d97d88f7a93c5800ba0765d5d54 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 15:33:03 +0900 Subject: [PATCH 007/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ScheduleCommandController.java | 24 ++++++++++++++++--- .../command/dto/CreateScheduleRequest.java | 9 +++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java index 7b73e73e..d3d613ab 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java @@ -1,6 +1,11 @@ package kr.spot.schedule.presentation.command; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.code.status.SuccessStatus; @@ -23,18 +28,31 @@ public class ScheduleCommandController { private final ManageScheduleService manageScheduleService; + @Operation(summary = "์Šคํ„ฐ๋”” ์ผ์ • ์ƒ์„ฑ", description = "์Šคํ„ฐ๋””์— ์ƒˆ๋กœ์šด ์ผ์ •์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์ผ์ • ์ƒ์„ฑ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`STUDY404`: ์Šคํ„ฐ๋””๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) @PostMapping public ResponseEntity> createSchedule( - @PathVariable Long studyId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, @RequestBody CreateScheduleRequest request) { manageScheduleService.createSchedule(request, studyId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); } + @Operation(summary = "์Šคํ„ฐ๋”” ์ผ์ • ์‚ญ์ œ", description = "์Šคํ„ฐ๋””์˜ ์ผ์ •์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์ผ์ • ์‚ญ์ œ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`SCHEDULE403`: ํ•ด๋‹น ์ผ์ •์— ๋Œ€ํ•œ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`SCHEDULE404`: ์ผ์ •์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) @DeleteMapping("/{scheduleId}") public ResponseEntity> deleteSchedule( - @PathVariable Long studyId, - @PathVariable Long scheduleId) { + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId) { manageScheduleService.deleteSchedule(studyId, scheduleId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java index 055068bd..b95b76ea 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java @@ -1,11 +1,20 @@ package kr.spot.schedule.presentation.command.dto; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +@Schema(description = "์Šคํ„ฐ๋”” ์ผ์ • ์ƒ์„ฑ ์š”์ฒญ") public record CreateScheduleRequest( + @Schema(description = "์ผ์ • ์ œ๋ชฉ", example = "์ฃผ๊ฐ„ ์Šคํ„ฐ๋”” ๋ชจ์ž„", requiredMode = Schema.RequiredMode.REQUIRED) String title, + + @Schema(description = "์žฅ์†Œ ์ •๋ณด", example = "๊ฐ•๋‚จ์—ญ ์Šคํ„ฐ๋””์นดํŽ˜ 3์ธต") String locationInfo, + + @Schema(description = "์‹œ์ž‘ ์ผ์‹œ", example = "2025-01-15T14:00:00") LocalDateTime startAt, + + @Schema(description = "์ข…๋ฃŒ ์ผ์‹œ", example = "2025-01-15T16:00:00") LocalDateTime endAt ) { From a45bcd3717582b704f9f98877558f7dcb2a36743 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 22:11:14 +0900 Subject: [PATCH 008/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20(=EC=9B=94,=20=EC=A3=BC,=20=EB=8B=A4?= =?UTF-8?q?=EA=B0=80=EC=98=A4=EB=8A=94=20=EC=9D=BC=EC=A0=95)=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/query/GetScheduleService.java | 54 ++++++++++++++ .../kr/spot/schedule/domain/Schedule.java | 7 ++ .../jpa/querydsl/ScheduleQueryRepository.java | 73 +++++++++++++++++++ .../query/ScheduleQueryController.java | 58 ++++++++++++++- .../query/dto/GetScheduleListResponse.java | 30 ++++++++ 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java new file mode 100644 index 00000000..f3b11b9c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java @@ -0,0 +1,54 @@ +package kr.spot.schedule.application.query; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.querydsl.ScheduleQueryRepository; +import kr.spot.schedule.presentation.query.dto.GetScheduleListResponse; +import kr.spot.schedule.presentation.query.dto.GetScheduleListResponse.ScheduleResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetScheduleService { + + private static final int UPCOMING_LIMIT = 5; + + private final ScheduleQueryRepository scheduleQueryRepository; + + public GetScheduleListResponse getMonthlySchedules(Long studyId, int year, int month) { + LocalDate date = LocalDate.of(year, month, 1); + List schedules = scheduleQueryRepository.findMonthlySchedules(studyId, date); + return toResponse(schedules); + } + + public GetScheduleListResponse getWeeklySchedules(Long studyId, LocalDate date) { + List schedules = scheduleQueryRepository.findWeeklySchedules(studyId, date); + return toResponse(schedules); + } + + public GetScheduleListResponse getUpcomingSchedules(Long studyId) { + List schedules = scheduleQueryRepository.findUpcomingSchedules(studyId, UPCOMING_LIMIT); + return toResponse(schedules); + } + + private GetScheduleListResponse toResponse(List schedules) { + LocalDateTime now = LocalDateTime.now(); + + List responses = schedules.stream() + .map(schedule -> ScheduleResponse.from( + schedule.getId(), + schedule.getTitle(), + schedule.getStartAt(), + schedule.getEndAt(), + schedule.isOngoing(now) + )) + .toList(); + + return GetScheduleListResponse.from(responses); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java index 0971c3c1..85a7ccc6 100644 --- a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java @@ -48,6 +48,13 @@ public void delete(long studyId) { super.delete(); } + public boolean isOngoing(LocalDateTime now) { + if (startAt == null || endAt == null) { + return false; + } + return !now.isBefore(startAt) && !now.isAfter(endAt); + } + private void validateIsValidAccess(long studyId) { if (studyId != this.studyId) { throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED); diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java new file mode 100644 index 00000000..ed308735 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.infrastructure.jpa.querydsl; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.List; +import kr.spot.schedule.domain.QSchedule; +import kr.spot.schedule.domain.Schedule; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ScheduleQueryRepository { + + private final JPAQueryFactory query; + + public List findMonthlySchedules(Long studyId, LocalDate date) { + QSchedule schedule = QSchedule.schedule; + + YearMonth yearMonth = YearMonth.from(date); + LocalDateTime startOfMonth = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime endOfMonth = yearMonth.atEndOfMonth().atTime(LocalTime.MAX); + + return query + .selectFrom(schedule) + .where( + schedule.studyId.eq(studyId), + schedule.startAt.between(startOfMonth, endOfMonth) + .or(schedule.endAt.between(startOfMonth, endOfMonth)) + ) + .orderBy(schedule.startAt.asc()) + .fetch(); + } + + public List findWeeklySchedules(Long studyId, LocalDate date) { + QSchedule schedule = QSchedule.schedule; + + LocalDate monday = date.with(DayOfWeek.MONDAY); + LocalDate sunday = date.with(DayOfWeek.SUNDAY); + LocalDateTime startOfWeek = monday.atStartOfDay(); + LocalDateTime endOfWeek = sunday.atTime(LocalTime.MAX); + + return query + .selectFrom(schedule) + .where( + schedule.studyId.eq(studyId), + schedule.startAt.between(startOfWeek, endOfWeek) + .or(schedule.endAt.between(startOfWeek, endOfWeek)) + ) + .orderBy(schedule.startAt.asc()) + .fetch(); + } + + public List findUpcomingSchedules(Long studyId, int limit) { + QSchedule schedule = QSchedule.schedule; + + LocalDateTime now = LocalDateTime.now(); + + return query + .selectFrom(schedule) + .where( + schedule.studyId.eq(studyId), + schedule.startAt.goe(now) + ) + .orderBy(schedule.startAt.asc()) + .limit(limit) + .fetch(); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java index db9bbb90..7861eef5 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java @@ -1,8 +1,24 @@ package kr.spot.schedule.presentation.query; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import kr.spot.ApiResponse; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.query.GetScheduleService; +import kr.spot.schedule.presentation.query.dto.GetScheduleListResponse; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "์Šคํ„ฐ๋”” ์ผ์ •") @@ -11,5 +27,45 @@ @RequiredArgsConstructor public class ScheduleQueryController { - // TODO: ์›” ๋‹จ์œ„ ์กฐํšŒ, ์ฃผ ๋‹จ์œ„ ์กฐํšŒ ํ•„์š” + private final GetScheduleService getScheduleService; + + @Operation(summary = "์›”๋ณ„ ์ผ์ • ์กฐํšŒ", description = "ํ•ด๋‹น ์—ฐ/์›”์˜ ๋ชจ๋“  ์ผ์ •์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/monthly") + public ResponseEntity> getMonthlySchedules( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์—ฐ๋„", example = "2025") @RequestParam Integer year, + @Parameter(description = "์›”", example = "1") @RequestParam Integer month) { + GetScheduleListResponse response = getScheduleService.getMonthlySchedules(studyId, year, month); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation(summary = "์ฃผ๊ฐ„ ์ผ์ • ์กฐํšŒ", description = "ํ•ด๋‹น ๋‚ ์งœ๊ฐ€ ์†ํ•œ ์ฃผ(์›”~์ผ)์˜ ๋ชจ๋“  ์ผ์ •์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/weekly") + public ResponseEntity> getWeeklySchedules( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์กฐํšŒ ๊ธฐ์ค€ ๋‚ ์งœ (ํ•ด๋‹น ์ฃผ ์ „์ฒด ์กฐํšŒ)", example = "2025-01-15") + @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) { + GetScheduleListResponse response = getScheduleService.getWeeklySchedules(studyId, date); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation(summary = "๋‹ค๊ฐ€์˜ค๋Š” ์ผ์ • ์กฐํšŒ", description = "ํ˜„์žฌ ์‹œ์  ์ดํ›„์˜ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์ผ์ • 2๊ฐœ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/upcoming") + public ResponseEntity> getUpcomingSchedules( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId) { + GetScheduleListResponse response = getScheduleService.getUpcomingSchedules(studyId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java new file mode 100644 index 00000000..3174c818 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java @@ -0,0 +1,30 @@ +package kr.spot.schedule.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record GetScheduleListResponse( + List schedules, + long totalCount +) { + + public static GetScheduleListResponse from(List schedules) { + return new GetScheduleListResponse(schedules, schedules.size()); + } + + public record ScheduleResponse( + long scheduleId, + String title, + LocalDateTime startAt, + LocalDateTime endAt, + boolean isNow + ) { + + public static ScheduleResponse from(long scheduleId, String title, + LocalDateTime startAt, LocalDateTime endAt, boolean isNow) { + return new ScheduleResponse(scheduleId, title, startAt, endAt, isNow); + } + + } + +} From 87de5813de272b2b8f497903ec9c2cf492e95ef5 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 22:14:26 +0900 Subject: [PATCH 009/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20(=EC=9B=94,=20=EC=A3=BC,=20=EB=8B=A4?= =?UTF-8?q?=EA=B0=80=EC=98=A4=EB=8A=94=20=EC=9D=BC=EC=A0=95)=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/query/GetScheduleService.java | 2 +- .../query/GetScheduleServiceTest.java | 161 ++++++++++++++++++ .../kr/spot/schedule/domain/ScheduleTest.java | 102 +++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java index f3b11b9c..2d9b4f67 100644 --- a/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java @@ -16,7 +16,7 @@ @RequiredArgsConstructor public class GetScheduleService { - private static final int UPCOMING_LIMIT = 5; + private static final int UPCOMING_LIMIT = 2; private final ScheduleQueryRepository scheduleQueryRepository; diff --git a/modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java new file mode 100644 index 00000000..722791a1 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java @@ -0,0 +1,161 @@ +package kr.spot.schedule.application.query; + +import static kr.spot.schedule.common.ScheduleFixture.STUDY_ID; +import static kr.spot.schedule.common.ScheduleFixture.schedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.querydsl.ScheduleQueryRepository; +import kr.spot.schedule.presentation.query.dto.GetScheduleListResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetScheduleServiceTest { + + @Mock + ScheduleQueryRepository scheduleQueryRepository; + + GetScheduleService getScheduleService; + + @BeforeEach + void setUp() { + getScheduleService = new GetScheduleService(scheduleQueryRepository); + } + + @Nested + @DisplayName("์›”๋ณ„ ์ผ์ • ์กฐํšŒ (getMonthlySchedules)") + class GetMonthlySchedules { + + @Test + @DisplayName("์›”๋ณ„ ์ผ์ •์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_get_monthly_schedules() { + // given + int year = 2025; + int month = 1; + List schedules = List.of( + schedule(1L, STUDY_ID), + schedule(2L, STUDY_ID) + ); + + when(scheduleQueryRepository.findMonthlySchedules(anyLong(), any(LocalDate.class))) + .thenReturn(schedules); + + // when + GetScheduleListResponse response = getScheduleService.getMonthlySchedules(STUDY_ID, year, month); + + // then + assertThat(response.schedules()).hasSize(2); + assertThat(response.totalCount()).isEqualTo(2); + verify(scheduleQueryRepository).findMonthlySchedules(STUDY_ID, LocalDate.of(year, month, 1)); + } + + @Test + @DisplayName("์ผ์ •์ด ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_no_schedules() { + // given + when(scheduleQueryRepository.findMonthlySchedules(anyLong(), any(LocalDate.class))) + .thenReturn(Collections.emptyList()); + + // when + GetScheduleListResponse response = getScheduleService.getMonthlySchedules(STUDY_ID, 2025, 1); + + // then + assertThat(response.schedules()).isEmpty(); + assertThat(response.totalCount()).isZero(); + } + } + + @Nested + @DisplayName("์ฃผ๊ฐ„ ์ผ์ • ์กฐํšŒ (getWeeklySchedules)") + class GetWeeklySchedules { + + @Test + @DisplayName("์ฃผ๊ฐ„ ์ผ์ •์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_get_weekly_schedules() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + List schedules = List.of(schedule(1L, STUDY_ID)); + + when(scheduleQueryRepository.findWeeklySchedules(anyLong(), any(LocalDate.class))) + .thenReturn(schedules); + + // when + GetScheduleListResponse response = getScheduleService.getWeeklySchedules(STUDY_ID, date); + + // then + assertThat(response.schedules()).hasSize(1); + verify(scheduleQueryRepository).findWeeklySchedules(STUDY_ID, date); + } + + @Test + @DisplayName("์ผ์ •์ด ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_no_schedules() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + + when(scheduleQueryRepository.findWeeklySchedules(anyLong(), any(LocalDate.class))) + .thenReturn(Collections.emptyList()); + + // when + GetScheduleListResponse response = getScheduleService.getWeeklySchedules(STUDY_ID, date); + + // then + assertThat(response.schedules()).isEmpty(); + assertThat(response.totalCount()).isZero(); + } + } + + @Nested + @DisplayName("๋‹ค๊ฐ€์˜ค๋Š” ์ผ์ • ์กฐํšŒ (getUpcomingSchedules)") + class GetUpcomingSchedules { + + @Test + @DisplayName("๋‹ค๊ฐ€์˜ค๋Š” ์ผ์ •์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_get_upcoming_schedules() { + // given + List schedules = List.of( + schedule(1L, STUDY_ID), + schedule(2L, STUDY_ID) + ); + + when(scheduleQueryRepository.findUpcomingSchedules(anyLong(), anyInt())) + .thenReturn(schedules); + + // when + GetScheduleListResponse response = getScheduleService.getUpcomingSchedules(STUDY_ID); + + // then + assertThat(response.schedules()).hasSize(2); + verify(scheduleQueryRepository).findUpcomingSchedules(STUDY_ID, 2); + } + + @Test + @DisplayName("์ผ์ •์ด ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_no_schedules() { + // given + when(scheduleQueryRepository.findUpcomingSchedules(anyLong(), anyInt())) + .thenReturn(Collections.emptyList()); + + // when + GetScheduleListResponse response = getScheduleService.getUpcomingSchedules(STUDY_ID); + + // then + assertThat(response.schedules()).isEmpty(); + assertThat(response.totalCount()).isZero(); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java index e80c1e9a..ef42f07b 100644 --- a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java +++ b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java @@ -10,6 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.time.LocalDateTime; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; import org.junit.jupiter.api.DisplayName; @@ -89,4 +90,105 @@ void should_throw_exception_when_delete_with_different_study_id() { .hasFieldOrPropertyWithValue("status", ErrorStatus._SCHEDULE_ACCESS_DENIED); } } + + @Nested + @DisplayName("์ง„ํ–‰ ์ค‘ ์—ฌ๋ถ€ ํ™•์ธ (isOngoing)") + class IsOngoing { + + @Test + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ผ์ • ์‹œ๊ฐ„ ๋ฒ”์œ„ ๋‚ด์— ์žˆ์œผ๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_true_when_now_is_within_schedule() { + // given + Schedule schedule = schedule(); + LocalDateTime duringSchedule = START_AT.plusMinutes(30); + + // when + boolean result = schedule.isOngoing(duringSchedule); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ผ์ • ์‹œ์ž‘ ์‹œ๊ฐ„๊ณผ ๊ฐ™์œผ๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_true_when_now_equals_start_time() { + // given + Schedule schedule = schedule(); + + // when + boolean result = schedule.isOngoing(START_AT); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ผ์ • ์ข…๋ฃŒ ์‹œ๊ฐ„๊ณผ ๊ฐ™์œผ๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_true_when_now_equals_end_time() { + // given + Schedule schedule = schedule(); + + // when + boolean result = schedule.isOngoing(END_AT); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ผ์ • ์‹œ์ž‘ ์ „์ด๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_false_when_now_is_before_schedule() { + // given + Schedule schedule = schedule(); + LocalDateTime beforeSchedule = START_AT.minusMinutes(1); + + // when + boolean result = schedule.isOngoing(beforeSchedule); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ผ์ • ์ข…๋ฃŒ ํ›„์ด๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_false_when_now_is_after_schedule() { + // given + Schedule schedule = schedule(); + LocalDateTime afterSchedule = END_AT.plusMinutes(1); + + // when + boolean result = schedule.isOngoing(afterSchedule); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("์‹œ์ž‘ ์‹œ๊ฐ„์ด null์ด๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_false_when_start_time_is_null() { + // given + Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, null, END_AT); + LocalDateTime now = LocalDateTime.now(); + + // when + boolean result = schedule.isOngoing(now); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("์ข…๋ฃŒ ์‹œ๊ฐ„์ด null์ด๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_false_when_end_time_is_null() { + // given + Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, START_AT, null); + LocalDateTime now = LocalDateTime.now(); + + // when + boolean result = schedule.isOngoing(now); + + // then + assertThat(result).isFalse(); + } + } } From 9f3bd82de5c634fe95aea81e0337abb0e797146c Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 22:15:10 +0900 Subject: [PATCH 010/126] =?UTF-8?q?[Refactor]=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20-=20study=20=ED=95=98=EC=9C=84=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20-=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=EC=99=80=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ApplyStudyServiceTest.java | 17 ++++++------- .../command/CreateStudyServiceTest.java | 3 +-- .../event/StudyCreatedEventTest.java | 3 +-- .../event/StudyImageUploadListenerTest.java | 18 ++++++------- .../query/GetMyStudyInfoServiceTest.java | 5 ++-- .../query/GetStudyApplicationServiceTest.java | 11 ++++---- .../common/ApplyStudyRequestFixture.java | 2 +- .../common/MemberInfoResponseFixture.java | 2 +- .../common/StudyApplicationInfoFixture.java | 2 +- .../spot/{ => study}/common/StudyFixture.java | 2 +- .../common/StudyMemberFixture.java | 2 +- .../{ => study}/domain/StudyMemberTest.java | 18 ++++++------- .../kr/spot/{ => study}/domain/StudyTest.java | 25 +++++++++---------- .../spot/{ => study}/domain/vo/FeeTest.java | 3 +-- 14 files changed, 52 insertions(+), 61 deletions(-) rename modules/study/src/test/java/kr/spot/{ => study}/application/command/ApplyStudyServiceTest.java (95%) rename modules/study/src/test/java/kr/spot/{ => study}/application/command/CreateStudyServiceTest.java (98%) rename modules/study/src/test/java/kr/spot/{ => study}/application/event/StudyCreatedEventTest.java (95%) rename modules/study/src/test/java/kr/spot/{ => study}/application/event/StudyImageUploadListenerTest.java (89%) rename modules/study/src/test/java/kr/spot/{ => study}/application/query/GetMyStudyInfoServiceTest.java (98%) rename modules/study/src/test/java/kr/spot/{ => study}/application/query/GetStudyApplicationServiceTest.java (95%) rename modules/study/src/test/java/kr/spot/{ => study}/common/ApplyStudyRequestFixture.java (93%) rename modules/study/src/test/java/kr/spot/{ => study}/common/MemberInfoResponseFixture.java (96%) rename modules/study/src/test/java/kr/spot/{ => study}/common/StudyApplicationInfoFixture.java (97%) rename modules/study/src/test/java/kr/spot/{ => study}/common/StudyFixture.java (97%) rename modules/study/src/test/java/kr/spot/{ => study}/common/StudyMemberFixture.java (97%) rename modules/study/src/test/java/kr/spot/{ => study}/domain/StudyMemberTest.java (91%) rename modules/study/src/test/java/kr/spot/{ => study}/domain/StudyTest.java (93%) rename modules/study/src/test/java/kr/spot/{ => study}/domain/vo/FeeTest.java (96%) diff --git a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java similarity index 95% rename from modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java rename to modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java index aaf9e725..6f1d3fde 100644 --- a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java @@ -1,9 +1,9 @@ -package kr.spot.application.command; +package kr.spot.study.application.command; -import static kr.spot.common.ApplyStudyRequestFixture.create; -import static kr.spot.common.StudyFixture.study; -import static kr.spot.common.StudyMemberFixture.applied; -import static kr.spot.common.StudyMemberFixture.awaitingSelfApproval; +import static kr.spot.study.common.ApplyStudyRequestFixture.create; +import static kr.spot.study.common.StudyFixture.study; +import static kr.spot.study.common.StudyMemberFixture.applied; +import static kr.spot.study.common.StudyMemberFixture.awaitingSelfApproval; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -15,11 +15,10 @@ import java.util.List; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; -import kr.spot.common.ApplyStudyRequestFixture; -import kr.spot.common.StudyFixture; -import kr.spot.common.StudyMemberFixture; import kr.spot.exception.GeneralException; -import kr.spot.study.application.command.ApplyStudyService; +import kr.spot.study.common.ApplyStudyRequestFixture; +import kr.spot.study.common.StudyFixture; +import kr.spot.study.common.StudyMemberFixture; import kr.spot.study.domain.Study; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.Decision; diff --git a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/command/CreateStudyServiceTest.java similarity index 98% rename from modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java rename to modules/study/src/test/java/kr/spot/study/application/command/CreateStudyServiceTest.java index 507010b3..351f4339 100644 --- a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/command/CreateStudyServiceTest.java @@ -1,4 +1,4 @@ -package kr.spot.application.command; +package kr.spot.study.application.command; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -7,7 +7,6 @@ import java.util.Set; import kr.spot.IdGenerator; -import kr.spot.study.application.command.CreateStudyService; import kr.spot.study.application.event.StudyCreatedEvent; import kr.spot.study.domain.Study; import kr.spot.study.domain.enums.Category; diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java b/modules/study/src/test/java/kr/spot/study/application/event/StudyCreatedEventTest.java similarity index 95% rename from modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java rename to modules/study/src/test/java/kr/spot/study/application/event/StudyCreatedEventTest.java index 5f658d9c..38eb4144 100644 --- a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/event/StudyCreatedEventTest.java @@ -1,8 +1,7 @@ -package kr.spot.application.event; +package kr.spot.study.application.event; import static org.assertj.core.api.Assertions.assertThat; -import kr.spot.study.application.event.StudyCreatedEvent; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java b/modules/study/src/test/java/kr/spot/study/application/event/StudyImageUploadListenerTest.java similarity index 89% rename from modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java rename to modules/study/src/test/java/kr/spot/study/application/event/StudyImageUploadListenerTest.java index 3bca88c5..6ef447a7 100644 --- a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/event/StudyImageUploadListenerTest.java @@ -1,11 +1,11 @@ -package kr.spot.application.event; - -import static kr.spot.common.StudyFixture.DESCRIPTION; -import static kr.spot.common.StudyFixture.FEE_AMOUNT; -import static kr.spot.common.StudyFixture.HAS_FEE; -import static kr.spot.common.StudyFixture.LEADER_ID; -import static kr.spot.common.StudyFixture.MAX_MEMBERS; -import static kr.spot.common.StudyFixture.NAME; +package kr.spot.study.application.event; + +import static kr.spot.study.common.StudyFixture.DESCRIPTION; +import static kr.spot.study.common.StudyFixture.FEE_AMOUNT; +import static kr.spot.study.common.StudyFixture.HAS_FEE; +import static kr.spot.study.common.StudyFixture.LEADER_ID; +import static kr.spot.study.common.StudyFixture.MAX_MEMBERS; +import static kr.spot.study.common.StudyFixture.NAME; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -15,8 +15,6 @@ import kr.spot.ports.FileStoragePort; import kr.spot.ports.dto.UploadResult; -import kr.spot.study.application.event.StudyCreatedEvent; -import kr.spot.study.application.event.StudyImageUploadListener; import kr.spot.study.domain.Study; import kr.spot.study.domain.vo.Fee; import kr.spot.study.infrastructure.jpa.StudyRepository; diff --git a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java similarity index 98% rename from modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java rename to modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java index 8927a8aa..3c8ea97d 100644 --- a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java @@ -1,6 +1,6 @@ -package kr.spot.application.query; +package kr.spot.study.application.query; -import static kr.spot.common.StudyFixture.createStudies; +import static kr.spot.study.common.StudyFixture.createStudies; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -12,7 +12,6 @@ import java.util.List; import kr.spot.ports.GetPreferredCategoryPort; import kr.spot.ports.GetPreferredRegionPort; -import kr.spot.study.application.query.GetMyStudyInfoService; import kr.spot.study.domain.Study; import kr.spot.study.domain.enums.Category; import kr.spot.study.domain.enums.FeeCategory; diff --git a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/GetStudyApplicationServiceTest.java similarity index 95% rename from modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java rename to modules/study/src/test/java/kr/spot/study/application/query/GetStudyApplicationServiceTest.java index 6fd4382f..76a857de 100644 --- a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/query/GetStudyApplicationServiceTest.java @@ -1,8 +1,8 @@ -package kr.spot.application.query; +package kr.spot.study.application.query; -import static kr.spot.common.MemberInfoResponseFixture.createMap; -import static kr.spot.common.StudyApplicationInfoFixture.createList; -import static kr.spot.common.StudyMemberFixture.applied; +import static kr.spot.study.common.MemberInfoResponseFixture.createMap; +import static kr.spot.study.common.StudyApplicationInfoFixture.createList; +import static kr.spot.study.common.StudyMemberFixture.applied; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -12,12 +12,11 @@ import java.util.List; import java.util.Map; import kr.spot.code.status.ErrorStatus; -import kr.spot.common.StudyMemberFixture; import kr.spot.exception.GeneralException; import kr.spot.ports.GetMemberInfoPort; import kr.spot.ports.dto.MemberInfoResponse; -import kr.spot.study.application.query.GetStudyApplicationService; import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.common.StudyMemberFixture; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.StudyMemberStatus; import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom; diff --git a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java b/modules/study/src/test/java/kr/spot/study/common/ApplyStudyRequestFixture.java similarity index 93% rename from modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java rename to modules/study/src/test/java/kr/spot/study/common/ApplyStudyRequestFixture.java index 626e015c..4f12ba8b 100644 --- a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java +++ b/modules/study/src/test/java/kr/spot/study/common/ApplyStudyRequestFixture.java @@ -1,4 +1,4 @@ -package kr.spot.common; +package kr.spot.study.common; import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; diff --git a/modules/study/src/test/java/kr/spot/common/MemberInfoResponseFixture.java b/modules/study/src/test/java/kr/spot/study/common/MemberInfoResponseFixture.java similarity index 96% rename from modules/study/src/test/java/kr/spot/common/MemberInfoResponseFixture.java rename to modules/study/src/test/java/kr/spot/study/common/MemberInfoResponseFixture.java index b0af0894..d86761bc 100644 --- a/modules/study/src/test/java/kr/spot/common/MemberInfoResponseFixture.java +++ b/modules/study/src/test/java/kr/spot/study/common/MemberInfoResponseFixture.java @@ -1,4 +1,4 @@ -package kr.spot.common; +package kr.spot.study.common; import java.util.List; import java.util.Map; diff --git a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java b/modules/study/src/test/java/kr/spot/study/common/StudyApplicationInfoFixture.java similarity index 97% rename from modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java rename to modules/study/src/test/java/kr/spot/study/common/StudyApplicationInfoFixture.java index 175465a7..dd0e5b60 100644 --- a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java +++ b/modules/study/src/test/java/kr/spot/study/common/StudyApplicationInfoFixture.java @@ -1,4 +1,4 @@ -package kr.spot.common; +package kr.spot.study.common; import java.util.List; import java.util.stream.LongStream; diff --git a/modules/study/src/test/java/kr/spot/common/StudyFixture.java b/modules/study/src/test/java/kr/spot/study/common/StudyFixture.java similarity index 97% rename from modules/study/src/test/java/kr/spot/common/StudyFixture.java rename to modules/study/src/test/java/kr/spot/study/common/StudyFixture.java index ae2f42fc..c00b6d5a 100644 --- a/modules/study/src/test/java/kr/spot/common/StudyFixture.java +++ b/modules/study/src/test/java/kr/spot/study/common/StudyFixture.java @@ -1,4 +1,4 @@ -package kr.spot.common; +package kr.spot.study.common; import java.util.List; import java.util.stream.LongStream; diff --git a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java b/modules/study/src/test/java/kr/spot/study/common/StudyMemberFixture.java similarity index 97% rename from modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java rename to modules/study/src/test/java/kr/spot/study/common/StudyMemberFixture.java index 02bddc82..aa28d858 100644 --- a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java +++ b/modules/study/src/test/java/kr/spot/study/common/StudyMemberFixture.java @@ -1,4 +1,4 @@ -package kr.spot.common; +package kr.spot.study.common; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.Decision; diff --git a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java b/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java similarity index 91% rename from modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java rename to modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java index fddbf648..33ad9fb3 100644 --- a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java +++ b/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java @@ -1,12 +1,12 @@ -package kr.spot.domain; - -import static kr.spot.common.StudyMemberFixture.ID; -import static kr.spot.common.StudyMemberFixture.MEMBER_ID; -import static kr.spot.common.StudyMemberFixture.MESSAGE; -import static kr.spot.common.StudyMemberFixture.STUDY_ID; -import static kr.spot.common.StudyMemberFixture.applied; -import static kr.spot.common.StudyMemberFixture.awaitingSelfApproval; -import static kr.spot.common.StudyMemberFixture.owner; +package kr.spot.study.domain; + +import static kr.spot.study.common.StudyMemberFixture.ID; +import static kr.spot.study.common.StudyMemberFixture.MEMBER_ID; +import static kr.spot.study.common.StudyMemberFixture.MESSAGE; +import static kr.spot.study.common.StudyMemberFixture.STUDY_ID; +import static kr.spot.study.common.StudyMemberFixture.applied; +import static kr.spot.study.common.StudyMemberFixture.awaitingSelfApproval; +import static kr.spot.study.common.StudyMemberFixture.owner; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/modules/study/src/test/java/kr/spot/domain/StudyTest.java b/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java similarity index 93% rename from modules/study/src/test/java/kr/spot/domain/StudyTest.java rename to modules/study/src/test/java/kr/spot/study/domain/StudyTest.java index 82792662..f2a33bcf 100644 --- a/modules/study/src/test/java/kr/spot/domain/StudyTest.java +++ b/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java @@ -1,21 +1,20 @@ -package kr.spot.domain; - -import static kr.spot.common.StudyFixture.DESCRIPTION; -import static kr.spot.common.StudyFixture.FEE_AMOUNT; -import static kr.spot.common.StudyFixture.HAS_FEE; -import static kr.spot.common.StudyFixture.ID; -import static kr.spot.common.StudyFixture.IMAGE_URL; -import static kr.spot.common.StudyFixture.LEADER_ID; -import static kr.spot.common.StudyFixture.MAX_MEMBERS; -import static kr.spot.common.StudyFixture.NAME; -import static kr.spot.common.StudyFixture.study; -import static kr.spot.common.StudyMemberFixture.applied; +package kr.spot.study.domain; + +import static kr.spot.study.common.StudyFixture.DESCRIPTION; +import static kr.spot.study.common.StudyFixture.FEE_AMOUNT; +import static kr.spot.study.common.StudyFixture.HAS_FEE; +import static kr.spot.study.common.StudyFixture.ID; +import static kr.spot.study.common.StudyFixture.IMAGE_URL; +import static kr.spot.study.common.StudyFixture.LEADER_ID; +import static kr.spot.study.common.StudyFixture.MAX_MEMBERS; +import static kr.spot.study.common.StudyFixture.NAME; +import static kr.spot.study.common.StudyFixture.study; +import static kr.spot.study.common.StudyMemberFixture.applied; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; -import kr.spot.study.domain.Study; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.Decision; import kr.spot.study.domain.enums.RecruitingStatus; diff --git a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java b/modules/study/src/test/java/kr/spot/study/domain/vo/FeeTest.java similarity index 96% rename from modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java rename to modules/study/src/test/java/kr/spot/study/domain/vo/FeeTest.java index 1538e08a..b5243747 100644 --- a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java +++ b/modules/study/src/test/java/kr/spot/study/domain/vo/FeeTest.java @@ -1,11 +1,10 @@ -package kr.spot.domain.vo; +package kr.spot.study.domain.vo; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; -import kr.spot.study.domain.vo.Fee; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From 0ddba922ac859bf69e8a66cb097fbe16014d12a4 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 22:23:52 +0900 Subject: [PATCH 011/126] =?UTF-8?q?[Refactor]=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/study/domain/Region.java | 34 ------------------- .../infrastructure/jpa/RegionRepository.java | 8 ----- 2 files changed, 42 deletions(-) delete mode 100644 modules/study/src/main/java/kr/spot/study/domain/Region.java delete mode 100644 modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java diff --git a/modules/study/src/main/java/kr/spot/study/domain/Region.java b/modules/study/src/main/java/kr/spot/study/domain/Region.java deleted file mode 100644 index 5333852c..00000000 --- a/modules/study/src/main/java/kr/spot/study/domain/Region.java +++ /dev/null @@ -1,34 +0,0 @@ -package kr.spot.study.domain; - -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import kr.spot.domain.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; - - -@Getter -@Entity -@SQLDelete(sql = "UPDATE region SET status = 'INACTIVE' WHERE id = ?") -@SQLRestriction("status = 'ACTIVE'") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Region extends BaseEntity { - - @Id - private String code; - - private String province; - - private String district; - - private String neighborhood; - - public static Region of(String code, String province, String district, String neighborhood) { - return new Region(code, province, district, neighborhood); - } -} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java deleted file mode 100644 index fa6e2772..00000000 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.spot.study.infrastructure.jpa; - -import kr.spot.study.domain.Region; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface RegionRepository extends JpaRepository { - -} From ab253c7fb8eb57d21ddbdacb206cf886a0ead33e Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 22:24:24 +0900 Subject: [PATCH 012/126] =?UTF-8?q?[Refactor]=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8C=80=20=EC=9E=85=EB=A0=A5=20=ED=8F=AC=EB=A7=B7=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=AA=85=EC=84=B8=20=EC=B6=94=EA=B0=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/presentation/query/ScheduleQueryController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java index 7861eef5..ac9c5376 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java @@ -51,7 +51,7 @@ public ResponseEntity> getMonthlySchedules( @GetMapping("/weekly") public ResponseEntity> getWeeklySchedules( @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "์กฐํšŒ ๊ธฐ์ค€ ๋‚ ์งœ (ํ•ด๋‹น ์ฃผ ์ „์ฒด ์กฐํšŒ)", example = "2025-01-15") + @Parameter(description = "์กฐํšŒ ๊ธฐ์ค€ ๋‚ ์งœ (ํ•ด๋‹น ์ฃผ ์ „์ฒด ์กฐํšŒ), ISO 8601 ํ‘œ์ค€ ๋ฐฉ์‹์œผ๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”. ", example = "2025-01-15") @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) { GetScheduleListResponse response = getScheduleService.getWeeklySchedules(studyId, date); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); From 7f2fd0d4f00efc6c74a2118286b46c2fedfb4aed Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 22:51:30 +0900 Subject: [PATCH 013/126] =?UTF-8?q?[Refactor]=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20=EC=B6=94=EC=83=81=ED=99=94=20=20?= =?UTF-8?q?=20-=20common/view=20=EB=AA=A8=EB=93=88=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?(ViewableType,=20ViewAbuseGuard,=20ViewCounter)=20=20=20-=20POS?= =?UTF-8?q?T,=20STUDY,=20STUDY=5FBOARD=20=ED=83=80=EC=9E=85=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=20=20-=20Redis=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20(RedisViewAbuseGuard,=20RedisView?= =?UTF-8?q?Counter)=20=20=20-=20Post=20=EB=AA=A8=EB=93=88=20=EC=83=88=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/view/build.gradle | 2 + .../java/kr/spot/view/ViewAbuseGuard.java | 6 +++ .../main/java/kr/spot/view/ViewCounter.java | 8 +++ .../main/java/kr/spot/view/ViewableType.java | 15 ++++++ modules/post/build.gradle | 1 + .../application/ports/PostViewCounter.java | 8 --- .../application/ports/ViewAbuseGuard.java | 6 --- .../application/query/GetPostService.java | 13 ++--- .../infrastructure/rdb/DbViewAbuseGuard.java | 20 ++++--- .../redis/RedisPostViewCounter.java | 31 ----------- .../redis/RedisViewAbuseGuard.java | 17 +++--- .../redis/RedisViewCounter.java | 32 +++++++++++ .../application/query/GetPostServiceTest.java | 43 +++++++-------- .../redis/RedisViewAbuseGuardTest.java | 37 +++++++++---- ...terTest.java => RedisViewCounterTest.java} | 54 ++++++++++++------- settings.gradle | 1 + 16 files changed, 181 insertions(+), 113 deletions(-) create mode 100644 common/view/build.gradle create mode 100644 common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java create mode 100644 common/view/src/main/java/kr/spot/view/ViewCounter.java create mode 100644 common/view/src/main/java/kr/spot/view/ViewableType.java delete mode 100644 modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java delete mode 100644 modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java delete mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java create mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java rename modules/post/src/test/java/kr/spot/infrastructure/redis/{RedisPostViewHistoryCounterTest.java => RedisViewCounterTest.java} (51%) diff --git a/common/view/build.gradle b/common/view/build.gradle new file mode 100644 index 00000000..7d82dc72 --- /dev/null +++ b/common/view/build.gradle @@ -0,0 +1,2 @@ +dependencies { +} diff --git a/common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java b/common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java new file mode 100644 index 00000000..5f872bc6 --- /dev/null +++ b/common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java @@ -0,0 +1,6 @@ +package kr.spot.view; + +public interface ViewAbuseGuard { + + boolean shouldCount(ViewableType type, long targetId, long viewerId); +} diff --git a/common/view/src/main/java/kr/spot/view/ViewCounter.java b/common/view/src/main/java/kr/spot/view/ViewCounter.java new file mode 100644 index 00000000..5ffe9aeb --- /dev/null +++ b/common/view/src/main/java/kr/spot/view/ViewCounter.java @@ -0,0 +1,8 @@ +package kr.spot.view; + +public interface ViewCounter { + + long incrementAndGet(ViewableType type, long targetId); + + long currentDelta(ViewableType type, long targetId); +} diff --git a/common/view/src/main/java/kr/spot/view/ViewableType.java b/common/view/src/main/java/kr/spot/view/ViewableType.java new file mode 100644 index 00000000..11277ae8 --- /dev/null +++ b/common/view/src/main/java/kr/spot/view/ViewableType.java @@ -0,0 +1,15 @@ +package kr.spot.view; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ViewableType { + + POST("post"), + STUDY("study"), + STUDY_BOARD("study-board"); + + private final String keyPrefix; +} diff --git a/modules/post/build.gradle b/modules/post/build.gradle index 94311469..0a955723 100644 --- a/modules/post/build.gradle +++ b/modules/post/build.gradle @@ -8,6 +8,7 @@ dependencies { implementation project(":common:api") implementation project(":common:snowflake") implementation project(":common:bucket") + implementation project(":common:view") implementation project(":modules:shared") implementation project(":modules:member-api") diff --git a/modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java b/modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java deleted file mode 100644 index 1599d504..00000000 --- a/modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.spot.application.ports; - -public interface PostViewCounter { - - long incrementAndGetDelta(long postId); - - long currentDelta(long postId); -} diff --git a/modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java deleted file mode 100644 index ec29dd63..00000000 --- a/modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java +++ /dev/null @@ -1,6 +0,0 @@ -package kr.spot.application.ports; - -public interface ViewAbuseGuard { - - boolean shouldCount(long postId, long viewerId); -} \ No newline at end of file diff --git a/modules/post/src/main/java/kr/spot/application/query/GetPostService.java b/modules/post/src/main/java/kr/spot/application/query/GetPostService.java index 2c8121a2..bfaf4725 100644 --- a/modules/post/src/main/java/kr/spot/application/query/GetPostService.java +++ b/modules/post/src/main/java/kr/spot/application/query/GetPostService.java @@ -10,8 +10,9 @@ import java.util.Map; import java.util.Set; import kr.spot.application.ports.HotPostStore; -import kr.spot.application.ports.PostViewCounter; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; import kr.spot.domain.Comment; import kr.spot.domain.Post; import kr.spot.domain.PostStats; @@ -44,7 +45,7 @@ public class GetPostService { public static final int MAX_PAGE_SIZE = 50; public static final int MAX_CONTENT_LENGTH = 100; - private final PostViewCounter postViewCounter; + private final ViewCounter viewCounter; private final ViewAbuseGuard viewAbuseGuard; private final HotPostStore hotPostStore; private final PostRepository postRepository; @@ -126,10 +127,10 @@ public RecentPostResponse getRecentPosts() { private long getViewDeltaFromCounter(Long postId, Long viewerId) { long viewDelta = 0L; try { - if (viewAbuseGuard.shouldCount(postId, viewerId)) { - viewDelta = postViewCounter.incrementAndGetDelta(postId); // ๋ธํƒ€ ์ฆ๊ฐ€ ๋ฐ ํ˜„์žฌ๊ฐ’ ๋ฐ˜ํ™˜ + if (viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)) { + viewDelta = viewCounter.incrementAndGet(ViewableType.POST, postId); } else { - viewDelta = postViewCounter.currentDelta(postId); // ๋ธํƒ€๋งŒ ์กฐํšŒ + viewDelta = viewCounter.currentDelta(ViewableType.POST, postId); } } catch (Exception ignore) { log.warn("Redis view counter access failed for postId: {}", postId, ignore); diff --git a/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java index 8688a2af..a96b7ccc 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java @@ -1,35 +1,43 @@ package kr.spot.infrastructure.rdb; import kr.spot.IdGenerator; -import kr.spot.application.ports.ViewAbuseGuard; import kr.spot.infrastructure.jpa.PostViewHistoryRepository; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +/** + * DB ๊ธฐ๋ฐ˜ ์กฐํšŒ ์–ด๋ทฐ์ง• ๋ฐฉ์ง€ (ํ˜„์žฌ ๋ฏธ์‚ฌ์šฉ - Redis ์‚ฌ์šฉ ์ค‘) + * POST ํƒ€์ž…๋งŒ ์ง€์› + */ //@Component @Transactional(propagation = Propagation.REQUIRES_NEW) @RequiredArgsConstructor public class DbViewAbuseGuard implements ViewAbuseGuard { - public static final int TTL_MINUTES = 10; - public static final int ZERO = 0; + private static final int TTL_MINUTES = 10; + private static final int ZERO = 0; private final IdGenerator idGenerator; private final PostViewHistoryRepository postViewHistoryRepository; @Override - public boolean shouldCount(long postId, long viewerId) { + public boolean shouldCount(ViewableType type, long targetId, long viewerId) { + if (type != ViewableType.POST) { + throw new UnsupportedOperationException("DbViewAbuseGuard only supports POST type"); + } int updated = postViewHistoryRepository.touchIfExpired( - viewerId, postId, TTL_MINUTES + viewerId, targetId, TTL_MINUTES ); if (updated > ZERO) { return true; } int inserted = postViewHistoryRepository.insertIfAbsent( - idGenerator.nextId(), viewerId, postId + idGenerator.nextId(), viewerId, targetId ); return inserted > ZERO; } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java deleted file mode 100644 index 124c965d..00000000 --- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.spot.infrastructure.redis; - -import kr.spot.application.ports.PostViewCounter; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RedisPostViewCounter implements PostViewCounter { - - public static final String DELTA_KEY = "view:post:%d"; - - private final StringRedisTemplate redis; - - private static String deltaKey(long postId) { - return DELTA_KEY.formatted(postId); - } - - @Override - public long incrementAndGetDelta(long postId) { - Long v = redis.opsForValue().increment(deltaKey(postId)); - return v == null ? 0L : v; - } - - @Override - public long currentDelta(long postId) { - String v = redis.opsForValue().get(deltaKey(postId)); - return (v == null) ? 0L : Long.parseLong(v); - } -} diff --git a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java index 8a115d65..8c1d0140 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java @@ -1,7 +1,8 @@ package kr.spot.infrastructure.redis; import java.time.Duration; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; @@ -10,19 +11,19 @@ @RequiredArgsConstructor public class RedisViewAbuseGuard implements ViewAbuseGuard { - public static final String GUARD_KEY = "view:guard:%d:%d"; - public static final String VALUE = "1"; + private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; + private static final String VALUE = "1"; private static final Duration WINDOW = Duration.ofMinutes(10); - + private final StringRedisTemplate redis; - private static String guardKey(long postId, long viewerId) { - return GUARD_KEY.formatted(postId, viewerId); + private static String guardKey(ViewableType type, long targetId, long viewerId) { + return GUARD_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId, viewerId); } @Override - public boolean shouldCount(long postId, long viewerId) { - String key = guardKey(postId, viewerId); + public boolean shouldCount(ViewableType type, long targetId, long viewerId) { + String key = guardKey(type, targetId, viewerId); Boolean ok = redis.opsForValue().setIfAbsent(key, VALUE, WINDOW); return Boolean.TRUE.equals(ok); } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java new file mode 100644 index 00000000..b50e6c6b --- /dev/null +++ b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java @@ -0,0 +1,32 @@ +package kr.spot.infrastructure.redis; + +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisViewCounter implements ViewCounter { + + private static final String DELTA_KEY_FORMAT = "view:%s:%d"; + + private final StringRedisTemplate redis; + + private static String deltaKey(ViewableType type, long targetId) { + return DELTA_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId); + } + + @Override + public long incrementAndGet(ViewableType type, long targetId) { + Long v = redis.opsForValue().increment(deltaKey(type, targetId)); + return v == null ? 0L : v; + } + + @Override + public long currentDelta(ViewableType type, long targetId) { + String v = redis.opsForValue().get(deltaKey(type, targetId)); + return (v == null) ? 0L : Long.parseLong(v); + } +} diff --git a/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java b/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java index f70e3b26..f4df4a6d 100644 --- a/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java +++ b/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java @@ -19,8 +19,9 @@ import java.util.Set; import java.util.stream.IntStream; import kr.spot.application.ports.HotPostStore; -import kr.spot.application.ports.PostViewCounter; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; import kr.spot.code.status.ErrorStatus; import kr.spot.common.PostFixture; import kr.spot.domain.Post; @@ -49,7 +50,7 @@ class GetPostServiceTest { private static final String KEY = "popular:top3:total"; @Mock - PostViewCounter postViewCounter; + ViewCounter viewCounter; @Mock ViewAbuseGuard viewAbuseGuard; @@ -76,7 +77,7 @@ class GetPostServiceTest { @BeforeEach void setUp() { - getPostService = new GetPostService(postViewCounter, viewAbuseGuard, hotPostStore, + getPostService = new GetPostService(viewCounter, viewAbuseGuard, hotPostStore, postRepository, postQueryRepository, commentRepository, @@ -196,22 +197,22 @@ void should_get_post_without_comments() { // ---------------- ์ถ”๊ฐ€: ๋ทฐ ๋ธํƒ€/๊ฐ€๋“œ/์žฅ์•  ์ผ€์ด์Šค ---------------- @Test - @DisplayName("์–ด๋ทฐ์ง• ๊ฐ€๋“œ ํ†ต๊ณผ ์‹œ: incrementAndGetDelta๋ฅผ ํ˜ธ์ถœํ•ด DB viewCount + ๋ธํƒ€๋กœ ๋…ธ์ถœ") + @DisplayName("์–ด๋ทฐ์ง• ๊ฐ€๋“œ ํ†ต๊ณผ ์‹œ: incrementAndGet์„ ํ˜ธ์ถœํ•ด DB viewCount + ๋ธํƒ€๋กœ ๋…ธ์ถœ") void should_add_delta_when_guard_allows() { long postId = 1L, viewerId = 10L; Post post = post(); - PostStats stats = postStats(); // ์˜ˆ: DB viewCount = 0L ๊ฐ€์ • + PostStats stats = postStats(); when(postRepository.getPostById(postId)).thenReturn(post); when(postStatsRepository.getPostStatsById(postId)).thenReturn(stats); when(postImageRepository.getPostImageByPostId(postId)).thenReturn(postImage()); - when(viewAbuseGuard.shouldCount(postId, viewerId)).thenReturn(true); - when(postViewCounter.incrementAndGetDelta(postId)).thenReturn(5L); + when(viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)).thenReturn(true); + when(viewCounter.incrementAndGet(ViewableType.POST, postId)).thenReturn(5L); var res = getPostService.getPostDetail(postId, viewerId); - verify(viewAbuseGuard).shouldCount(postId, viewerId); - verify(postViewCounter).incrementAndGetDelta(postId); + verify(viewAbuseGuard).shouldCount(ViewableType.POST, postId, viewerId); + verify(viewCounter).incrementAndGet(ViewableType.POST, postId); assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount() + 5L); } @@ -220,18 +221,18 @@ void should_add_delta_when_guard_allows() { void should_use_current_delta_when_guard_blocks() { long postId = 1L, viewerId = 10L; Post post = post(); - PostStats stats = postStats(); // DB viewCount = 0L ๊ฐ€์ • + PostStats stats = postStats(); when(postRepository.getPostById(postId)).thenReturn(post); when(postStatsRepository.getPostStatsById(postId)).thenReturn(stats); when(postImageRepository.getPostImageByPostId(postId)).thenReturn(postImage()); - when(viewAbuseGuard.shouldCount(postId, viewerId)).thenReturn(false); - when(postViewCounter.currentDelta(postId)).thenReturn(7L); + when(viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)).thenReturn(false); + when(viewCounter.currentDelta(ViewableType.POST, postId)).thenReturn(7L); var res = getPostService.getPostDetail(postId, viewerId); - verify(viewAbuseGuard).shouldCount(postId, viewerId); - verify(postViewCounter).currentDelta(postId); + verify(viewAbuseGuard).shouldCount(ViewableType.POST, postId, viewerId); + verify(viewCounter).currentDelta(ViewableType.POST, postId); assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount() + 7L); } @@ -240,20 +241,20 @@ void should_use_current_delta_when_guard_blocks() { void should_fallback_to_db_count_when_redis_fails() { long postId = 1L, viewerId = 10L; Post post = post(); - PostStats stats = postStats(); // DB viewCount ์˜ˆ: 123L + PostStats stats = postStats(); when(postRepository.getPostById(postId)).thenReturn(post); when(postStatsRepository.getPostStatsById(postId)).thenReturn(stats); when(postImageRepository.getPostImageByPostId(postId)).thenReturn(postImage()); - when(viewAbuseGuard.shouldCount(postId, viewerId)).thenReturn(true); - when(postViewCounter.incrementAndGetDelta(postId)).thenThrow( + when(viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)).thenReturn(true); + when(viewCounter.incrementAndGet(ViewableType.POST, postId)).thenThrow( new RuntimeException("Redis down")); var res = getPostService.getPostDetail(postId, viewerId); - verify(viewAbuseGuard).shouldCount(postId, viewerId); - verify(postViewCounter).incrementAndGetDelta(postId); - assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount()); // ๋ธํƒ€ ๋ฏธ๋ฐ˜์˜ + verify(viewAbuseGuard).shouldCount(ViewableType.POST, postId, viewerId); + verify(viewCounter).incrementAndGet(ViewableType.POST, postId); + assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount()); } // ---------------- getPostList ---------------- diff --git a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java index 98665708..d5a896f9 100644 --- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java +++ b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java @@ -4,7 +4,8 @@ import static org.mockito.Mockito.when; import java.time.Duration; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,8 +16,9 @@ class RedisViewAbuseGuardTest { - public static final Duration TIMEOUT = Duration.ofMinutes(10); - private static final String GUARD_KEY = "view:guard:%d:%d"; + private static final Duration TIMEOUT = Duration.ofMinutes(10); + private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; + @Mock StringRedisTemplate redis; @@ -36,14 +38,14 @@ void setUp() { @DisplayName("์ด์ „์— ์กฐํšŒํ•œ ์  ์—†๋Š” ์‚ฌ์šฉ์ž๋Š” true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") void should_return_true_for_new_viewer() { // given - long postId = 1L; + long targetId = 1L; long viewerId = 2L; - String key = GUARD_KEY.formatted(postId, viewerId); + String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); // when - boolean result = guard.shouldCount(postId, viewerId); + boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); // then assertThat(result).isTrue(); @@ -53,16 +55,33 @@ void should_return_true_for_new_viewer() { @DisplayName("์ด์ „์— ์กฐํšŒํ•œ ์  ์žˆ๋Š” ์‚ฌ์šฉ์ž๋Š” false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") void should_return_false_for_existing_viewer() { // given - long postId = 1L; + long targetId = 1L; long viewerId = 2L; - String key = GUARD_KEY.formatted(postId, viewerId); + String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(false); // when - boolean result = guard.shouldCount(postId, viewerId); + boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); // then assertThat(result).isFalse(); } + + @Test + @DisplayName("STUDY ํƒ€์ž…์— ๋Œ€ํ•ด์„œ๋„ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.") + void should_work_for_study_type() { + // given + long studyId = 100L; + long viewerId = 2L; + String key = GUARD_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId, viewerId); + + when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); + + // when + boolean result = guard.shouldCount(ViewableType.STUDY, studyId, viewerId); + + // then + assertThat(result).isTrue(); + } } diff --git a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java similarity index 51% rename from modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java rename to modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java index bb79ba2e..2df4a232 100644 --- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java +++ b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java @@ -3,7 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; -import kr.spot.application.ports.PostViewCounter; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,7 +13,9 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; -class RedisPostViewHistoryCounterTest { +class RedisViewCounterTest { + + private static final String DELTA_KEY_FORMAT = "view:%s:%d"; @Mock StringRedisTemplate redis; @@ -20,25 +23,25 @@ class RedisPostViewHistoryCounterTest { @Mock ValueOperations valueOps; - PostViewCounter counter; + ViewCounter counter; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); when(redis.opsForValue()).thenReturn(valueOps); - counter = new RedisPostViewCounter(redis); + counter = new RedisViewCounter(redis); } @Test - @DisplayName("๊ฒŒ์‹œ๊ธ€์˜ ์กฐํšŒ์ˆ˜ ๋ธํƒ€๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๊ณ , ์ฆ๊ฐ€๋œ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("์กฐํšŒ์ˆ˜ ๋ธํƒ€๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๊ณ , ์ฆ๊ฐ€๋œ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") void should_increment_and_get_delta() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); when(valueOps.increment(key)).thenReturn(5L); // when - long delta = counter.incrementAndGetDelta(postId); + long delta = counter.incrementAndGet(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(5L); @@ -48,12 +51,12 @@ void should_increment_and_get_delta() { @DisplayName("๋ธํƒ€ ๊ฐ’ ์ฆ๊ฐ€ ์‹œ, ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์ด ์—†์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") void should_return_0_if_no_value() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); - when(valueOps.get(key)).thenReturn(null); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); + when(valueOps.increment(key)).thenReturn(null); // when - long delta = counter.incrementAndGetDelta(postId); + long delta = counter.incrementAndGet(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(0L); @@ -63,12 +66,12 @@ void should_return_0_if_no_value() { @DisplayName("ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์„ long ํƒ€์ž…์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") void should_return_current_delta() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); when(valueOps.get(key)).thenReturn("10"); // when - long delta = counter.currentDelta(postId); + long delta = counter.currentDelta(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(10L); @@ -78,14 +81,29 @@ void should_return_current_delta() { @DisplayName("ํ˜„์žฌ ๋ธํƒ€ ๊ฐ’ ์กฐํšŒ ์‹œ, ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์ด ์—†์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") void should_return_0_if_no_current_value() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); when(valueOps.get(key)).thenReturn(null); // when - long delta = counter.currentDelta(postId); + long delta = counter.currentDelta(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(0L); } + + @Test + @DisplayName("STUDY ํƒ€์ž…์— ๋Œ€ํ•ด์„œ๋„ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.") + void should_work_for_study_type() { + // given + long studyId = 100L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId); + when(valueOps.increment(key)).thenReturn(3L); + + // when + long delta = counter.incrementAndGet(ViewableType.STUDY, studyId); + + // then + assertThat(delta).isEqualTo(3L); + } } diff --git a/settings.gradle b/settings.gradle index d804bddb..b9948059 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,6 +6,7 @@ include 'app:config' include 'common:snowflake' include 'common:api' include 'common:bucket' +include 'common:view' include 'modules:shared' include 'modules:member' From a8e8978326e7cab36823279d99f420ea87c849d9 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 23:00:28 +0900 Subject: [PATCH 014/126] =?UTF-8?q?[Refactor]=20Redis=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84=EC=B2=B4=EB=A5=BC=20infra/view-r?= =?UTF-8?q?edis=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=20?= =?UTF-8?q?=20-=20infra/view-redis=20=EB=AA=A8=EB=93=88=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=20=20-=20RedisViewAbuseGuard,=20RedisViewCounter?= =?UTF-8?q?=EB=A5=BC=20post=20=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=20=20-=20post=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=B4=20infra:view-redis=20=EC=9D=98=EC=A1=B4=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20=20=20-=20study=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=EB=8F=84=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/post/build.gradle | 1 + .../redis/RedisViewAbuseGuard.java | 30 ----- .../redis/RedisViewCounter.java | 32 ----- .../redis/RedisViewAbuseGuardTest.java | 87 -------------- .../redis/RedisViewCounterTest.java | 109 ------------------ settings.gradle | 1 + 6 files changed, 2 insertions(+), 258 deletions(-) delete mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java delete mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java delete mode 100644 modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java delete mode 100644 modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java diff --git a/modules/post/build.gradle b/modules/post/build.gradle index 0a955723..f2d9ac25 100644 --- a/modules/post/build.gradle +++ b/modules/post/build.gradle @@ -9,6 +9,7 @@ dependencies { implementation project(":common:snowflake") implementation project(":common:bucket") implementation project(":common:view") + implementation project(":infra:view-redis") implementation project(":modules:shared") implementation project(":modules:member-api") diff --git a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java deleted file mode 100644 index 8c1d0140..00000000 --- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java +++ /dev/null @@ -1,30 +0,0 @@ -package kr.spot.infrastructure.redis; - -import java.time.Duration; -import kr.spot.view.ViewAbuseGuard; -import kr.spot.view.ViewableType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RedisViewAbuseGuard implements ViewAbuseGuard { - - private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; - private static final String VALUE = "1"; - private static final Duration WINDOW = Duration.ofMinutes(10); - - private final StringRedisTemplate redis; - - private static String guardKey(ViewableType type, long targetId, long viewerId) { - return GUARD_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId, viewerId); - } - - @Override - public boolean shouldCount(ViewableType type, long targetId, long viewerId) { - String key = guardKey(type, targetId, viewerId); - Boolean ok = redis.opsForValue().setIfAbsent(key, VALUE, WINDOW); - return Boolean.TRUE.equals(ok); - } -} diff --git a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java deleted file mode 100644 index b50e6c6b..00000000 --- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java +++ /dev/null @@ -1,32 +0,0 @@ -package kr.spot.infrastructure.redis; - -import kr.spot.view.ViewCounter; -import kr.spot.view.ViewableType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RedisViewCounter implements ViewCounter { - - private static final String DELTA_KEY_FORMAT = "view:%s:%d"; - - private final StringRedisTemplate redis; - - private static String deltaKey(ViewableType type, long targetId) { - return DELTA_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId); - } - - @Override - public long incrementAndGet(ViewableType type, long targetId) { - Long v = redis.opsForValue().increment(deltaKey(type, targetId)); - return v == null ? 0L : v; - } - - @Override - public long currentDelta(ViewableType type, long targetId) { - String v = redis.opsForValue().get(deltaKey(type, targetId)); - return (v == null) ? 0L : Long.parseLong(v); - } -} diff --git a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java deleted file mode 100644 index d5a896f9..00000000 --- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package kr.spot.infrastructure.redis; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import kr.spot.view.ViewAbuseGuard; -import kr.spot.view.ViewableType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -class RedisViewAbuseGuardTest { - - private static final Duration TIMEOUT = Duration.ofMinutes(10); - private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; - - @Mock - StringRedisTemplate redis; - - @Mock - ValueOperations valueOps; - - ViewAbuseGuard guard; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - when(redis.opsForValue()).thenReturn(valueOps); - guard = new RedisViewAbuseGuard(redis); - } - - @Test - @DisplayName("์ด์ „์— ์กฐํšŒํ•œ ์  ์—†๋Š” ์‚ฌ์šฉ์ž๋Š” true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - void should_return_true_for_new_viewer() { - // given - long targetId = 1L; - long viewerId = 2L; - String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); - - when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); - - // when - boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); - - // then - assertThat(result).isTrue(); - } - - @Test - @DisplayName("์ด์ „์— ์กฐํšŒํ•œ ์  ์žˆ๋Š” ์‚ฌ์šฉ์ž๋Š” false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - void should_return_false_for_existing_viewer() { - // given - long targetId = 1L; - long viewerId = 2L; - String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); - - when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(false); - - // when - boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); - - // then - assertThat(result).isFalse(); - } - - @Test - @DisplayName("STUDY ํƒ€์ž…์— ๋Œ€ํ•ด์„œ๋„ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.") - void should_work_for_study_type() { - // given - long studyId = 100L; - long viewerId = 2L; - String key = GUARD_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId, viewerId); - - when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); - - // when - boolean result = guard.shouldCount(ViewableType.STUDY, studyId, viewerId); - - // then - assertThat(result).isTrue(); - } -} diff --git a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java deleted file mode 100644 index 2df4a232..00000000 --- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package kr.spot.infrastructure.redis; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import kr.spot.view.ViewCounter; -import kr.spot.view.ViewableType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -class RedisViewCounterTest { - - private static final String DELTA_KEY_FORMAT = "view:%s:%d"; - - @Mock - StringRedisTemplate redis; - - @Mock - ValueOperations valueOps; - - ViewCounter counter; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - when(redis.opsForValue()).thenReturn(valueOps); - counter = new RedisViewCounter(redis); - } - - @Test - @DisplayName("์กฐํšŒ์ˆ˜ ๋ธํƒ€๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๊ณ , ์ฆ๊ฐ€๋œ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - void should_increment_and_get_delta() { - // given - long targetId = 1L; - String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); - when(valueOps.increment(key)).thenReturn(5L); - - // when - long delta = counter.incrementAndGet(ViewableType.POST, targetId); - - // then - assertThat(delta).isEqualTo(5L); - } - - @Test - @DisplayName("๋ธํƒ€ ๊ฐ’ ์ฆ๊ฐ€ ์‹œ, ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์ด ์—†์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - void should_return_0_if_no_value() { - // given - long targetId = 1L; - String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); - when(valueOps.increment(key)).thenReturn(null); - - // when - long delta = counter.incrementAndGet(ViewableType.POST, targetId); - - // then - assertThat(delta).isEqualTo(0L); - } - - @Test - @DisplayName("ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์„ long ํƒ€์ž…์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - void should_return_current_delta() { - // given - long targetId = 1L; - String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); - when(valueOps.get(key)).thenReturn("10"); - - // when - long delta = counter.currentDelta(ViewableType.POST, targetId); - - // then - assertThat(delta).isEqualTo(10L); - } - - @Test - @DisplayName("ํ˜„์žฌ ๋ธํƒ€ ๊ฐ’ ์กฐํšŒ ์‹œ, ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์ด ์—†์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - void should_return_0_if_no_current_value() { - // given - long targetId = 1L; - String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); - when(valueOps.get(key)).thenReturn(null); - - // when - long delta = counter.currentDelta(ViewableType.POST, targetId); - - // then - assertThat(delta).isEqualTo(0L); - } - - @Test - @DisplayName("STUDY ํƒ€์ž…์— ๋Œ€ํ•ด์„œ๋„ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.") - void should_work_for_study_type() { - // given - long studyId = 100L; - String key = DELTA_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId); - when(valueOps.increment(key)).thenReturn(3L); - - // when - long delta = counter.incrementAndGet(ViewableType.STUDY, studyId); - - // then - assertThat(delta).isEqualTo(3L); - } -} diff --git a/settings.gradle b/settings.gradle index b9948059..4b247681 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,3 +22,4 @@ include 'modules:study-api' include 'modules:region-api' include 'infra:bucket-s3' +include 'infra:view-redis' From c0d1a1dcdd163ad86f33f4c1581985fa378bd34a Mon Sep 17 00:00:00 2001 From: msk226 Date: Sat, 27 Dec 2025 23:00:32 +0900 Subject: [PATCH 015/126] =?UTF-8?q?[Refactor]=20Redis=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84=EC=B2=B4=EB=A5=BC=20infra/view-r?= =?UTF-8?q?edis=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=20?= =?UTF-8?q?=20-=20infra/view-redis=20=EB=AA=A8=EB=93=88=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=20=20-=20RedisViewAbuseGuard,=20RedisViewCounter?= =?UTF-8?q?=EB=A5=BC=20post=20=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=20=20-=20post=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=B4=20infra:view-redis=20=EC=9D=98=EC=A1=B4=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20=20=20-=20study=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=EB=8F=84=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/view-redis/build.gradle | 15 +++ .../spot/view/redis/RedisViewAbuseGuard.java | 30 +++++ .../kr/spot/view/redis/RedisViewCounter.java | 32 +++++ .../view/redis/RedisViewAbuseGuardTest.java | 87 ++++++++++++++ .../spot/view/redis/RedisViewCounterTest.java | 109 ++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 infra/view-redis/build.gradle create mode 100644 infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java create mode 100644 infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java create mode 100644 infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java create mode 100644 infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java diff --git a/infra/view-redis/build.gradle b/infra/view-redis/build.gradle new file mode 100644 index 00000000..d429b256 --- /dev/null +++ b/infra/view-redis/build.gradle @@ -0,0 +1,15 @@ +plugins { + id "io.spring.dependency-management" + id "java" +} + +dependencies { + implementation project(":common:view") + + // Spring Boot Starters + implementation "org.springframework.boot:spring-boot-starter-data-redis" + + // Testing + testImplementation "org.springframework.boot:spring-boot-starter-test" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" +} diff --git a/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java new file mode 100644 index 00000000..3d075cdb --- /dev/null +++ b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java @@ -0,0 +1,30 @@ +package kr.spot.view.redis; + +import java.time.Duration; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisViewAbuseGuard implements ViewAbuseGuard { + + private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; + private static final String VALUE = "1"; + private static final Duration WINDOW = Duration.ofMinutes(10); + + private final StringRedisTemplate redis; + + private static String guardKey(ViewableType type, long targetId, long viewerId) { + return GUARD_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId, viewerId); + } + + @Override + public boolean shouldCount(ViewableType type, long targetId, long viewerId) { + String key = guardKey(type, targetId, viewerId); + Boolean ok = redis.opsForValue().setIfAbsent(key, VALUE, WINDOW); + return Boolean.TRUE.equals(ok); + } +} diff --git a/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java new file mode 100644 index 00000000..6662c4e4 --- /dev/null +++ b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java @@ -0,0 +1,32 @@ +package kr.spot.view.redis; + +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisViewCounter implements ViewCounter { + + private static final String DELTA_KEY_FORMAT = "view:%s:%d"; + + private final StringRedisTemplate redis; + + private static String deltaKey(ViewableType type, long targetId) { + return DELTA_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId); + } + + @Override + public long incrementAndGet(ViewableType type, long targetId) { + Long v = redis.opsForValue().increment(deltaKey(type, targetId)); + return v == null ? 0L : v; + } + + @Override + public long currentDelta(ViewableType type, long targetId) { + String v = redis.opsForValue().get(deltaKey(type, targetId)); + return (v == null) ? 0L : Long.parseLong(v); + } +} diff --git a/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java new file mode 100644 index 00000000..35a90a67 --- /dev/null +++ b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java @@ -0,0 +1,87 @@ +package kr.spot.view.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +class RedisViewAbuseGuardTest { + + private static final Duration TIMEOUT = Duration.ofMinutes(10); + private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; + + @Mock + StringRedisTemplate redis; + + @Mock + ValueOperations valueOps; + + ViewAbuseGuard guard; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(redis.opsForValue()).thenReturn(valueOps); + guard = new RedisViewAbuseGuard(redis); + } + + @Test + @DisplayName("์ด์ „์— ์กฐํšŒํ•œ ์  ์—†๋Š” ์‚ฌ์šฉ์ž๋Š” true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void should_return_true_for_new_viewer() { + // given + long targetId = 1L; + long viewerId = 2L; + String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); + + when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); + + // when + boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("์ด์ „์— ์กฐํšŒํ•œ ์  ์žˆ๋Š” ์‚ฌ์šฉ์ž๋Š” false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void should_return_false_for_existing_viewer() { + // given + long targetId = 1L; + long viewerId = 2L; + String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); + + when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(false); + + // when + boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("STUDY ํƒ€์ž…์— ๋Œ€ํ•ด์„œ๋„ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.") + void should_work_for_study_type() { + // given + long studyId = 100L; + long viewerId = 2L; + String key = GUARD_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId, viewerId); + + when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); + + // when + boolean result = guard.shouldCount(ViewableType.STUDY, studyId, viewerId); + + // then + assertThat(result).isTrue(); + } +} diff --git a/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java new file mode 100644 index 00000000..57335230 --- /dev/null +++ b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java @@ -0,0 +1,109 @@ +package kr.spot.view.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +class RedisViewCounterTest { + + private static final String DELTA_KEY_FORMAT = "view:%s:%d"; + + @Mock + StringRedisTemplate redis; + + @Mock + ValueOperations valueOps; + + ViewCounter counter; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(redis.opsForValue()).thenReturn(valueOps); + counter = new RedisViewCounter(redis); + } + + @Test + @DisplayName("์กฐํšŒ์ˆ˜ ๋ธํƒ€๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๊ณ , ์ฆ๊ฐ€๋œ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void should_increment_and_get_delta() { + // given + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); + when(valueOps.increment(key)).thenReturn(5L); + + // when + long delta = counter.incrementAndGet(ViewableType.POST, targetId); + + // then + assertThat(delta).isEqualTo(5L); + } + + @Test + @DisplayName("๋ธํƒ€ ๊ฐ’ ์ฆ๊ฐ€ ์‹œ, ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์ด ์—†์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void should_return_0_if_no_value() { + // given + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); + when(valueOps.increment(key)).thenReturn(null); + + // when + long delta = counter.incrementAndGet(ViewableType.POST, targetId); + + // then + assertThat(delta).isEqualTo(0L); + } + + @Test + @DisplayName("ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์„ long ํƒ€์ž…์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void should_return_current_delta() { + // given + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); + when(valueOps.get(key)).thenReturn("10"); + + // when + long delta = counter.currentDelta(ViewableType.POST, targetId); + + // then + assertThat(delta).isEqualTo(10L); + } + + @Test + @DisplayName("ํ˜„์žฌ ๋ธํƒ€ ๊ฐ’ ์กฐํšŒ ์‹œ, ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ๊ฐ’์ด ์—†์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + void should_return_0_if_no_current_value() { + // given + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); + when(valueOps.get(key)).thenReturn(null); + + // when + long delta = counter.currentDelta(ViewableType.POST, targetId); + + // then + assertThat(delta).isEqualTo(0L); + } + + @Test + @DisplayName("STUDY ํƒ€์ž…์— ๋Œ€ํ•ด์„œ๋„ ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค.") + void should_work_for_study_type() { + // given + long studyId = 100L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId); + when(valueOps.increment(key)).thenReturn(3L); + + // when + long delta = counter.incrementAndGet(ViewableType.STUDY, studyId); + + // then + assertThat(delta).isEqualTo(3L); + } +} From 03020b928b8b0e83eac7f1cd0d9f833757f849d9 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 13:29:43 +0900 Subject: [PATCH 016/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EA=B4=80=EB=A0=A8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/kr/spot/post/Comment.java | 44 +++++++++++++++++++ .../src/main/java/kr/spot/post/Post.java | 44 +++++++++++++++++++ .../src/main/java/kr/spot/post/PostStats.java | 37 ++++++++++++++++ .../main/java/kr/spot/post/vo/WriterInfo.java | 39 ++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/post/Comment.java create mode 100644 modules/study/src/main/java/kr/spot/post/Post.java create mode 100644 modules/study/src/main/java/kr/spot/post/PostStats.java create mode 100644 modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java diff --git a/modules/study/src/main/java/kr/spot/post/Comment.java b/modules/study/src/main/java/kr/spot/post/Comment.java new file mode 100644 index 00000000..2dd40801 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/Comment.java @@ -0,0 +1,44 @@ +package kr.spot.post; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import kr.spot.post.vo.WriterInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity(name = "StudyPostComment") +@SQLDelete(sql = "UPDATE study_post_comment SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Comment extends BaseEntity { + + @Id + private Long id; + + private Long postId; + + private WriterInfo writerInfo; + + private String content; + + public static Comment of(Long id, Long postId, WriterInfo writerInfo, String content) { + return new Comment(id, postId, writerInfo, content); + } + + public void update(Long currentMemberId, String content) { + writerInfo.validateIsOwnMember(currentMemberId); + this.content = content; + } + + public void delete(Long currentMemberId) { + writerInfo.validateIsOwnMember(currentMemberId); + super.delete(); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/Post.java b/modules/study/src/main/java/kr/spot/post/Post.java new file mode 100644 index 00000000..b63b713f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/Post.java @@ -0,0 +1,44 @@ +package kr.spot.post; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import kr.spot.post.vo.WriterInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity(name = "StudyPost") +@SQLDelete(sql = "UPDATE study_post SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Post extends BaseEntity { + + @Id + private Long id; + + @Embedded + private WriterInfo writerInfo; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + private boolean isPinned; + + private boolean isPrivate; + + public static Post of(Long id, WriterInfo writerInfo, String title, String content, + boolean isPinned, boolean isPrivate) { + return new Post(id, writerInfo, title, content, isPinned, isPrivate); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/PostStats.java b/modules/study/src/main/java/kr/spot/post/PostStats.java new file mode 100644 index 00000000..01e14762 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/PostStats.java @@ -0,0 +1,37 @@ +package kr.spot.post; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity(name = "StudyPostStats") +@SQLDelete(sql = "UPDATE study_post_stats SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class PostStats extends BaseEntity { + + @Id + private Long postId; + + private Long viewCount; + + private Long likeCount; + + private Long commentCount; + + public static PostStats of(Long postId) { + return new PostStats(postId, 0L, 0L, 0L); + } + + public static PostStats of(Long postId, Long viewCount, Long likeCount, Long commentCount) { + return new PostStats(postId, viewCount, likeCount, commentCount); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java b/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java new file mode 100644 index 00000000..5c69c637 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java @@ -0,0 +1,39 @@ +package kr.spot.post.vo; + +import jakarta.persistence.Embeddable; +import java.util.Objects; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class WriterInfo { + + private Long writerId; + + private String writerName; + + private String WriterProfileImageUrl; + + public static WriterInfo of(Long writerId, String writerName, String writerProfileImageUrl) { + return new WriterInfo(writerId, writerName, writerProfileImageUrl); + } + + public void validateIsOwnMember(Long currentUserId) { + if (!Objects.equals(writerId, currentUserId)) { + throw new GeneralException(ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + } + + public boolean isSameWriter(Long memberId) { + return Objects.equals(this.writerId, memberId); + } +} From 857cd458fbf3c726204208f77114f13701cbbc64 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 13:30:47 +0900 Subject: [PATCH 017/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EA=B4=80=EB=A0=A8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20domain=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/kr/spot/post/{ => domain}/Comment.java | 4 ++-- .../study/src/main/java/kr/spot/post/{ => domain}/Post.java | 4 ++-- .../src/main/java/kr/spot/post/{ => domain}/PostStats.java | 2 +- .../main/java/kr/spot/post/{ => domain}/vo/WriterInfo.java | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename modules/study/src/main/java/kr/spot/post/{ => domain}/Comment.java (94%) rename modules/study/src/main/java/kr/spot/post/{ => domain}/Post.java (94%) rename modules/study/src/main/java/kr/spot/post/{ => domain}/PostStats.java (97%) rename modules/study/src/main/java/kr/spot/post/{ => domain}/vo/WriterInfo.java (96%) diff --git a/modules/study/src/main/java/kr/spot/post/Comment.java b/modules/study/src/main/java/kr/spot/post/domain/Comment.java similarity index 94% rename from modules/study/src/main/java/kr/spot/post/Comment.java rename to modules/study/src/main/java/kr/spot/post/domain/Comment.java index 2dd40801..56e08d18 100644 --- a/modules/study/src/main/java/kr/spot/post/Comment.java +++ b/modules/study/src/main/java/kr/spot/post/domain/Comment.java @@ -1,9 +1,9 @@ -package kr.spot.post; +package kr.spot.post.domain; import jakarta.persistence.Entity; import jakarta.persistence.Id; import kr.spot.domain.BaseEntity; -import kr.spot.post.vo.WriterInfo; +import kr.spot.post.domain.vo.WriterInfo; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/post/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java similarity index 94% rename from modules/study/src/main/java/kr/spot/post/Post.java rename to modules/study/src/main/java/kr/spot/post/domain/Post.java index b63b713f..79a45f57 100644 --- a/modules/study/src/main/java/kr/spot/post/Post.java +++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java @@ -1,11 +1,11 @@ -package kr.spot.post; +package kr.spot.post.domain; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Id; import kr.spot.domain.BaseEntity; -import kr.spot.post.vo.WriterInfo; +import kr.spot.post.domain.vo.WriterInfo; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/post/PostStats.java b/modules/study/src/main/java/kr/spot/post/domain/PostStats.java similarity index 97% rename from modules/study/src/main/java/kr/spot/post/PostStats.java rename to modules/study/src/main/java/kr/spot/post/domain/PostStats.java index 01e14762..b7be7ce7 100644 --- a/modules/study/src/main/java/kr/spot/post/PostStats.java +++ b/modules/study/src/main/java/kr/spot/post/domain/PostStats.java @@ -1,4 +1,4 @@ -package kr.spot.post; +package kr.spot.post.domain; import jakarta.persistence.Entity; import jakarta.persistence.Id; diff --git a/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java b/modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java similarity index 96% rename from modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java rename to modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java index 5c69c637..8437abb6 100644 --- a/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java +++ b/modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java @@ -1,4 +1,4 @@ -package kr.spot.post.vo; +package kr.spot.post.domain.vo; import jakarta.persistence.Embeddable; import java.util.Objects; From 0c59e9f0ef3a56fdcd9681976fd3ad3a1eff6b3e Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 14:01:25 +0900 Subject: [PATCH 018/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=9D=EC=84=B1/=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 1 + .../command/ManagePostService.java | 65 +++++++++++++++++++ .../main/java/kr/spot/post/domain/Post.java | 30 ++++++++- .../infrastructure/jpa/CommentRepository.java | 8 +++ .../infrastructure/jpa/PostRepository.java | 14 ++++ .../jpa/PostStatsRepository.java | 8 +++ .../command/PostCommandController.java | 61 +++++++++++++++++ .../command/dto/ManagePostRequest.java | 6 ++ 8 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index d0d5e295..33b64d73 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -48,6 +48,7 @@ public enum ErrorStatus implements BaseErrorCode { "์…€ํ”„ ์Šน์ธ์„ ์œ„ํ•œ ์œ ํšจํ•˜์ง€ ์•Š์€ ์Šคํ„ฐ๋”” ๋ฉค๋ฒ„ ์ƒํƒœ์ž…๋‹ˆ๋‹ค."), _STUDY_ALREADY_APPLIED(400, "STUDY4006", "์ด๋ฏธ ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์ง€์›ํ•œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค."), _STUDY_ACCESS_DENIED(403, "STUDY4032", "์Šคํ„ฐ๋””์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."), + _INVALID_STUDY_ACCESS(403, "STUDY4033", "์œ ํšจํ•˜์ง€ ์•Š์€ ์Šคํ„ฐ๋”” ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค."), // ๊ฒŒ์‹œ๊ธ€ ๊ด€๋ จ _POST_NOT_FOUND(404, "POST404", "๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), diff --git a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java new file mode 100644 index 00000000..0460e382 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java @@ -0,0 +1,65 @@ +package kr.spot.post.application.command; + +import kr.spot.IdGenerator; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.domain.vo.WriterInfo; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.post.presentation.command.dto.ManagePostRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManagePostService { + + private final IdGenerator idGenerator; + private final GetWriterInfoPort getWriterInfoPort; + private final PostRepository postRepository; + private final PostStatsRepository postStatsRepository; + + public void createPost(long studyId, long writerId, ManagePostRequest request) { + WriterInfo writerInfo = getWriterInfo(writerId); + Post post = createAndSavePost(request, studyId, writerInfo); + initializeAndSavePostStats(post); + } + + public void updatePost(long studyId, long postId, ManagePostRequest request, long writerId) { + Post post = postRepository.getById(postId); + post.update(request.title(), request.content(), request.isPrivate(), writerId, studyId); + } + + public void deletePost(long studyId, long postId, long writerId) { + Post post = postRepository.getById(postId); + post.delete(writerId, studyId); + } + + public void pinPost(long studyId, long postId) { + Post post = postRepository.getById(postId); + + } + + private WriterInfo getWriterInfo(long writerId) { + WriterInfoResponse writerInfoResponse = getWriterInfoPort.get(writerId); + return WriterInfo.of(writerInfoResponse.writerId(), writerInfoResponse.nickname(), + writerInfoResponse.profileImageUrl()); + } + + private Post createAndSavePost(ManagePostRequest request, long studyId, WriterInfo writerInfo) { + Post post = Post.of(idGenerator.nextId(), studyId, writerInfo, request.title(), + request.content(), + request.isPrivate()); + postRepository.save(post); + return post; + } + + private void initializeAndSavePostStats(Post post) { + PostStats postStats = PostStats.of(post.getId()); + postStatsRepository.save(postStats); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java index 79a45f57..ba34b96e 100644 --- a/modules/study/src/main/java/kr/spot/post/domain/Post.java +++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java @@ -4,7 +4,9 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import kr.spot.code.status.ErrorStatus; import kr.spot.domain.BaseEntity; +import kr.spot.exception.GeneralException; import kr.spot.post.domain.vo.WriterInfo; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -24,6 +26,8 @@ public class Post extends BaseEntity { @Id private Long id; + private Long studyId; + @Embedded private WriterInfo writerInfo; @@ -37,8 +41,28 @@ public class Post extends BaseEntity { private boolean isPrivate; - public static Post of(Long id, WriterInfo writerInfo, String title, String content, - boolean isPinned, boolean isPrivate) { - return new Post(id, writerInfo, title, content, isPinned, isPrivate); + public static Post of(Long id, Long studyId, WriterInfo writerInfo, String title, String content, + boolean isPrivate) { + return new Post(id, studyId, writerInfo, title, content, false, isPrivate); + } + + public void update(String title, String content, boolean isPrivate, long memberId, long studyId) { + validateStudyId(studyId); + writerInfo.validateIsOwnMember(memberId); + this.title = title; + this.content = content; + this.isPrivate = isPrivate; + } + + public void delete(long memberId, long studyId) { + validateStudyId(studyId); + writerInfo.validateIsOwnMember(memberId); + super.delete(); + } + + private void validateStudyId(long studyId) { + if (this.studyId != studyId) { + throw new GeneralException(ErrorStatus._INVALID_STUDY_ACCESS); + } } } diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java new file mode 100644 index 00000000..66a6ced9 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java @@ -0,0 +1,8 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.post.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java new file mode 100644 index 00000000..2ae79226 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java @@ -0,0 +1,14 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.post.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { + + default Post getById(long postId) { + return findById(postId).orElseThrow( + () -> new GeneralException(ErrorStatus._POST_NOT_FOUND)); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java new file mode 100644 index 00000000..982bc361 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java @@ -0,0 +1,8 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.post.domain.PostStats; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostStatsRepository extends JpaRepository { + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java new file mode 100644 index 00000000..df8ae38a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java @@ -0,0 +1,61 @@ +package kr.spot.post.presentation.command; + +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.post.application.command.ManagePostService; +import kr.spot.post.presentation.command.dto.ManagePostRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€") +@RestController("studyPostCommandController") +@RequestMapping("/api/studies/{studyId}/posts") +@RequiredArgsConstructor +public class PostCommandController { + + private final ManagePostService managePostService; + + @PostMapping + public ResponseEntity> createPost( + @PathVariable Long studyId, + @CurrentMember Long writerId, + @RequestBody ManagePostRequest request) { + managePostService.createPost(studyId, writerId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @PutMapping("/{postId}") + public ResponseEntity> updatePost( + @PathVariable Long studyId, + @PathVariable Long postId, + @CurrentMember Long writerId, + @RequestBody ManagePostRequest request) { + managePostService.updatePost(studyId, postId, request, writerId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @DeleteMapping("/{postId}") + public ResponseEntity> deletePost( + @PathVariable Long studyId, + @PathVariable Long postId, + @CurrentMember Long writerId) { + managePostService.deletePost(studyId, postId, writerId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @PostMapping("/{postId}/pin") + public ResponseEntity> pinPost( + @PathVariable Long studyId, + @PathVariable Long postId) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java new file mode 100644 index 00000000..cd0b4d53 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java @@ -0,0 +1,6 @@ +package kr.spot.post.presentation.command.dto; + +public record ManagePostRequest( + String title, String content, Boolean isPrivate) { + +} From 55d9391cf24d9fd8707d92b8e3842f239c84a2f6 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 14:07:56 +0900 Subject: [PATCH 019/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=95=80=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManagePostService.java | 5 +++++ .../main/java/kr/spot/post/domain/Post.java | 19 +++++++++++++++++-- .../command/PostCommandController.java | 11 ++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java index 0460e382..10b6bf7f 100644 --- a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java +++ b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java @@ -41,7 +41,12 @@ public void deletePost(long studyId, long postId, long writerId) { public void pinPost(long studyId, long postId) { Post post = postRepository.getById(postId); + post.pin(studyId); + } + public void unpinPost(long studyId, long postId) { + Post post = postRepository.getById(postId); + post.unpin(studyId); } private WriterInfo getWriterInfo(long writerId) { diff --git a/modules/study/src/main/java/kr/spot/post/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java index ba34b96e..91048e9f 100644 --- a/modules/study/src/main/java/kr/spot/post/domain/Post.java +++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java @@ -4,6 +4,7 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import java.time.LocalDateTime; import kr.spot.code.status.ErrorStatus; import kr.spot.domain.BaseEntity; import kr.spot.exception.GeneralException; @@ -37,13 +38,13 @@ public class Post extends BaseEntity { @Column(nullable = false) private String content; - private boolean isPinned; + private LocalDateTime pinnedAt; private boolean isPrivate; public static Post of(Long id, Long studyId, WriterInfo writerInfo, String title, String content, boolean isPrivate) { - return new Post(id, studyId, writerInfo, title, content, false, isPrivate); + return new Post(id, studyId, writerInfo, title, content, null, isPrivate); } public void update(String title, String content, boolean isPrivate, long memberId, long studyId) { @@ -60,6 +61,20 @@ public void delete(long memberId, long studyId) { super.delete(); } + public void pin(long studyId) { + validateStudyId(studyId); + this.pinnedAt = LocalDateTime.now(); + } + + public void unpin(long studyId) { + validateStudyId(studyId); + this.pinnedAt = null; + } + + public boolean isPinned() { + return pinnedAt != null; + } + private void validateStudyId(long studyId) { if (this.studyId != studyId) { throw new GeneralException(ErrorStatus._INVALID_STUDY_ACCESS); diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java index df8ae38a..cdf3f3d2 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java @@ -56,6 +56,15 @@ public ResponseEntity> deletePost( public ResponseEntity> pinPost( @PathVariable Long studyId, @PathVariable Long postId) { - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + managePostService.pinPost(studyId, postId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @DeleteMapping("/{postId}/pin") + public ResponseEntity> unpinPost( + @PathVariable Long studyId, + @PathVariable Long postId) { + managePostService.unpinPost(studyId, postId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } } From 47f1e9fd93ca4631c28419a518404a18d2ca4026 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 14:12:30 +0900 Subject: [PATCH 020/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20command=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManagePostServiceTest.java | 373 ++++++++++++++++++ .../java/kr/spot/post/common/PostFixture.java | 46 +++ .../java/kr/spot/post/domain/PostTest.java | 239 +++++++++++ 3 files changed, 658 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/post/common/PostFixture.java create mode 100644 modules/study/src/test/java/kr/spot/post/domain/PostTest.java diff --git a/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java new file mode 100644 index 00000000..e2804ada --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java @@ -0,0 +1,373 @@ +package kr.spot.post.application.command; + +import static kr.spot.post.common.PostFixture.CONTENT; +import static kr.spot.post.common.PostFixture.IS_PRIVATE; +import static kr.spot.post.common.PostFixture.STUDY_ID; +import static kr.spot.post.common.PostFixture.TITLE; +import static kr.spot.post.common.PostFixture.WRITER_ID; +import static kr.spot.post.common.PostFixture.WRITER_NAME; +import static kr.spot.post.common.PostFixture.WRITER_PROFILE_IMAGE_URL; +import static kr.spot.post.common.PostFixture.managePostRequest; +import static kr.spot.post.common.PostFixture.post; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.post.presentation.command.dto.ManagePostRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManagePostServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + GetWriterInfoPort getWriterInfoPort; + + @Mock + PostRepository postRepository; + + @Mock + PostStatsRepository postStatsRepository; + + @Captor + ArgumentCaptor postCaptor; + + @Captor + ArgumentCaptor postStatsCaptor; + + ManagePostService managePostService; + + @BeforeEach + void setUp() { + managePostService = new ManagePostService( + idGenerator, getWriterInfoPort, postRepository, postStatsRepository); + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ (createPost)") + class CreatePost { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_post_successfully() { + // given + Long generatedId = 1L; + ManagePostRequest request = managePostRequest(); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of( + WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); + + when(idGenerator.nextId()).thenReturn(generatedId); + when(getWriterInfoPort.get(WRITER_ID)).thenReturn(writerInfoResponse); + when(postRepository.save(any(Post.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + managePostService.createPost(STUDY_ID, WRITER_ID, request); + + // then + verify(postRepository).save(postCaptor.capture()); + verify(postStatsRepository).save(postStatsCaptor.capture()); + + Post capturedPost = postCaptor.getValue(); + assertThat(capturedPost.getId()).isEqualTo(generatedId); + assertThat(capturedPost.getStudyId()).isEqualTo(STUDY_ID); + assertThat(capturedPost.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID); + assertThat(capturedPost.getTitle()).isEqualTo(TITLE); + assertThat(capturedPost.getContent()).isEqualTo(CONTENT); + assertThat(capturedPost.isPrivate()).isEqualTo(IS_PRIVATE); + + PostStats capturedPostStats = postStatsCaptor.getValue(); + assertThat(capturedPostStats.getPostId()).isEqualTo(generatedId); + assertThat(capturedPostStats.getViewCount()).isZero(); + assertThat(capturedPostStats.getLikeCount()).isZero(); + assertThat(capturedPostStats.getCommentCount()).isZero(); + } + + @Test + @DisplayName("๋น„๊ณต๊ฐœ ๊ฒŒ์‹œ๊ธ€์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_private_post() { + // given + Long generatedId = 1L; + ManagePostRequest request = managePostRequest("์ œ๋ชฉ", "๋‚ด์šฉ", true); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of( + WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); + + when(idGenerator.nextId()).thenReturn(generatedId); + when(getWriterInfoPort.get(WRITER_ID)).thenReturn(writerInfoResponse); + when(postRepository.save(any(Post.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + managePostService.createPost(STUDY_ID, WRITER_ID, request); + + // then + verify(postRepository).save(postCaptor.capture()); + + Post capturedPost = postCaptor.getValue(); + assertThat(capturedPost.isPrivate()).isTrue(); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ • (updatePost)") + class UpdatePost { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_post_successfully() { + // given + long postId = 1L; + Post post = post(postId, STUDY_ID, WRITER_ID); + ManagePostRequest request = managePostRequest("์ˆ˜์ •๋œ ์ œ๋ชฉ", "์ˆ˜์ •๋œ ๋‚ด์šฉ", true); + + when(postRepository.getById(postId)).thenReturn(post); + + // when + managePostService.updatePost(STUDY_ID, postId, request, WRITER_ID); + + // then + assertThat(post.getTitle()).isEqualTo("์ˆ˜์ •๋œ ์ œ๋ชฉ"); + assertThat(post.getContent()).isEqualTo("์ˆ˜์ •๋œ ๋‚ด์šฉ"); + assertThat(post.isPrivate()).isTrue(); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_updates() { + // given + long postId = 1L; + long otherMemberId = 999L; + Post post = post(postId, STUDY_ID, WRITER_ID); + ManagePostRequest request = managePostRequest(); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy( + () -> managePostService.updatePost(STUDY_ID, postId, request, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_from_different_study() { + // given + long postId = 1L; + long otherStudyId = 999L; + Post post = post(postId, STUDY_ID, WRITER_ID); + ManagePostRequest request = managePostRequest(); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy( + () -> managePostService.updatePost(otherStudyId, postId, request, WRITER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒŒ์‹œ๊ธ€์„ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_not_found() { + // given + long postId = 999L; + ManagePostRequest request = managePostRequest(); + + when(postRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)); + + // when & then + assertThatThrownBy( + () -> managePostService.updatePost(STUDY_ID, postId, request, WRITER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ (deletePost)") + class DeletePost { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_post_successfully() { + // given + long postId = 1L; + Post post = post(postId, STUDY_ID, WRITER_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when + managePostService.deletePost(STUDY_ID, postId, WRITER_ID); + + // then + verify(postRepository).getById(postId); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_deletes() { + // given + long postId = 1L; + long otherMemberId = 999L; + Post post = post(postId, STUDY_ID, WRITER_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy( + () -> managePostService.deletePost(STUDY_ID, postId, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_from_different_study() { + // given + long postId = 1L; + long otherStudyId = 999L; + Post post = post(postId, STUDY_ID, WRITER_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy( + () -> managePostService.deletePost(otherStudyId, postId, WRITER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ํ•€ (pinPost)") + class PinPost { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ํ•€ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_pin_post_successfully() { + // given + long postId = 1L; + Post post = post(postId, STUDY_ID, WRITER_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when + managePostService.pinPost(STUDY_ID, postId); + + // then + assertThat(post.isPinned()).isTrue(); + assertThat(post.getPinnedAt()).isNotNull(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_pin_from_different_study() { + // given + long postId = 1L; + long otherStudyId = 999L; + Post post = post(postId, STUDY_ID, WRITER_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy(() -> managePostService.pinPost(otherStudyId, postId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒŒ์‹œ๊ธ€์„ ํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_not_found() { + // given + long postId = 999L; + + when(postRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์–ธํ•€ (unpinPost)") + class UnpinPost { + + @Test + @DisplayName("ํ•€๋œ ๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์–ธํ•€ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_unpin_post_successfully() { + // given + long postId = 1L; + Post post = post(postId, STUDY_ID, WRITER_ID); + post.pin(STUDY_ID); + assertThat(post.isPinned()).isTrue(); + + when(postRepository.getById(postId)).thenReturn(post); + + // when + managePostService.unpinPost(STUDY_ID, postId); + + // then + assertThat(post.isPinned()).isFalse(); + assertThat(post.getPinnedAt()).isNull(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ์–ธํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_unpin_from_different_study() { + // given + long postId = 1L; + long otherStudyId = 999L; + Post post = post(postId, STUDY_ID, WRITER_ID); + post.pin(STUDY_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy(() -> managePostService.unpinPost(otherStudyId, postId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒŒ์‹œ๊ธ€์„ ์–ธํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_not_found() { + // given + long postId = 999L; + + when(postRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/post/common/PostFixture.java b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java new file mode 100644 index 00000000..d00440f4 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java @@ -0,0 +1,46 @@ +package kr.spot.post.common; + +import kr.spot.post.domain.Post; +import kr.spot.post.domain.vo.WriterInfo; +import kr.spot.post.presentation.command.dto.ManagePostRequest; + +public class PostFixture { + + public static final Long ID = 1L; + public static final Long STUDY_ID = 100L; + public static final Long WRITER_ID = 200L; + public static final String WRITER_NAME = "ํ…Œ์ŠคํŠธ์œ ์ €"; + public static final String WRITER_PROFILE_IMAGE_URL = "https://example.com/profile.jpg"; + public static final String TITLE = "ํ…Œ์ŠคํŠธ ๊ฒŒ์‹œ๊ธ€ ์ œ๋ชฉ"; + public static final String CONTENT = "ํ…Œ์ŠคํŠธ ๊ฒŒ์‹œ๊ธ€ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค."; + public static final boolean IS_PRIVATE = false; + + public static WriterInfo writerInfo() { + return WriterInfo.of(WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); + } + + public static WriterInfo writerInfo(Long writerId) { + return WriterInfo.of(writerId, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); + } + + public static Post post() { + return Post.of(ID, STUDY_ID, writerInfo(), TITLE, CONTENT, IS_PRIVATE); + } + + public static Post post(Long id, Long studyId) { + return Post.of(id, studyId, writerInfo(), TITLE, CONTENT, IS_PRIVATE); + } + + public static Post post(Long id, Long studyId, Long writerId) { + return Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, IS_PRIVATE); + } + + public static ManagePostRequest managePostRequest() { + return new ManagePostRequest(TITLE, CONTENT, IS_PRIVATE); + } + + public static ManagePostRequest managePostRequest(String title, String content, + boolean isPrivate) { + return new ManagePostRequest(title, content, isPrivate); + } +} diff --git a/modules/study/src/test/java/kr/spot/post/domain/PostTest.java b/modules/study/src/test/java/kr/spot/post/domain/PostTest.java new file mode 100644 index 00000000..fef1e8e3 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/domain/PostTest.java @@ -0,0 +1,239 @@ +package kr.spot.post.domain; + +import static kr.spot.post.common.PostFixture.CONTENT; +import static kr.spot.post.common.PostFixture.ID; +import static kr.spot.post.common.PostFixture.IS_PRIVATE; +import static kr.spot.post.common.PostFixture.STUDY_ID; +import static kr.spot.post.common.PostFixture.TITLE; +import static kr.spot.post.common.PostFixture.WRITER_ID; +import static kr.spot.post.common.PostFixture.post; +import static kr.spot.post.common.PostFixture.writerInfo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PostTest { + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ (of)") + class CreatePost { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€ ๊ฐ์ฒด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_post_successfully() { + // when + Post post = Post.of(ID, STUDY_ID, writerInfo(), TITLE, CONTENT, IS_PRIVATE); + + // then + assertThat(post).isNotNull(); + assertThat(post.getId()).isEqualTo(ID); + assertThat(post.getStudyId()).isEqualTo(STUDY_ID); + assertThat(post.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID); + assertThat(post.getTitle()).isEqualTo(TITLE); + assertThat(post.getContent()).isEqualTo(CONTENT); + assertThat(post.isPrivate()).isEqualTo(IS_PRIVATE); + assertThat(post.isPinned()).isFalse(); + } + + @Test + @DisplayName("๋น„๊ณต๊ฐœ ๊ฒŒ์‹œ๊ธ€์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_private_post() { + // when + Post post = Post.of(ID, STUDY_ID, writerInfo(), TITLE, CONTENT, true); + + // then + assertThat(post.isPrivate()).isTrue(); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ • (update)") + class UpdatePost { + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_post_successfully() { + // given + Post post = post(); + String newTitle = "์ˆ˜์ •๋œ ์ œ๋ชฉ"; + String newContent = "์ˆ˜์ •๋œ ๋‚ด์šฉ"; + boolean newIsPrivate = true; + + // when + post.update(newTitle, newContent, newIsPrivate, WRITER_ID, STUDY_ID); + + // then + assertThat(post.getTitle()).isEqualTo(newTitle); + assertThat(post.getContent()).isEqualTo(newContent); + assertThat(post.isPrivate()).isEqualTo(newIsPrivate); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_updates() { + // given + Post post = post(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy( + () -> post.update("์ƒˆ ์ œ๋ชฉ", "์ƒˆ ๋‚ด์šฉ", false, otherMemberId, STUDY_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_from_different_study() { + // given + Post post = post(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy( + () -> post.update("์ƒˆ ์ œ๋ชฉ", "์ƒˆ ๋‚ด์šฉ", false, WRITER_ID, otherStudyId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ (delete)") + class DeletePost { + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_post_successfully() { + // given + Post post = post(); + + // when & then (์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์œผ๋ฉด ์„ฑ๊ณต) + post.delete(WRITER_ID, STUDY_ID); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_deletes() { + // given + Post post = post(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> post.delete(otherMemberId, STUDY_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_from_different_study() { + // given + Post post = post(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> post.delete(WRITER_ID, otherStudyId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ํ•€ (pin)") + class PinPost { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ํ•€ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_pin_post_successfully() { + // given + Post post = post(); + assertThat(post.isPinned()).isFalse(); + + // when + post.pin(STUDY_ID); + + // then + assertThat(post.isPinned()).isTrue(); + assertThat(post.getPinnedAt()).isNotNull(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_pin_from_different_study() { + // given + Post post = post(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> post.pin(otherStudyId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์–ธํ•€ (unpin)") + class UnpinPost { + + @Test + @DisplayName("ํ•€๋œ ๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์–ธํ•€ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_unpin_post_successfully() { + // given + Post post = post(); + post.pin(STUDY_ID); + assertThat(post.isPinned()).isTrue(); + + // when + post.unpin(STUDY_ID); + + // then + assertThat(post.isPinned()).isFalse(); + assertThat(post.getPinnedAt()).isNull(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ์–ธํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_unpin_from_different_study() { + // given + Post post = post(); + post.pin(STUDY_ID); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> post.unpin(otherStudyId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + } + + @Nested + @DisplayName("ํ•€ ์ƒํƒœ ํ™•์ธ (isPinned)") + class IsPinned { + + @Test + @DisplayName("ํ•€๋˜์ง€ ์•Š์€ ๊ฒŒ์‹œ๊ธ€์€ false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_false_when_not_pinned() { + // given + Post post = post(); + + // when & then + assertThat(post.isPinned()).isFalse(); + } + + @Test + @DisplayName("ํ•€๋œ ๊ฒŒ์‹œ๊ธ€์€ true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_true_when_pinned() { + // given + Post post = post(); + post.pin(STUDY_ID); + + // when & then + assertThat(post.isPinned()).isTrue(); + } + } +} From 2effadc56d8fb397dbb8a33240e2be1f8cf8fc4e Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 14:15:50 +0900 Subject: [PATCH 021/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20command=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AA=85=EC=84=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/PostCommandController.java | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java index cdf3f3d2..6169c576 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java @@ -1,5 +1,10 @@ package kr.spot.post.presentation.command; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; @@ -24,46 +29,80 @@ public class PostCommandController { private final ManagePostService managePostService; + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ", description = "์Šคํ„ฐ๋””์— ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๊ธ€์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`STUDY404`: ์Šคํ„ฐ๋””๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) @PostMapping public ResponseEntity> createPost( - @PathVariable Long studyId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, @CurrentMember Long writerId, @RequestBody ManagePostRequest request) { managePostService.createPost(studyId, writerId, request); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); } + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ •", description = "์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. ์ž‘์„ฑ์ž๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ • ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`POST403`: ์ž‘์„ฑ์ž๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) @PutMapping("/{postId}") public ResponseEntity> updatePost( - @PathVariable Long studyId, - @PathVariable Long postId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, @CurrentMember Long writerId, @RequestBody ManagePostRequest request) { managePostService.updatePost(studyId, postId, request, writerId); - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ", description = "์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. ์ž‘์„ฑ์ž๋งŒ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`POST403`: ์ž‘์„ฑ์ž๋งŒ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) @DeleteMapping("/{postId}") public ResponseEntity> deletePost( - @PathVariable Long studyId, - @PathVariable Long postId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, @CurrentMember Long writerId) { managePostService.deletePost(studyId, postId, writerId); - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ํ•€", description = "์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€์„ ์ƒ๋‹จ์— ๊ณ ์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "๊ฒŒ์‹œ๊ธ€ ํ•€ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: ํ•ด๋‹น ์Šคํ„ฐ๋””์— ๋Œ€ํ•œ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) @PostMapping("/{postId}/pin") public ResponseEntity> pinPost( - @PathVariable Long studyId, - @PathVariable Long postId) { + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId) { managePostService.pinPost(studyId, postId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ํ•€ ํ•ด์ œ", description = "์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€ ์ƒ๋‹จ ๊ณ ์ •์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "๊ฒŒ์‹œ๊ธ€ ํ•€ ํ•ด์ œ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: ํ•ด๋‹น ์Šคํ„ฐ๋””์— ๋Œ€ํ•œ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) @DeleteMapping("/{postId}/pin") public ResponseEntity> unpinPost( - @PathVariable Long studyId, - @PathVariable Long postId) { + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId) { managePostService.unpinPost(studyId, postId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } From 7e1ffd955e98e61b4cd336e7d90db822dc0a4f7a Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 14:21:16 +0900 Subject: [PATCH 022/126] =?UTF-8?q?[Feature]=20post=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EA=B3=BC=20=EB=B9=88=20=EB=84=A4=EC=9E=84=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EB=B9=88=20=EB=84=A4=EC=9E=84=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/spot/post/application/command/ManagePostService.java | 2 +- .../java/kr/spot/post/infrastructure/jpa/CommentRepository.java | 2 ++ .../java/kr/spot/post/infrastructure/jpa/PostRepository.java | 2 ++ .../kr/spot/post/infrastructure/jpa/PostStatsRepository.java | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java index 10b6bf7f..2678d759 100644 --- a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java +++ b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java @@ -13,7 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Service +@Service(value = "ManageStudyPostService") @Transactional @RequiredArgsConstructor public class ManagePostService { diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java index 66a6ced9..89057078 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java @@ -2,7 +2,9 @@ import kr.spot.post.domain.Comment; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository(value = "studyCommentRepository") public interface CommentRepository extends JpaRepository { } diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java index 2ae79226..61fa9cf4 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java @@ -4,7 +4,9 @@ import kr.spot.exception.GeneralException; import kr.spot.post.domain.Post; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository(value = "studyPostRepository") public interface PostRepository extends JpaRepository { default Post getById(long postId) { diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java index 982bc361..e6c5a7f3 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java @@ -2,7 +2,9 @@ import kr.spot.post.domain.PostStats; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository(value = "studyPostStatsRepository") public interface PostStatsRepository extends JpaRepository { } From 713d9d1c46b64e7d31476e981e0f1c53e292c513 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 14:27:32 +0900 Subject: [PATCH 023/126] =?UTF-8?q?[Feature]=20=EC=9E=91=EC=84=B1=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EA=B4=80=EB=A0=A8=20=EC=95=A0=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=88=A8=EA=B8=B0=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/presentation/command/PostCommandController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java index 6169c576..94247ef3 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java @@ -38,7 +38,7 @@ public class PostCommandController { @PostMapping public ResponseEntity> createPost( @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @CurrentMember Long writerId, + @CurrentMember @Parameter(hidden = true) Long writerId, @RequestBody ManagePostRequest request) { managePostService.createPost(studyId, writerId, request); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); @@ -55,7 +55,7 @@ public ResponseEntity> createPost( public ResponseEntity> updatePost( @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, - @CurrentMember Long writerId, + @CurrentMember @Parameter(hidden = true) Long writerId, @RequestBody ManagePostRequest request) { managePostService.updatePost(studyId, postId, request, writerId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); @@ -72,7 +72,7 @@ public ResponseEntity> updatePost( public ResponseEntity> deletePost( @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, - @CurrentMember Long writerId) { + @CurrentMember @Parameter(hidden = true) Long writerId) { managePostService.deletePost(studyId, postId, writerId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } From 8b62a810238c900ff1d8738fac6ecae626023540 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 14:32:26 +0900 Subject: [PATCH 024/126] =?UTF-8?q?[Feature]=20=ED=95=B4=EB=8B=B9=20?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=94=94=EC=9D=98=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=EB=A7=8C=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=ED=8C=90=EC=97=90=20=EC=A0=91=EA=B7=BC=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManagePostService.java | 11 +++- .../command/PostCommandController.java | 10 ++-- .../command/ManagePostServiceTest.java | 53 ++++++++++++++++--- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java index 2678d759..c17f212a 100644 --- a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java +++ b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java @@ -9,6 +9,7 @@ import kr.spot.post.infrastructure.jpa.PostRepository; import kr.spot.post.infrastructure.jpa.PostStatsRepository; import kr.spot.post.presentation.command.dto.ManagePostRequest; +import kr.spot.study.application.validator.StudyAccessValidator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,29 +23,35 @@ public class ManagePostService { private final GetWriterInfoPort getWriterInfoPort; private final PostRepository postRepository; private final PostStatsRepository postStatsRepository; + private final StudyAccessValidator studyAccessValidator; public void createPost(long studyId, long writerId, ManagePostRequest request) { + studyAccessValidator.validateStudyMember(studyId, writerId); WriterInfo writerInfo = getWriterInfo(writerId); Post post = createAndSavePost(request, studyId, writerInfo); initializeAndSavePostStats(post); } public void updatePost(long studyId, long postId, ManagePostRequest request, long writerId) { + studyAccessValidator.validateStudyMember(studyId, writerId); Post post = postRepository.getById(postId); post.update(request.title(), request.content(), request.isPrivate(), writerId, studyId); } public void deletePost(long studyId, long postId, long writerId) { + studyAccessValidator.validateStudyMember(studyId, writerId); Post post = postRepository.getById(postId); post.delete(writerId, studyId); } - public void pinPost(long studyId, long postId) { + public void pinPost(long studyId, long postId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); Post post = postRepository.getById(postId); post.pin(studyId); } - public void unpinPost(long studyId, long postId) { + public void unpinPost(long studyId, long postId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); Post post = postRepository.getById(postId); post.unpin(studyId); } diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java index 94247ef3..3cf4fc82 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java @@ -87,8 +87,9 @@ public ResponseEntity> deletePost( @PostMapping("/{postId}/pin") public ResponseEntity> pinPost( @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId) { - managePostService.pinPost(studyId, postId); + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + managePostService.pinPost(studyId, postId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } @@ -102,8 +103,9 @@ public ResponseEntity> pinPost( @DeleteMapping("/{postId}/pin") public ResponseEntity> unpinPost( @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId) { - managePostService.unpinPost(studyId, postId); + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + managePostService.unpinPost(studyId, postId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } } diff --git a/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java index e2804ada..8d617bd9 100644 --- a/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java +++ b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java @@ -16,6 +16,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; + import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; @@ -26,6 +29,7 @@ import kr.spot.post.infrastructure.jpa.PostRepository; import kr.spot.post.infrastructure.jpa.PostStatsRepository; import kr.spot.post.presentation.command.dto.ManagePostRequest; +import kr.spot.study.application.validator.StudyAccessValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -51,6 +55,9 @@ class ManagePostServiceTest { @Mock PostStatsRepository postStatsRepository; + @Mock + StudyAccessValidator studyAccessValidator; + @Captor ArgumentCaptor postCaptor; @@ -62,7 +69,7 @@ class ManagePostServiceTest { @BeforeEach void setUp() { managePostService = new ManagePostService( - idGenerator, getWriterInfoPort, postRepository, postStatsRepository); + idGenerator, getWriterInfoPort, postRepository, postStatsRepository, studyAccessValidator); } @Nested @@ -276,13 +283,29 @@ void should_pin_post_successfully() { when(postRepository.getById(postId)).thenReturn(post); // when - managePostService.pinPost(STUDY_ID, postId); + managePostService.pinPost(STUDY_ID, postId, WRITER_ID); // then assertThat(post.isPinned()).isTrue(); assertThat(post.getPinnedAt()).isNotNull(); } + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_study_member_pins() { + // given + long postId = 1L; + long nonMemberId = 999L; + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId); + + // when & then + assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId, nonMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + } + @Test @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") void should_throw_exception_when_pin_from_different_study() { @@ -294,7 +317,7 @@ void should_throw_exception_when_pin_from_different_study() { when(postRepository.getById(postId)).thenReturn(post); // when & then - assertThatThrownBy(() -> managePostService.pinPost(otherStudyId, postId)) + assertThatThrownBy(() -> managePostService.pinPost(otherStudyId, postId, WRITER_ID)) .isInstanceOf(GeneralException.class) .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); } @@ -309,7 +332,7 @@ void should_throw_exception_when_post_not_found() { .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)); // when & then - assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId)) + assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId, WRITER_ID)) .isInstanceOf(GeneralException.class) .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); } @@ -331,13 +354,29 @@ void should_unpin_post_successfully() { when(postRepository.getById(postId)).thenReturn(post); // when - managePostService.unpinPost(STUDY_ID, postId); + managePostService.unpinPost(STUDY_ID, postId, WRITER_ID); // then assertThat(post.isPinned()).isFalse(); assertThat(post.getPinnedAt()).isNull(); } + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์–ธํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_study_member_unpins() { + // given + long postId = 1L; + long nonMemberId = 999L; + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId); + + // when & then + assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId, nonMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + } + @Test @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ์–ธํ•€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") void should_throw_exception_when_unpin_from_different_study() { @@ -350,7 +389,7 @@ void should_throw_exception_when_unpin_from_different_study() { when(postRepository.getById(postId)).thenReturn(post); // when & then - assertThatThrownBy(() -> managePostService.unpinPost(otherStudyId, postId)) + assertThatThrownBy(() -> managePostService.unpinPost(otherStudyId, postId, WRITER_ID)) .isInstanceOf(GeneralException.class) .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); } @@ -365,7 +404,7 @@ void should_throw_exception_when_post_not_found() { .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)); // when & then - assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId)) + assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId, WRITER_ID)) .isInstanceOf(GeneralException.class) .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); } From 4ec853d4081949f89eb07dae51bb7edd73386e66 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 15:03:06 +0900 Subject: [PATCH 025/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EB=8C=93=EA=B8=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageCommentService.java | 73 ++++++++++++++++ .../main/java/kr/spot/post/domain/Post.java | 4 + .../infrastructure/jpa/CommentRepository.java | 7 ++ .../jpa/PostStatsRepository.java | 10 +++ .../command/CommentCommandController.java | 86 +++++++++++++++++++ .../command/dto/CreateCommentResponse.java | 8 ++ .../command/dto/ManageCommentRequest.java | 5 ++ 7 files changed, 193 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java diff --git a/modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java b/modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java new file mode 100644 index 00000000..ab60fbcf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java @@ -0,0 +1,73 @@ +package kr.spot.post.application.command; + +import kr.spot.IdGenerator; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.vo.WriterInfo; +import kr.spot.post.infrastructure.jpa.CommentRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.post.presentation.command.dto.CreateCommentResponse; +import kr.spot.post.presentation.command.dto.ManageCommentRequest; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service(value = "ManageStudyCommentService") +@Transactional +@RequiredArgsConstructor +public class ManageCommentService { + + private final IdGenerator idGenerator; + private final GetWriterInfoPort getWriterInfoPort; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final PostStatsRepository postStatsRepository; + private final StudyAccessValidator studyAccessValidator; + + public CreateCommentResponse createComment(long writerId, long postId, long studyId, + ManageCommentRequest request) { + studyAccessValidator.validateStudyMember(studyId, writerId); + validatePostBelongsToStudy(postId, studyId); + WriterInfo writerInfo = getWriterInfo(writerId); + Comment comment = Comment.of(idGenerator.nextId(), postId, writerInfo, request.content()); + Comment save = saveCommentAndIncreaseCommentCount(postId, comment); + return CreateCommentResponse.of(save.getId()); + } + + public void updateComment(long writerId, long commentId, long studyId, + ManageCommentRequest request) { + studyAccessValidator.validateStudyMember(studyId, writerId); + Comment comment = commentRepository.getById(commentId); + validatePostBelongsToStudy(comment.getPostId(), studyId); + comment.update(writerId, request.content()); + } + + public void deleteComment(long writerId, long commentId, long studyId) { + studyAccessValidator.validateStudyMember(studyId, writerId); + Comment comment = commentRepository.getById(commentId); + validatePostBelongsToStudy(comment.getPostId(), studyId); + comment.delete(writerId); + postStatsRepository.decreaseCommentCount(comment.getPostId()); + } + + private void validatePostBelongsToStudy(long postId, long studyId) { + Post post = postRepository.getById(postId); + post.validateBelongsToStudy(studyId); + } + + private WriterInfo getWriterInfo(long writerId) { + WriterInfoResponse writerInfoResponse = getWriterInfoPort.get(writerId); + return WriterInfo.of(writerInfoResponse.writerId(), writerInfoResponse.nickname(), + writerInfoResponse.profileImageUrl()); + } + + private Comment saveCommentAndIncreaseCommentCount(long postId, Comment comment) { + Comment save = commentRepository.save(comment); + postStatsRepository.increaseCommentCount(postId); + return save; + } +} diff --git a/modules/study/src/main/java/kr/spot/post/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java index 91048e9f..faf17b45 100644 --- a/modules/study/src/main/java/kr/spot/post/domain/Post.java +++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java @@ -75,6 +75,10 @@ public boolean isPinned() { return pinnedAt != null; } + public void validateBelongsToStudy(long studyId) { + validateStudyId(studyId); + } + private void validateStudyId(long studyId) { if (this.studyId != studyId) { throw new GeneralException(ErrorStatus._INVALID_STUDY_ACCESS); diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java index 89057078..d0e9a0aa 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java @@ -1,5 +1,7 @@ package kr.spot.post.infrastructure.jpa; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; import kr.spot.post.domain.Comment; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -7,4 +9,9 @@ @Repository(value = "studyCommentRepository") public interface CommentRepository extends JpaRepository { + default Comment getById(long id) { + return findById(id) + .orElseThrow(() -> new GeneralException(ErrorStatus._COMMENT_NOT_FOUND)); + } + } diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java index e6c5a7f3..e9f8a112 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java @@ -2,9 +2,19 @@ import kr.spot.post.domain.PostStats; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository(value = "studyPostStatsRepository") public interface PostStatsRepository extends JpaRepository { + @Modifying + @Query("update StudyPostStats s set s.commentCount = s.commentCount + 1 where s.postId = :postId") + int increaseCommentCount(@Param("postId") long postId); + + @Modifying + @Query("update StudyPostStats s set s.commentCount = case when s.commentCount > 0 then s.commentCount - 1 else 0 end where s.postId = :postId") + int decreaseCommentCount(@Param("postId") long postId); } diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java new file mode 100644 index 00000000..1d4a5710 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java @@ -0,0 +1,86 @@ +package kr.spot.post.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.post.application.command.ManageCommentService; +import kr.spot.post.presentation.command.dto.CreateCommentResponse; +import kr.spot.post.presentation.command.dto.ManageCommentRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "๋Œ“๊ธ€") +@RestController("studyCommentCommandController") +@RequestMapping("/api/studies/{studyId}/posts") +@RequiredArgsConstructor +public class CommentCommandController { + + private final ManageCommentService commentService; + + @Operation(summary = "๋Œ“๊ธ€ ์ž‘์„ฑ", description = "ํŠน์ • ๊ฒŒ์‹œ๊ธ€์— ์ƒˆ๋กœ์šด ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "์ž‘์„ฑ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = """ + - `MEMBER404`: ํšŒ์›์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + - `POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + """, content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{postId}/comments") + public ResponseEntity> createComment( + @CurrentMember @Parameter(hidden = true) Long writerId, + @PathVariable Long studyId, + @PathVariable Long postId, + @RequestBody ManageCommentRequest request) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED, + commentService.createComment(writerId, postId, studyId, request))); + } + + @Operation(summary = "๋Œ“๊ธ€ ์ˆ˜์ •", description = "๊ธฐ์กด ๋Œ“๊ธ€์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "์ˆ˜์ • ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "์ˆ˜์ • ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`COMMENT404`: ๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PutMapping("/comments/{commentId}") + public ResponseEntity> updateComment( + @PathVariable Long commentId, + @PathVariable Long studyId, + @RequestBody ManageCommentRequest request, + @CurrentMember @Parameter(hidden = true) Long writerId) { + commentService.updateComment(writerId, commentId, studyId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } + + @Operation(summary = "๋Œ“๊ธ€ ์‚ญ์ œ", description = "๊ธฐ์กด ๋Œ“๊ธ€์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "์‚ญ์ œ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "์‚ญ์ œ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`COMMENT404`: ๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/comments/{commentId}") + public ResponseEntity> deleteComment( + @PathVariable Long commentId, + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long writerId + ) { + commentService.deleteComment(writerId, commentId, studyId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java new file mode 100644 index 00000000..5d77fc68 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java @@ -0,0 +1,8 @@ +package kr.spot.post.presentation.command.dto; + +public record CreateCommentResponse(Long commentId) { + + public static CreateCommentResponse of(Long commentId) { + return new CreateCommentResponse(commentId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java new file mode 100644 index 00000000..d781d2c6 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java @@ -0,0 +1,5 @@ +package kr.spot.post.presentation.command.dto; + +public record ManageCommentRequest(String content) { + +} From 33ce5a2cce40b62e4dd5a72a34dae8adda890a30 Mon Sep 17 00:00:00 2001 From: msk226 Date: Sun, 28 Dec 2025 15:03:14 +0900 Subject: [PATCH 026/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EB=8C=93=EA=B8=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageCommentServiceTest.java | 344 ++++++++++++++++++ .../kr/spot/post/common/CommentFixture.java | 33 ++ .../java/kr/spot/post/domain/CommentTest.java | 98 +++++ 3 files changed, 475 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/post/common/CommentFixture.java create mode 100644 modules/study/src/test/java/kr/spot/post/domain/CommentTest.java diff --git a/modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java new file mode 100644 index 00000000..6990eb91 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java @@ -0,0 +1,344 @@ +package kr.spot.post.application.command; + +import static kr.spot.post.common.CommentFixture.CONTENT; +import static kr.spot.post.common.CommentFixture.POST_ID; +import static kr.spot.post.common.CommentFixture.comment; +import static kr.spot.post.common.CommentFixture.manageCommentRequest; +import static kr.spot.post.common.PostFixture.STUDY_ID; +import static kr.spot.post.common.PostFixture.WRITER_ID; +import static kr.spot.post.common.PostFixture.WRITER_NAME; +import static kr.spot.post.common.PostFixture.WRITER_PROFILE_IMAGE_URL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import static kr.spot.post.common.PostFixture.post; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.infrastructure.jpa.CommentRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.post.presentation.command.dto.CreateCommentResponse; +import kr.spot.post.presentation.command.dto.ManageCommentRequest; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManageCommentServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + GetWriterInfoPort getWriterInfoPort; + + @Mock + CommentRepository commentRepository; + + @Mock + PostRepository postRepository; + + @Mock + PostStatsRepository postStatsRepository; + + @Mock + StudyAccessValidator studyAccessValidator; + + @Captor + ArgumentCaptor commentCaptor; + + ManageCommentService manageCommentService; + + @BeforeEach + void setUp() { + manageCommentService = new ManageCommentService( + idGenerator, getWriterInfoPort, commentRepository, postRepository, postStatsRepository, + studyAccessValidator); + } + + @Nested + @DisplayName("๋Œ“๊ธ€ ์ƒ์„ฑ (createComment)") + class CreateComment { + + @Test + @DisplayName("๋Œ“๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_comment_successfully() { + // given + Long generatedId = 1L; + Post post = post(POST_ID, STUDY_ID, WRITER_ID); + ManageCommentRequest request = manageCommentRequest(); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of( + WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); + + when(postRepository.getById(anyLong())).thenReturn(post); + when(idGenerator.nextId()).thenReturn(generatedId); + when(getWriterInfoPort.get(WRITER_ID)).thenReturn(writerInfoResponse); + when(commentRepository.save(any(Comment.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + CreateCommentResponse response = manageCommentService.createComment( + WRITER_ID, POST_ID, STUDY_ID, request); + + // then + assertThat(response.commentId()).isEqualTo(generatedId); + + verify(commentRepository).save(commentCaptor.capture()); + verify(postStatsRepository).increaseCommentCount(POST_ID); + + Comment capturedComment = commentCaptor.getValue(); + assertThat(capturedComment.getId()).isEqualTo(generatedId); + assertThat(capturedComment.getPostId()).isEqualTo(POST_ID); + assertThat(capturedComment.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID); + assertThat(capturedComment.getContent()).isEqualTo(CONTENT); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_study_member_creates() { + // given + long nonMemberId = 999L; + ManageCommentRequest request = manageCommentRequest(); + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId); + + // when & then + assertThatThrownBy( + () -> manageCommentService.createComment(nonMemberId, POST_ID, STUDY_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€์— ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_belongs_to_different_study() { + // given + long otherStudyId = 999L; + Post postInOtherStudy = post(POST_ID, otherStudyId, WRITER_ID); + ManageCommentRequest request = manageCommentRequest(); + + when(postRepository.getById(POST_ID)).thenReturn(postInOtherStudy); + + // when & then + assertThatThrownBy( + () -> manageCommentService.createComment(WRITER_ID, POST_ID, STUDY_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + } + + @Nested + @DisplayName("๋Œ“๊ธ€ ์ˆ˜์ • (updateComment)") + class UpdateComment { + + @Test + @DisplayName("๋Œ“๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_comment_successfully() { + // given + long commentId = 1L; + Post post = post(POST_ID, STUDY_ID, WRITER_ID); + Comment comment = comment(commentId, POST_ID, WRITER_ID); + ManageCommentRequest request = manageCommentRequest("์ˆ˜์ •๋œ ๋Œ“๊ธ€ ๋‚ด์šฉ"); + + when(commentRepository.getById(commentId)).thenReturn(comment); + when(postRepository.getById(POST_ID)).thenReturn(post); + + // when + manageCommentService.updateComment(WRITER_ID, commentId, STUDY_ID, request); + + // then + assertThat(comment.getContent()).isEqualTo("์ˆ˜์ •๋œ ๋Œ“๊ธ€ ๋‚ด์šฉ"); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_updates() { + // given + long commentId = 1L; + long otherMemberId = 999L; + Post post = post(POST_ID, STUDY_ID, WRITER_ID); + Comment comment = comment(commentId, POST_ID, WRITER_ID); + ManageCommentRequest request = manageCommentRequest(); + + when(commentRepository.getById(commentId)).thenReturn(comment); + when(postRepository.getById(POST_ID)).thenReturn(post); + + // when & then + assertThatThrownBy( + () -> manageCommentService.updateComment(otherMemberId, commentId, STUDY_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_study_member_updates() { + // given + long commentId = 1L; + long nonMemberId = 999L; + ManageCommentRequest request = manageCommentRequest(); + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId); + + // when & then + assertThatThrownBy( + () -> manageCommentService.updateComment(nonMemberId, commentId, STUDY_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€ ๋Œ“๊ธ€์„ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_comment_belongs_to_different_study() { + // given + long commentId = 1L; + long otherStudyId = 999L; + Post postInOtherStudy = post(POST_ID, otherStudyId, WRITER_ID); + Comment comment = comment(commentId, POST_ID, WRITER_ID); + ManageCommentRequest request = manageCommentRequest(); + + when(commentRepository.getById(commentId)).thenReturn(comment); + when(postRepository.getById(POST_ID)).thenReturn(postInOtherStudy); + + // when & then + assertThatThrownBy( + () -> manageCommentService.updateComment(WRITER_ID, commentId, STUDY_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋Œ“๊ธ€์„ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_comment_not_found() { + // given + long commentId = 999L; + ManageCommentRequest request = manageCommentRequest(); + + when(commentRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._COMMENT_NOT_FOUND)); + + // when & then + assertThatThrownBy( + () -> manageCommentService.updateComment(WRITER_ID, commentId, STUDY_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._COMMENT_NOT_FOUND); + } + } + + @Nested + @DisplayName("๋Œ“๊ธ€ ์‚ญ์ œ (deleteComment)") + class DeleteComment { + + @Test + @DisplayName("๋Œ“๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_comment_successfully() { + // given + long commentId = 1L; + Post post = post(POST_ID, STUDY_ID, WRITER_ID); + Comment comment = comment(commentId, POST_ID, WRITER_ID); + + when(commentRepository.getById(commentId)).thenReturn(comment); + when(postRepository.getById(POST_ID)).thenReturn(post); + + // when + manageCommentService.deleteComment(WRITER_ID, commentId, STUDY_ID); + + // then + verify(commentRepository).getById(commentId); + verify(postStatsRepository).decreaseCommentCount(POST_ID); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_deletes() { + // given + long commentId = 1L; + long otherMemberId = 999L; + Post post = post(POST_ID, STUDY_ID, WRITER_ID); + Comment comment = comment(commentId, POST_ID, WRITER_ID); + + when(commentRepository.getById(commentId)).thenReturn(comment); + when(postRepository.getById(POST_ID)).thenReturn(post); + + // when & then + assertThatThrownBy( + () -> manageCommentService.deleteComment(otherMemberId, commentId, STUDY_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_study_member_deletes() { + // given + long commentId = 1L; + long nonMemberId = 999L; + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId); + + // when & then + assertThatThrownBy( + () -> manageCommentService.deleteComment(nonMemberId, commentId, STUDY_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋Œ“๊ธ€์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_comment_not_found() { + // given + long commentId = 999L; + + when(commentRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._COMMENT_NOT_FOUND)); + + // when & then + assertThatThrownBy( + () -> manageCommentService.deleteComment(WRITER_ID, commentId, STUDY_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€ ๋Œ“๊ธ€์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_comment_belongs_to_different_study() { + // given + long commentId = 1L; + long otherStudyId = 999L; + Post postInOtherStudy = post(POST_ID, otherStudyId, WRITER_ID); + Comment comment = comment(commentId, POST_ID, WRITER_ID); + + when(commentRepository.getById(commentId)).thenReturn(comment); + when(postRepository.getById(POST_ID)).thenReturn(postInOtherStudy); + + // when & then + assertThatThrownBy( + () -> manageCommentService.deleteComment(WRITER_ID, commentId, STUDY_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/post/common/CommentFixture.java b/modules/study/src/test/java/kr/spot/post/common/CommentFixture.java new file mode 100644 index 00000000..6831e47a --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/common/CommentFixture.java @@ -0,0 +1,33 @@ +package kr.spot.post.common; + +import static kr.spot.post.common.PostFixture.writerInfo; + +import kr.spot.post.domain.Comment; +import kr.spot.post.presentation.command.dto.ManageCommentRequest; + +public class CommentFixture { + + public static final long COMMENT_ID = 1L; + public static final long POST_ID = 100L; + public static final String CONTENT = "ํ…Œ์ŠคํŠธ ๋Œ“๊ธ€ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค."; + + public static Comment comment() { + return Comment.of(COMMENT_ID, POST_ID, writerInfo(), CONTENT); + } + + public static Comment comment(long id, long postId) { + return Comment.of(id, postId, writerInfo(), CONTENT); + } + + public static Comment comment(long id, long postId, long writerId) { + return Comment.of(id, postId, writerInfo(writerId), CONTENT); + } + + public static ManageCommentRequest manageCommentRequest() { + return new ManageCommentRequest(CONTENT); + } + + public static ManageCommentRequest manageCommentRequest(String content) { + return new ManageCommentRequest(content); + } +} diff --git a/modules/study/src/test/java/kr/spot/post/domain/CommentTest.java b/modules/study/src/test/java/kr/spot/post/domain/CommentTest.java new file mode 100644 index 00000000..24b775bc --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/domain/CommentTest.java @@ -0,0 +1,98 @@ +package kr.spot.post.domain; + +import static kr.spot.post.common.CommentFixture.COMMENT_ID; +import static kr.spot.post.common.CommentFixture.CONTENT; +import static kr.spot.post.common.CommentFixture.POST_ID; +import static kr.spot.post.common.CommentFixture.comment; +import static kr.spot.post.common.PostFixture.WRITER_ID; +import static kr.spot.post.common.PostFixture.writerInfo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CommentTest { + + @Nested + @DisplayName("๋Œ“๊ธ€ ์ƒ์„ฑ (of)") + class CreateComment { + + @Test + @DisplayName("๋Œ“๊ธ€ ๊ฐ์ฒด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_comment_successfully() { + // when + Comment comment = Comment.of(COMMENT_ID, POST_ID, writerInfo(), CONTENT); + + // then + assertThat(comment).isNotNull(); + assertThat(comment.getId()).isEqualTo(COMMENT_ID); + assertThat(comment.getPostId()).isEqualTo(POST_ID); + assertThat(comment.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID); + assertThat(comment.getContent()).isEqualTo(CONTENT); + } + } + + @Nested + @DisplayName("๋Œ“๊ธ€ ์ˆ˜์ • (update)") + class UpdateComment { + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ๋Œ“๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_comment_successfully() { + // given + Comment comment = comment(); + String newContent = "์ˆ˜์ •๋œ ๋Œ“๊ธ€ ๋‚ด์šฉ"; + + // when + comment.update(WRITER_ID, newContent); + + // then + assertThat(comment.getContent()).isEqualTo(newContent); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_updates() { + // given + Comment comment = comment(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> comment.update(otherMemberId, "์ƒˆ ๋‚ด์šฉ")) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + } + + @Nested + @DisplayName("๋Œ“๊ธ€ ์‚ญ์ œ (delete)") + class DeleteComment { + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ๋Œ“๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_comment_successfully() { + // given + Comment comment = comment(); + + // when & then (์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์œผ๋ฉด ์„ฑ๊ณต) + comment.delete(WRITER_ID); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_author_deletes() { + // given + Comment comment = comment(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> comment.delete(otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + } +} From 103928db08077fc1332a13b981c7c4930117aa89 Mon Sep 17 00:00:00 2001 From: msk226 Date: Mon, 29 Dec 2025 14:19:51 +0900 Subject: [PATCH 027/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 1 + .../dto/response/PostDetailResponse.java | 1 - modules/study/build.gradle | 1 + .../application/query/GetPostService.java | 147 ++++++++++++++++++ .../main/java/kr/spot/post/domain/Post.java | 10 ++ .../jpa/querydsl/PostQueryRepository.java | 83 ++++++++++ .../command/CommentCommandController.java | 2 +- .../query/PostQueryController.java | 69 ++++++++ .../query/dto/PostDetailResponse.java | 49 ++++++ .../query/dto/PostListResponse.java | 47 ++++++ .../query/dto/PostStatsResponse.java | 12 ++ .../validator/StudyAccessValidator.java | 8 + .../java/kr/spot/post/common/PostFixture.java | 33 ++++ 13 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index 33b64d73..49965980 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -54,6 +54,7 @@ public enum ErrorStatus implements BaseErrorCode { _POST_NOT_FOUND(404, "POST404", "๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), _COMMENT_NOT_FOUND(404, "COMMENT404", "๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), _ONLY_AUTHOR_CAN_MODIFY(403, "POST403", "๊ฒŒ์‹œ๊ธ€ ๋ฐ ๋Œ“๊ธ€ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + _PRIVATE_POST_ACCESS_DENIED(403, "POST4030", "์Šคํ„ฐ๋””์› ์ „์šฉ ๊ฒŒ์‹œ๊ธ€์ž…๋‹ˆ๋‹ค."), _ALREADY_LIKED(400, "POST4000", "์ด๋ฏธ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ ๊ฒŒ์‹œ๊ธ€์ž…๋‹ˆ๋‹ค."), _ALREADY_UNLIKED(400, "POST4001", "์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅด์ง€ ์•Š์€ ๊ฒŒ์‹œ๊ธ€์ž…๋‹ˆ๋‹ค."), _SORT_TYPE_INVALID(400, "POST4002", "์œ ํšจํ•˜์ง€ ์•Š์€ ์ •๋ ฌ ํƒ€์ž…์ž…๋‹ˆ๋‹ค."), diff --git a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java index e73540ef..14e6bc54 100644 --- a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java +++ b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java @@ -44,5 +44,4 @@ public static WriterInfoResponse of(Long writerId, String nickname, String profi return new WriterInfoResponse(writerId, nickname, profileImageUrl); } } - } diff --git a/modules/study/build.gradle b/modules/study/build.gradle index f7b9623f..ce7b6717 100644 --- a/modules/study/build.gradle +++ b/modules/study/build.gradle @@ -8,6 +8,7 @@ dependencies { implementation project(":common:api") implementation project(":common:snowflake") implementation project(":common:bucket") + implementation project(":common:view") implementation project(":modules:shared") implementation project(":modules:auth-api") diff --git a/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java new file mode 100644 index 00000000..90cf37e2 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java @@ -0,0 +1,147 @@ +package kr.spot.post.application.query; + +import static kr.spot.post.application.query.mapper.PostResponseMapper.DEFAULT_MAX_CONTENT_LENGTH; +import static kr.spot.post.application.query.mapper.PostResponseMapper.toDetailResponse; +import static kr.spot.post.application.query.mapper.PostResponseMapper.toPostItem; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.querydsl.PostQueryRepository; +import kr.spot.post.presentation.query.dto.PostDetailResponse; +import kr.spot.post.presentation.query.dto.PostListResponse; +import kr.spot.post.presentation.query.dto.PostListResponse.PostItem; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service(value = "studyGetPostService") +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetPostService { + + private static final int MAX_PAGE_SIZE = 50; + + private final ViewCounter viewCounter; + private final ViewAbuseGuard viewAbuseGuard; + private final StudyAccessValidator accessValidator; + private final PostRepository postRepository; + private final PostQueryRepository postQueryRepository; + + public PostDetailResponse getPostDetail(long studyId, long postId, long viewerId) { + Post post = findAndValidatePost(studyId, postId); + accessValidator.validateStudyMember(studyId, viewerId); + + PostStats stats = postQueryRepository.findStatsByPostId(postId); + List comments = postQueryRepository.findCommentsByPostId(postId); + long displayViewCount = calculateDisplayViewCount(stats, postId, viewerId); + + return toDetailResponse(post, stats, displayViewCount, comments, viewerId); + } + + public PostListResponse getPostList(long studyId, Long cursor, long viewerId, int size) { + int pageSize = Math.min(size, MAX_PAGE_SIZE); + boolean isStudyMember = accessValidator.isStudyMember(studyId, viewerId); + + List pinnedPosts = fetchPinnedPostsIfFirstPage(studyId, cursor); + PaginationResult paginationResult = fetchNormalPostsWithPagination(studyId, cursor, pageSize, + pinnedPosts.size()); + + List allPosts = mergePosts(pinnedPosts, paginationResult.posts()); + Map statsMap = fetchStatsForPosts(allPosts); + + List postItems = mapToPostItems(allPosts, statsMap, isStudyMember); + + return buildListResponse(postItems, paginationResult); + } + + private Post findAndValidatePost(long studyId, long postId) { + Post post = postRepository.getById(postId); + post.validateBelongsToStudy(studyId); + return post; + } + + private List fetchPinnedPostsIfFirstPage(long studyId, Long cursor) { + if (cursor != null) { + return List.of(); + } + return postQueryRepository.findPinnedPosts(studyId); + } + + private PaginationResult fetchNormalPostsWithPagination(long studyId, Long cursor, int pageSize, + int pinnedCount) { + int remainingSlots = pageSize - pinnedCount; + int fetchLimit = remainingSlots + 1; + + List posts = postQueryRepository.findPageByIdDesc(studyId, cursor, fetchLimit); + boolean hasNext = posts.size() > remainingSlots; + + if (hasNext) { + posts = posts.subList(0, remainingSlots); + } + + Long nextCursor = hasNext ? posts.getLast().getId() : null; + return new PaginationResult(posts, hasNext, nextCursor); + } + + private List mergePosts(List pinnedPosts, List normalPosts) { + List merged = new ArrayList<>(pinnedPosts.size() + normalPosts.size()); + merged.addAll(pinnedPosts); + merged.addAll(normalPosts); + return merged; + } + + private Map fetchStatsForPosts(List posts) { + List postIds = posts.stream().map(Post::getId).toList(); + return postQueryRepository.findStatsByPostIds(postIds); + } + + private List mapToPostItems(List posts, Map statsMap, + boolean isStudyMember) { + return posts.stream() + .map(post -> toPostItem(post, statsMap.get(post.getId()), DEFAULT_MAX_CONTENT_LENGTH, + isStudyMember)) + .toList(); + } + + private PostListResponse buildListResponse(List postItems, + PaginationResult paginationResult) { + return PostListResponse.builder() + .posts(postItems) + .hasNext(paginationResult.hasNext()) + .nextCursor(paginationResult.nextCursor()) + .build(); + } + + private long calculateDisplayViewCount(PostStats stats, long postId, long viewerId) { + long baseViewCount = (stats != null) ? stats.getViewCount() : 0L; + long viewDelta = getViewDeltaFromCounter(postId, viewerId); + return baseViewCount + viewDelta; + } + + private long getViewDeltaFromCounter(long postId, long viewerId) { + try { + if (viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)) { + return viewCounter.incrementAndGet(ViewableType.STUDY_BOARD, postId); + } + return viewCounter.currentDelta(ViewableType.STUDY_BOARD, postId); + } catch (Exception e) { + log.warn("Redis view counter access failed for study postId: {}", postId, e); + return 0L; + } + } + + private record PaginationResult(List posts, boolean hasNext, Long nextCursor) { + + } +} diff --git a/modules/study/src/main/java/kr/spot/post/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java index faf17b45..1608c21b 100644 --- a/modules/study/src/main/java/kr/spot/post/domain/Post.java +++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java @@ -75,10 +75,20 @@ public boolean isPinned() { return pinnedAt != null; } + public boolean isOwnedBy(long memberId) { + return writerInfo.isSameWriter(memberId); + } + public void validateBelongsToStudy(long studyId) { validateStudyId(studyId); } + public void validatePublicAccess() { + if (this.isPrivate) { + throw new GeneralException(ErrorStatus._PRIVATE_POST_ACCESS_DENIED); + } + } + private void validateStudyId(long studyId) { if (this.studyId != studyId) { throw new GeneralException(ErrorStatus._INVALID_STUDY_ACCESS); diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java new file mode 100644 index 00000000..9266c3d2 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java @@ -0,0 +1,83 @@ +package kr.spot.post.infrastructure.jpa.querydsl; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.domain.QComment; +import kr.spot.post.domain.QPost; +import kr.spot.post.domain.QPostStats; +import kr.spot.post.domain.Comment; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository(value = "studyPostQueryRepository") +@RequiredArgsConstructor +public class PostQueryRepository { + + private final JPAQueryFactory query; + + private final QPost post = QPost.post; + private final QPostStats postStats = QPostStats.postStats; + private final QComment comment = QComment.comment; + + public List findPinnedPosts(long studyId) { + return query + .selectFrom(post) + .where( + post.studyId.eq(studyId), + post.pinnedAt.isNotNull() + ) + .orderBy(post.pinnedAt.desc()) + .fetch(); + } + + public List findPageByIdDesc(long studyId, Long cursor, int limit) { + return query + .selectFrom(post) + .where( + post.studyId.eq(studyId), + post.pinnedAt.isNull(), + ltCursor(cursor) + ) + .orderBy(post.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression ltCursor(Long cursor) { + return (cursor == null) ? null : post.id.lt(cursor); + } + + public Map findStatsByPostIds(Collection postIds) { + if (postIds.isEmpty()) { + return Map.of(); + } + + return query + .selectFrom(postStats) + .where(postStats.postId.in(postIds)) + .fetch() + .stream() + .collect(Collectors.toMap(PostStats::getPostId, it -> it)); + } + + public PostStats findStatsByPostId(long postId) { + return query + .selectFrom(postStats) + .where(postStats.postId.eq(postId)) + .fetchOne(); + } + + public List findCommentsByPostId(long postId) { + return query + .selectFrom(comment) + .where(comment.postId.eq(postId)) + .orderBy(comment.createdAt.asc()) + .fetch(); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java index 1d4a5710..508852e6 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "๋Œ“๊ธ€") +@Tag(name = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ - ๋Œ“๊ธ€") @RestController("studyCommentCommandController") @RequestMapping("/api/studies/{studyId}/posts") @RequiredArgsConstructor diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java b/modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java new file mode 100644 index 00000000..34004b04 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java @@ -0,0 +1,69 @@ +package kr.spot.post.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.post.application.query.GetPostService; +import kr.spot.post.presentation.query.dto.PostDetailResponse; +import kr.spot.post.presentation.query.dto.PostListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€") +@RestController("studyPostQueryController") +@RequestMapping("/api/studies/{studyId}/posts") +@RequiredArgsConstructor +public class PostQueryController { + + private final GetPostService getPostService; + + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ธ ์กฐํšŒ", description = "ํŠน์ • ๊ฒŒ์‹œ๊ธ€์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @GetMapping("{postId}") + public ResponseEntity> getPostDetail( + @PathVariable Long studyId, + @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long viewerId + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, getPostService.getPostDetail(studyId, postId, viewerId))); + } + + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ", description = + "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ๋ฆฌ์ŠคํŠธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๋งˆ์ง€๋ง‰์œผ๋กœ ๋ณธ ๊ฒŒ์‹œ๊ธ€ ์ดํ›„์˜ ๊ฒŒ์‹œ๊ธ€๋“ค์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค." + + "๋˜ํ•œ ๊ธ€์ž ์ˆ˜๊ฐ€ ๋งŽ์€ ๊ฒŒ์‹œ๊ธ€์˜ ๊ฒฝ์šฐ ์ผ๋ถ€ ๋‚ด์šฉ์ด ์ƒ๋žต๋˜์–ด ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค. (ํ˜„์žฌ ๊ธฐ์ค€์€ 100์ž)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "size ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” 1๊ณผ 50 ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @GetMapping + public ResponseEntity> getPostList( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, + getPostService.getPostList(studyId, cursor, viewerId, size))); + } + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java new file mode 100644 index 00000000..bc766a6f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java @@ -0,0 +1,49 @@ +package kr.spot.post.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record PostDetailResponse( + Long postId, + String title, + String content, + boolean isPinned, + boolean isOwner, + WriterInfoResponse writer, + PostStatsResponse stats, + LocalDateTime createdAt, + List comments +) { + + public record CommentResponse( + Long commentId, + String content, + boolean isOwner, + WriterInfoResponse writer, + LocalDateTime createdAt + ) { + + public static CommentResponse of( + Long commentId, + String content, + boolean isOwner, + WriterInfoResponse writer, + LocalDateTime createdAt + ) { + return new CommentResponse(commentId, content, isOwner, writer, createdAt); + } + } + + public record WriterInfoResponse( + Long writerId, + String nickname, + String profileImageUrl + ) { + + public static WriterInfoResponse of(Long writerId, String nickname, String profileImageUrl) { + return new WriterInfoResponse(writerId, nickname, profileImageUrl); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java new file mode 100644 index 00000000..34713c89 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java @@ -0,0 +1,47 @@ +package kr.spot.post.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record PostListResponse( + List posts, + boolean hasNext, + Long nextCursor +) { + + public record PostItem( + Long postId, + String title, + String content, + boolean isPinned, + PostStatsResponse stats, + WriterInfoResponse writer, + LocalDateTime createdAt + ) { + + public static PostItem of( + Long postId, + String title, + String content, + boolean isPinned, + PostStatsResponse stats, + WriterInfoResponse writer, + LocalDateTime createdAt + ) { + return new PostItem(postId, title, content, isPinned, stats, writer, createdAt); + } + } + + public record WriterInfoResponse( + Long writerId, + String nickname, + String profileImageUrl + ) { + + public static WriterInfoResponse of(Long writerId, String nickname, String profileImageUrl) { + return new WriterInfoResponse(writerId, nickname, profileImageUrl); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java new file mode 100644 index 00000000..b7a8ff2a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java @@ -0,0 +1,12 @@ +package kr.spot.post.presentation.query.dto; + +public record PostStatsResponse( + Long likeCount, + Long viewCount, + Long commentCount +) { + + public static PostStatsResponse from(Long likeCount, Long viewCount, Long commentCount) { + return new PostStatsResponse(likeCount, viewCount, commentCount); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java index c5f4b40c..a8428812 100644 --- a/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java +++ b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java @@ -33,4 +33,12 @@ public void validateStudyMember(Long studyId, Long memberId) { throw new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED); } } + + public boolean isStudyMember(Long studyId, Long memberId) { + return studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn( + studyId, + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + ); + } } diff --git a/modules/study/src/test/java/kr/spot/post/common/PostFixture.java b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java index d00440f4..b5d1b65e 100644 --- a/modules/study/src/test/java/kr/spot/post/common/PostFixture.java +++ b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java @@ -1,6 +1,7 @@ package kr.spot.post.common; import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; import kr.spot.post.domain.vo.WriterInfo; import kr.spot.post.presentation.command.dto.ManagePostRequest; @@ -35,6 +36,38 @@ public static Post post(Long id, Long studyId, Long writerId) { return Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, IS_PRIVATE); } + public static Post privatePost(Long id, Long studyId) { + return Post.of(id, studyId, writerInfo(), TITLE, CONTENT, true); + } + + public static Post privatePost(Long id, Long studyId, Long writerId) { + return Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, true); + } + + public static Post pinnedPost(Long id, Long studyId) { + Post post = Post.of(id, studyId, writerInfo(), TITLE, CONTENT, IS_PRIVATE); + post.pin(studyId); + return post; + } + + public static Post pinnedPost(Long id, Long studyId, Long writerId) { + Post post = Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, IS_PRIVATE); + post.pin(studyId); + return post; + } + + public static Post postWithContent(Long id, Long studyId, String content) { + return Post.of(id, studyId, writerInfo(), TITLE, content, IS_PRIVATE); + } + + public static PostStats postStats(Long postId) { + return PostStats.of(postId); + } + + public static PostStats postStats(Long postId, Long viewCount, Long likeCount, Long commentCount) { + return PostStats.of(postId, viewCount, likeCount, commentCount); + } + public static ManagePostRequest managePostRequest() { return new ManagePostRequest(TITLE, CONTENT, IS_PRIVATE); } From 783b51f417e839c1986b9019288a6094dac45f50 Mon Sep 17 00:00:00 2001 From: msk226 Date: Mon, 29 Dec 2025 14:20:00 +0900 Subject: [PATCH 028/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/mapper/PostResponseMapper.java | 137 ++++++ .../application/query/GetPostServiceTest.java | 416 ++++++++++++++++++ 2 files changed, 553 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java create mode 100644 modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java diff --git a/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java new file mode 100644 index 00000000..2508bcdf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java @@ -0,0 +1,137 @@ +package kr.spot.post.application.query.mapper; + +import java.util.List; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.domain.vo.WriterInfo; +import kr.spot.post.presentation.query.dto.PostDetailResponse; +import kr.spot.post.presentation.query.dto.PostDetailResponse.CommentResponse; +import kr.spot.post.presentation.query.dto.PostListResponse.PostItem; +import kr.spot.post.presentation.query.dto.PostListResponse.WriterInfoResponse; +import kr.spot.post.presentation.query.dto.PostStatsResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PostResponseMapper { + + public static final int DEFAULT_MAX_CONTENT_LENGTH = 100; + private static final String PRIVATE_POST_MESSAGE = "์ด ๊ธ€์€ ์Šคํ„ฐ๋””์›์—๊ฒŒ๋งŒ ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค."; + + public static PostDetailResponse toDetailResponse( + Post post, + PostStats stats, + long displayViewCount, + List comments, + long viewerId + ) { + return PostDetailResponse.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .isPinned(post.isPinned()) + .isOwner(post.isOwnedBy(viewerId)) + .writer(toDetailWriterInfoResponse(post.getWriterInfo())) + .stats(toStatsResponse(stats, displayViewCount)) + .createdAt(post.getCreatedAt()) + .comments(toCommentResponses(comments, viewerId)) + .build(); + } + + public static PostItem toPostItem(Post post, PostStats stats, int maxContentLength, boolean isStudyMember) { + if (post.isPrivate() && !isStudyMember) { + return toMaskedPostItem(post, stats); + } + return toPublicPostItem(post, stats, maxContentLength); + } + + private static PostItem toPublicPostItem(Post post, PostStats stats, int maxContentLength) { + return PostItem.of( + post.getId(), + post.getTitle(), + summarize(post.getContent(), maxContentLength), + post.isPinned(), + toStatsResponse(stats), + toWriterInfoResponse(post.getWriterInfo()), + post.getCreatedAt() + ); + } + + private static PostItem toMaskedPostItem(Post post, PostStats stats) { + return PostItem.of( + post.getId(), + PRIVATE_POST_MESSAGE, + PRIVATE_POST_MESSAGE, + post.isPinned(), + toStatsResponse(stats), + toWriterInfoResponse(post.getWriterInfo()), + post.getCreatedAt() + ); + } + + private static List toCommentResponses(List comments, long viewerId) { + return comments.stream() + .map(comment -> toCommentResponse(comment, viewerId)) + .toList(); + } + + private static CommentResponse toCommentResponse(Comment comment, long viewerId) { + WriterInfo writer = comment.getWriterInfo(); + return CommentResponse.of( + comment.getId(), + comment.getContent(), + writer.isSameWriter(viewerId), + toDetailWriterInfoResponse(writer), + comment.getCreatedAt() + ); + } + + private static WriterInfoResponse toWriterInfoResponse(WriterInfo writerInfo) { + return WriterInfoResponse.of( + writerInfo.getWriterId(), + writerInfo.getWriterName(), + writerInfo.getWriterProfileImageUrl() + ); + } + + private static PostDetailResponse.WriterInfoResponse toDetailWriterInfoResponse(WriterInfo writerInfo) { + return PostDetailResponse.WriterInfoResponse.of( + writerInfo.getWriterId(), + writerInfo.getWriterName(), + writerInfo.getWriterProfileImageUrl() + ); + } + + private static PostStatsResponse toStatsResponse(PostStats stats, long displayViewCount) { + if (stats == null) { + return PostStatsResponse.from(0L, displayViewCount, 0L); + } + return PostStatsResponse.from( + stats.getLikeCount(), + displayViewCount, + stats.getCommentCount() + ); + } + + private static PostStatsResponse toStatsResponse(PostStats stats) { + if (stats == null) { + return PostStatsResponse.from(0L, 0L, 0L); + } + return PostStatsResponse.from( + stats.getLikeCount(), + stats.getViewCount(), + stats.getCommentCount() + ); + } + + private static String summarize(String content, int maxLength) { + if (content == null) { + return ""; + } + String stripped = content.strip(); + return stripped.length() > maxLength + ? stripped.substring(0, maxLength) + "..." + : stripped; + } +} diff --git a/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java new file mode 100644 index 00000000..09ffd31a --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java @@ -0,0 +1,416 @@ +package kr.spot.post.application.query; + +import static kr.spot.post.common.CommentFixture.comment; +import static kr.spot.post.common.PostFixture.STUDY_ID; +import static kr.spot.post.common.PostFixture.WRITER_ID; +import static kr.spot.post.common.PostFixture.pinnedPost; +import static kr.spot.post.common.PostFixture.post; +import static kr.spot.post.common.PostFixture.postStats; +import static kr.spot.post.common.PostFixture.privatePost; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.querydsl.PostQueryRepository; +import kr.spot.post.presentation.query.dto.PostDetailResponse; +import kr.spot.post.presentation.query.dto.PostListResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetPostServiceTest { + + @Mock + ViewCounter viewCounter; + + @Mock + ViewAbuseGuard viewAbuseGuard; + + @Mock + StudyAccessValidator accessValidator; + + @Mock + PostRepository postRepository; + + @Mock + PostQueryRepository postQueryRepository; + + GetPostService getPostService; + + @BeforeEach + void setUp() { + getPostService = new GetPostService( + viewCounter, viewAbuseGuard, accessValidator, postRepository, postQueryRepository); + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์ƒ์„ธ ์กฐํšŒ (getPostDetail)") + class GetPostDetail { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_get_post_detail_successfully() { + // given + long postId = 1L; + long viewerId = WRITER_ID; + Post post = post(postId, STUDY_ID, viewerId); + PostStats stats = postStats(postId, 10L, 5L, 3L); + List comments = List.of(comment(1L, postId), comment(2L, postId)); + + when(postRepository.getById(postId)).thenReturn(post); + when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats); + when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(comments); + when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn(true); + when(viewCounter.incrementAndGet(ViewableType.STUDY_BOARD, postId)).thenReturn(1L); + + // when + PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId); + + // then + assertThat(response.postId()).isEqualTo(postId); + assertThat(response.stats().viewCount()).isEqualTo(11L); // 10 + 1 + assertThat(response.stats().likeCount()).isEqualTo(5L); + assertThat(response.comments()).hasSize(2); + assertThat(response.isOwner()).isTrue(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ๊ฒŒ์‹œ๊ธ€์„ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_belongs_to_different_study() { + // given + long postId = 1L; + long otherStudyId = 999L; + Post post = post(postId, STUDY_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy(() -> getPostService.getPostDetail(otherStudyId, postId, WRITER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS); + } + + @Test + @DisplayName("๋น„๊ณต๊ฐœ ๊ฒŒ์‹œ๊ธ€์„ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_is_private() { + // given + long postId = 1L; + Post post = privatePost(postId, STUDY_ID); + + when(postRepository.getById(postId)).thenReturn(post); + + // when & then + assertThatThrownBy(() -> getPostService.getPostDetail(STUDY_ID, postId, WRITER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._PRIVATE_POST_ACCESS_DENIED); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒŒ์‹œ๊ธ€์„ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_not_found() { + // given + long postId = 999L; + + when(postRepository.getById(postId)) + .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> getPostService.getPostDetail(STUDY_ID, postId, WRITER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); + } + + @Test + @DisplayName("์ค‘๋ณต ์กฐํšŒ ์‹œ ์กฐํšŒ์ˆ˜๊ฐ€ ์ฆ๊ฐ€ํ•˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_increment_view_when_already_viewed() { + // given + long postId = 1L; + long viewerId = WRITER_ID; + Post post = post(postId, STUDY_ID); + PostStats stats = postStats(postId, 10L, 5L, 3L); + + when(postRepository.getById(postId)).thenReturn(post); + when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats); + when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of()); + when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn(false); + when(viewCounter.currentDelta(ViewableType.STUDY_BOARD, postId)).thenReturn(5L); + + // when + PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId); + + // then + assertThat(response.stats().viewCount()).isEqualTo(15L); // 10 + 5 (๊ธฐ์กด delta) + verify(viewCounter, never()).incrementAndGet(any(), anyLong()); + } + + @Test + @DisplayName("Redis ์žฅ์•  ์‹œ์—๋„ ๊ธฐ์กด ์กฐํšŒ์ˆ˜๋กœ ์ •์ƒ ์‘๋‹ตํ•œ๋‹ค") + void should_return_base_view_count_when_redis_fails() { + // given + long postId = 1L; + long viewerId = WRITER_ID; + Post post = post(postId, STUDY_ID); + PostStats stats = postStats(postId, 10L, 5L, 3L); + + when(postRepository.getById(postId)).thenReturn(post); + when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats); + when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of()); + when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)) + .thenThrow(new RuntimeException("Redis connection failed")); + + // when + PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId); + + // then + assertThat(response.stats().viewCount()).isEqualTo(10L); // ๊ธฐ์กด ์กฐํšŒ์ˆ˜๋งŒ + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ isOwner๊ฐ€ false์ด๋‹ค") + void should_return_is_owner_false_when_not_author() { + // given + long postId = 1L; + long viewerId = 999L; // ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž + Post post = post(postId, STUDY_ID, WRITER_ID); + PostStats stats = postStats(postId); + + when(postRepository.getById(postId)).thenReturn(post); + when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats); + when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of()); + when(viewAbuseGuard.shouldCount(any(), anyLong(), anyLong())).thenReturn(false); + when(viewCounter.currentDelta(any(), anyLong())).thenReturn(0L); + + // when + PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId); + + // then + assertThat(response.isOwner()).isFalse(); + } + } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ (getPostList)") + class GetPostList { + + @Test + @DisplayName("์ฒซ ํŽ˜์ด์ง€์—์„œ ํ•€ ๊ฒŒ์‹œ๊ธ€์ด ๋จผ์ € ๋‚˜์˜จ๋‹ค") + void should_show_pinned_posts_first_on_first_page() { + // given + Post pinned1 = pinnedPost(1L, STUDY_ID); + Post pinned2 = pinnedPost(2L, STUDY_ID); + Post normal1 = post(3L, STUDY_ID); + Post normal2 = post(4L, STUDY_ID); + + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of(pinned1, pinned2)); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt())) + .thenReturn(List.of(normal1, normal2)); + when(postQueryRepository.findStatsByPostIds(any())) + .thenReturn(Map.of(1L, postStats(1L), 2L, postStats(2L), 3L, postStats(3L), 4L, postStats(4L))); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10); + + // then + assertThat(response.posts()).hasSize(4); + assertThat(response.posts().get(0).isPinned()).isTrue(); + assertThat(response.posts().get(1).isPinned()).isTrue(); + assertThat(response.posts().get(2).isPinned()).isFalse(); + assertThat(response.posts().get(3).isPinned()).isFalse(); + } + + @Test + @DisplayName("๋‘ ๋ฒˆ์งธ ํŽ˜์ด์ง€์—์„œ๋Š” ํ•€ ๊ฒŒ์‹œ๊ธ€์ด ์กฐํšŒ๋˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_show_pinned_posts_on_second_page() { + // given + Long cursor = 3L; + Post normal1 = post(2L, STUDY_ID); + Post normal2 = post(1L, STUDY_ID); + + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(cursor), anyInt())) + .thenReturn(List.of(normal1, normal2)); + when(postQueryRepository.findStatsByPostIds(any())) + .thenReturn(Map.of(2L, postStats(2L), 1L, postStats(1L))); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, cursor, WRITER_ID, 10); + + // then + assertThat(response.posts()).hasSize(2); + verify(postQueryRepository, never()).findPinnedPosts(anyLong()); + } + + @Test + @DisplayName("ํŽ˜์ด์ง€๋„ค์ด์…˜์ด ์ •์ƒ ๋™์ž‘ํ•œ๋‹ค") + void should_handle_pagination_correctly() { + // given + Post post1 = post(5L, STUDY_ID); + Post post2 = post(4L, STUDY_ID); + Post post3 = post(3L, STUDY_ID); // ๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ํ™•์ธ์šฉ + + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of()); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(3))) // size + 1 + .thenReturn(List.of(post1, post2, post3)); + when(postQueryRepository.findStatsByPostIds(any())) + .thenReturn(Map.of(5L, postStats(5L), 4L, postStats(4L))); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 2); + + // then + assertThat(response.posts()).hasSize(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextCursor()).isEqualTo(4L); + } + + @Test + @DisplayName("๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์—์„œ๋Š” hasNext๊ฐ€ false์ด๋‹ค") + void should_return_has_next_false_on_last_page() { + // given + Post post1 = post(2L, STUDY_ID); + Post post2 = post(1L, STUDY_ID); + + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of()); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(11))) + .thenReturn(List.of(post1, post2)); // size๋ณด๋‹ค ์ ์Œ + when(postQueryRepository.findStatsByPostIds(any())) + .thenReturn(Map.of(2L, postStats(2L), 1L, postStats(1L))); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10); + + // then + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + } + + @Test + @DisplayName("์Šคํ„ฐ๋””์›์ธ ๊ฒฝ์šฐ ๋น„๊ณต๊ฐœ ๊ฒŒ์‹œ๊ธ€์ด ์ •์ƒ ํ‘œ์‹œ๋œ๋‹ค") + void should_show_private_post_content_for_study_member() { + // given + Post privatePostItem = privatePost(1L, STUDY_ID); + + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of()); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt())) + .thenReturn(List.of(privatePostItem)); + when(postQueryRepository.findStatsByPostIds(any())) + .thenReturn(Map.of(1L, postStats(1L))); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10); + + // then + assertThat(response.posts().get(0).title()).isNotEqualTo("์ด ๊ธ€์€ ์Šคํ„ฐ๋””์›์—๊ฒŒ๋งŒ ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("๋น„์Šคํ„ฐ๋””์›์ธ ๊ฒฝ์šฐ ๋น„๊ณต๊ฐœ ๊ฒŒ์‹œ๊ธ€์ด ๋งˆ์Šคํ‚น๋œ๋‹ค") + void should_mask_private_post_content_for_non_study_member() { + // given + long nonMemberId = 999L; + Post privatePostItem = privatePost(1L, STUDY_ID); + + when(accessValidator.isStudyMember(STUDY_ID, nonMemberId)).thenReturn(false); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of()); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt())) + .thenReturn(List.of(privatePostItem)); + when(postQueryRepository.findStatsByPostIds(any())) + .thenReturn(Map.of(1L, postStats(1L))); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, null, nonMemberId, 10); + + // then + assertThat(response.posts().get(0).title()).isEqualTo("์ด ๊ธ€์€ ์Šคํ„ฐ๋””์›์—๊ฒŒ๋งŒ ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค."); + assertThat(response.posts().get(0).content()).isEqualTo("์ด ๊ธ€์€ ์Šคํ„ฐ๋””์›์—๊ฒŒ๋งŒ ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์ด ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_no_posts() { + // given + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of()); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt())) + .thenReturn(List.of()); + when(postQueryRepository.findStatsByPostIds(any())).thenReturn(Map.of()); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10); + + // then + assertThat(response.posts()).isEmpty(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + } + + @Test + @DisplayName("size๊ฐ€ MAX_PAGE_SIZE๋ฅผ ์ดˆ๊ณผํ•˜๋ฉด MAX_PAGE_SIZE๋กœ ์ œํ•œ๋œ๋‹ค") + void should_limit_page_size_to_max() { + // given + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of()); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(51))) // MAX_PAGE_SIZE + 1 + .thenReturn(List.of()); + when(postQueryRepository.findStatsByPostIds(any())).thenReturn(Map.of()); + + // when + getPostService.getPostList(STUDY_ID, null, WRITER_ID, 100); + + // then + verify(postQueryRepository).findPageByIdDesc(STUDY_ID, null, 51); + } + + @Test + @DisplayName("ํ•€ ๊ฒŒ์‹œ๊ธ€๊ณผ ์ผ๋ฐ˜ ๊ฒŒ์‹œ๊ธ€์ด ํ•จ๊ป˜ ์žˆ์„ ๋•Œ ์˜ฌ๋ฐ”๋ฅธ ํŽ˜์ด์ง€ ํฌ๊ธฐ๊ฐ€ ์ ์šฉ๋œ๋‹ค") + void should_apply_correct_page_size_with_pinned_posts() { + // given + Post pinned = pinnedPost(10L, STUDY_ID); + Post normal1 = post(5L, STUDY_ID); + Post normal2 = post(4L, STUDY_ID); + Post normal3 = post(3L, STUDY_ID); // ๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ํ™•์ธ์šฉ + + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); + when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of(pinned)); + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(3))) // size(3) - pinned(1) + 1 + .thenReturn(List.of(normal1, normal2, normal3)); + when(postQueryRepository.findStatsByPostIds(any())) + .thenReturn(Map.of(10L, postStats(10L), 5L, postStats(5L), 4L, postStats(4L))); + + // when + PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 3); + + // then + assertThat(response.posts()).hasSize(3); // pinned(1) + normal(2) + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextCursor()).isEqualTo(4L); + } + } +} From bdfe01589cb89355794cd8e26b5a63c8c18fed95 Mon Sep 17 00:00:00 2001 From: msk226 Date: Mon, 29 Dec 2025 14:29:52 +0900 Subject: [PATCH 029/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=EC=B2=B4=ED=99=94=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/post/application/query/GetPostService.java | 2 +- .../src/main/java/kr/spot/post/domain/Post.java | 4 ++-- .../application/validator/StudyAccessValidator.java | 8 ++------ .../post/application/query/GetPostServiceTest.java | 13 +++++++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java index 90cf37e2..d2f9c7eb 100644 --- a/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java +++ b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java @@ -40,7 +40,7 @@ public class GetPostService { public PostDetailResponse getPostDetail(long studyId, long postId, long viewerId) { Post post = findAndValidatePost(studyId, postId); - accessValidator.validateStudyMember(studyId, viewerId); + post.validatePublicAccess(accessValidator.isStudyMember(studyId, viewerId)); PostStats stats = postQueryRepository.findStatsByPostId(postId); List comments = postQueryRepository.findCommentsByPostId(postId); diff --git a/modules/study/src/main/java/kr/spot/post/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java index 1608c21b..6553e3f2 100644 --- a/modules/study/src/main/java/kr/spot/post/domain/Post.java +++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java @@ -83,8 +83,8 @@ public void validateBelongsToStudy(long studyId) { validateStudyId(studyId); } - public void validatePublicAccess() { - if (this.isPrivate) { + public void validatePublicAccess(boolean isStudyMember) { + if (this.isPrivate && !isStudyMember) { throw new GeneralException(ErrorStatus._PRIVATE_POST_ACCESS_DENIED); } } diff --git a/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java index a8428812..7ad9d3ae 100644 --- a/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java +++ b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java @@ -23,12 +23,8 @@ public void validateStudyLeader(Long studyId, Long memberId) { } public void validateStudyMember(Long studyId, Long memberId) { - boolean isMember = studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn( - studyId, - memberId, - List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) - ); - + boolean isMember = isStudyMember(studyId, memberId); + if (!isMember) { throw new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED); } diff --git a/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java index 09ffd31a..21c93e0a 100644 --- a/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java +++ b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java @@ -121,6 +121,7 @@ void should_throw_exception_when_post_is_private() { Post post = privatePost(postId, STUDY_ID); when(postRepository.getById(postId)).thenReturn(post); + when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(false); // when & then assertThatThrownBy(() -> getPostService.getPostDetail(STUDY_ID, postId, WRITER_ID)) @@ -155,7 +156,8 @@ void should_not_increment_view_when_already_viewed() { when(postRepository.getById(postId)).thenReturn(post); when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats); when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of()); - when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn(false); + when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn( + false); when(viewCounter.currentDelta(ViewableType.STUDY_BOARD, postId)).thenReturn(5L); // when @@ -229,7 +231,8 @@ void should_show_pinned_posts_first_on_first_page() { when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt())) .thenReturn(List.of(normal1, normal2)); when(postQueryRepository.findStatsByPostIds(any())) - .thenReturn(Map.of(1L, postStats(1L), 2L, postStats(2L), 3L, postStats(3L), 4L, postStats(4L))); + .thenReturn( + Map.of(1L, postStats(1L), 2L, postStats(2L), 3L, postStats(3L), 4L, postStats(4L))); // when PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10); @@ -377,7 +380,8 @@ void should_limit_page_size_to_max() { // given when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of()); - when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(51))) // MAX_PAGE_SIZE + 1 + when( + postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(51))) // MAX_PAGE_SIZE + 1 .thenReturn(List.of()); when(postQueryRepository.findStatsByPostIds(any())).thenReturn(Map.of()); @@ -399,7 +403,8 @@ void should_apply_correct_page_size_with_pinned_posts() { when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true); when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of(pinned)); - when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(3))) // size(3) - pinned(1) + 1 + when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), + eq(3))) // size(3) - pinned(1) + 1 .thenReturn(List.of(normal1, normal2, normal3)); when(postQueryRepository.findStatsByPostIds(any())) .thenReturn(Map.of(10L, postStats(10L), 5L, postStats(5L), 4L, postStats(4L))); From 08027ab62658071fc233f0830caee991c03df88d Mon Sep 17 00:00:00 2001 From: msk226 Date: Mon, 29 Dec 2025 14:53:43 +0900 Subject: [PATCH 030/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EB=B0=98=EC=98=81=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/batch/PostViewFlusher.java | 2 +- modules/study/build.gradle | 1 + .../batch/StudyPostViewFlushJob.java | 157 ++++++++++++++++++ .../batch/StudyPostViewFlusher.java | 22 +++ .../jpa/PostStatsRepository.java | 9 + .../batch/StudyPostViewFlushJobTest.java | 94 +++++++++++ .../batch/StudyPostViewFlusherTest.java | 38 +++++ 7 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java create mode 100644 modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java create mode 100644 modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java diff --git a/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java b/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java index 579236f2..4e7fbc54 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java @@ -17,6 +17,6 @@ public class PostViewFlusher { @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateViewCount(Long postId, long delta) { postStatsRepository.increaseViewBy(postId, delta); - log.debug("DB ์—…๋ฐ์ดํŠธ: postId={}, delta={}", postId, delta); + log.debug("๊ฒŒ์‹œ๊ธ€ ์กฐํšŒ์ˆ˜ DB ์—…๋ฐ์ดํŠธ: postId={}, delta={}", postId, delta); } } diff --git a/modules/study/build.gradle b/modules/study/build.gradle index ce7b6717..2eaa92b6 100644 --- a/modules/study/build.gradle +++ b/modules/study/build.gradle @@ -18,6 +18,7 @@ dependencies { // Spring Boot Starters implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework.boot:spring-boot-starter-data-redis" // Testing testImplementation "org.springframework.boot:spring-boot-starter-test" diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java new file mode 100644 index 00000000..7bc4dedc --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java @@ -0,0 +1,157 @@ +package kr.spot.post.infrastructure.batch; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyPostViewFlushJob { + + private static final String KEY_PREFIX = "view:study-board:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + private static final String LOCK_KEY = "lock:study-post-view-flush"; + + private final StringRedisTemplate redis; + private final StudyPostViewFlusher studyPostViewFlusher; + + @Scheduled(cron = "0 * * * * *") + public void flush() { + if (!tryAcquireLock()) { + log.debug("๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์—์„œ ์‹คํ–‰ ์ค‘"); + return; + } + try { + executeBatch(); + } finally { + releaseLock(); + } + } + + private void executeBatch() { + log.info("StudyPostViewFlushJob ์‹œ์ž‘"); + long startTime = System.currentTimeMillis(); + + BatchResult result = processAllKeys(); + + long duration = System.currentTimeMillis() - startTime; + log.info("StudyPostViewFlushJob ์™„๋ฃŒ: ์„ฑ๊ณต {}๊ฑด, ์‹คํŒจ {}๊ฑด, {}ms", + result.successCount, result.failureCount, duration); + } + + private BatchResult processAllKeys() { + BatchResult result = new BatchResult(); + ScanOptions options = ScanOptions.scanOptions() + .match(KEY_PATTERN) + .count(500) + .build(); + + redis.execute((RedisCallback) connection -> { + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + byte[] keyBytes = cursor.next(); + processKey(keyBytes, result); + } + } catch (Exception e) { + log.error("SCAN ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜", e); + } + return null; + }); + + return result; + } + + private void processKey(byte[] keyBytes, BatchResult result) { + String key = new String(keyBytes, StandardCharsets.UTF_8); + long delta = 0; + + try { + String valueStr = redis.opsForValue().getAndDelete(key); + if (valueStr == null) { + return; + } + + delta = parseLong(valueStr); + if (delta <= 0) { + log.warn("์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฐ’ ๋ฌด์‹œ: key={}, value={}", key, valueStr); + return; + } + + Long postId = extractPostIdFromKey(key); + if (postId == null) { + log.error("postId ์ถ”์ถœ ์‹คํŒจ, ๊ฐ’ ์†์‹ค: key={}, delta={}", key, delta); + return; + } + + studyPostViewFlusher.updateViewCount(postId, delta); + result.recordSuccess(); + log.debug("์ฒ˜๋ฆฌ ์™„๋ฃŒ: postId={}, delta={}", postId, delta); + + } catch (Exception e) { + result.recordFailure(); + log.error("์ฒ˜๋ฆฌ ์‹คํŒจ: key={}", key, e); + if (delta > 0) { + restoreValue(key, delta); + } + } + } + + private void restoreValue(String key, long delta) { + try { + redis.opsForValue().increment(key, delta); + log.warn("DB ๋ฐ˜์˜ ์‹คํŒจ, Redis์— ๊ฐ’ ๋ณต๊ตฌ ์™„๋ฃŒ: key={}, delta={}", key, delta); + } catch (Exception e) { + log.error("๋ณต๊ตฌ ์‹คํŒจ, ๋ฐ์ดํ„ฐ ์†์‹ค: key={}, delta={}", key, delta, e); + } + } + + private boolean tryAcquireLock() { + Boolean acquired = redis.opsForValue() + .setIfAbsent(LOCK_KEY, "1", 55, TimeUnit.SECONDS); + return Boolean.TRUE.equals(acquired); + } + + private void releaseLock() { + redis.delete(LOCK_KEY); + } + + private long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + log.warn("Long ํŒŒ์‹ฑ ์‹คํŒจ: {}", value); + return 0L; + } + } + + private Long extractPostIdFromKey(String key) { + try { + return Long.parseLong(key.substring(KEY_PREFIX.length())); + } catch (Exception e) { + log.error("postId ์ถ”์ถœ ์‹คํŒจ: {}", key, e); + return null; + } + } + + private static class BatchResult { + + int successCount = 0; + int failureCount = 0; + + void recordSuccess() { + successCount++; + } + + void recordFailure() { + failureCount++; + } + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java new file mode 100644 index 00000000..77556f1e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java @@ -0,0 +1,22 @@ +package kr.spot.post.infrastructure.batch; + +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyPostViewFlusher { + + private final PostStatsRepository postStatsRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateViewCount(Long postId, long delta) { + postStatsRepository.increaseViewBy(postId, delta); + log.debug("์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์กฐํšŒ์ˆ˜ DB ์—…๋ฐ์ดํŠธ: postId={}, delta={}", postId, delta); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java index e9f8a112..ea15849c 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java @@ -17,4 +17,13 @@ public interface PostStatsRepository extends JpaRepository { @Modifying @Query("update StudyPostStats s set s.commentCount = case when s.commentCount > 0 then s.commentCount - 1 else 0 end where s.postId = :postId") int decreaseCommentCount(@Param("postId") long postId); + + @Modifying + @Query(""" + update StudyPostStats s + set s.viewCount = s.viewCount + :delta, + s.updatedAt = CURRENT_TIMESTAMP + where s.postId = :postId and s.status = 'ACTIVE' + """) + int increaseViewBy(@Param("postId") long postId, @Param("delta") long delta); } diff --git a/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java new file mode 100644 index 00000000..4be2c686 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java @@ -0,0 +1,94 @@ +package kr.spot.post.infrastructure.batch; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +class StudyPostViewFlushJobTest { + + private static final String LOCK_KEY = "lock:study-post-view-flush"; + + @Mock + StringRedisTemplate redis; + + @Mock + ValueOperations valueOps; + + @Mock + StudyPostViewFlusher studyPostViewFlusher; + + StudyPostViewFlushJob job; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(redis.opsForValue()).thenReturn(valueOps); + job = new StudyPostViewFlushJob(redis, studyPostViewFlusher); + } + + @Nested + @DisplayName("๋ถ„์‚ฐ ๋ฝ ํ…Œ์ŠคํŠธ") + class DistributedLockTest { + + @Test + @DisplayName("๋ฝ ํš๋“ ์‹คํŒจ ์‹œ ๋ฐฐ์น˜๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_execute_when_lock_not_acquired() { + // given + when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS))) + .thenReturn(false); + + // when + job.flush(); + + // then + verify(redis, never()).execute(any(RedisCallback.class)); + } + + @Test + @DisplayName("๋ฝ ํš๋“ ์„ฑ๊ณต ์‹œ ๋ฐฐ์น˜๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ๋ฝ์„ ํ•ด์ œํ•œ๋‹ค") + void should_execute_and_release_lock_when_acquired() { + // given + when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS))) + .thenReturn(true); + + // when + job.flush(); + + // then + verify(redis).execute(any(RedisCallback.class)); + verify(redis).delete(LOCK_KEY); + } + + @Test + @DisplayName("๋ฐฐ์น˜ ์‹คํ–‰ ์ค‘ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ๋ฝ์„ ํ•ด์ œํ•œ๋‹ค") + void should_release_lock_even_when_exception_occurs() { + // given + when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS))) + .thenReturn(true); + when(redis.execute(any(RedisCallback.class))) + .thenThrow(new RuntimeException("Redis error")); + + // when + try { + job.flush(); + } catch (Exception ignored) { + } + + // then + verify(redis).delete(LOCK_KEY); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java new file mode 100644 index 00000000..cdd9cad3 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java @@ -0,0 +1,38 @@ +package kr.spot.post.infrastructure.batch; + +import static org.mockito.Mockito.verify; + +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class StudyPostViewFlusherTest { + + @Mock + PostStatsRepository postStatsRepository; + + StudyPostViewFlusher flusher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + flusher = new StudyPostViewFlusher(postStatsRepository); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์กฐํšŒ์ˆ˜ ๋ธํƒ€๋ฅผ DB์— ๋ฐ˜์˜ํ•œ๋‹ค") + void should_update_view_count_in_db() { + // given + Long postId = 123L; + long delta = 50L; + + // when + flusher.updateViewCount(postId, delta); + + // then + verify(postStatsRepository).increaseViewBy(postId, delta); + } +} From 041bca79576ac655795ddf78ffce3494d01e14e9 Mon Sep 17 00:00:00 2001 From: msk226 Date: Mon, 29 Dec 2025 23:34:03 +0900 Subject: [PATCH 031/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/command/LikePostService.java | 45 ++++++ .../application/query/GetPostService.java | 16 ++- .../query/mapper/PostResponseMapper.java | 18 ++- .../java/kr/spot/post/domain/PostLike.java | 36 +++++ .../jpa/PostLikeRepository.java | 31 ++++ .../jpa/PostStatsRepository.java | 8 ++ .../jpa/querydsl/PostQueryRepository.java | 29 +++- .../command/PostCommandController.java | 34 +++++ .../query/dto/PostDetailResponse.java | 1 + .../query/dto/PostListResponse.java | 4 +- .../command/LikePostServiceTest.java | 132 ++++++++++++++++++ 11 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java create mode 100644 modules/study/src/main/java/kr/spot/post/domain/PostLike.java create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java create mode 100644 modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java diff --git a/modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java b/modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java new file mode 100644 index 00000000..fd742c95 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java @@ -0,0 +1,45 @@ +package kr.spot.post.application.command; + +import kr.spot.IdGenerator; +import kr.spot.post.infrastructure.jpa.PostLikeRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service(value = "studyLikePostService") +@Transactional +@RequiredArgsConstructor +public class LikePostService { + + private final IdGenerator idGenerator; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final PostStatsRepository postStatsRepository; + private final StudyAccessValidator accessValidator; + + public void likePost(long studyId, long postId, long memberId) { + validateAccess(studyId, postId, memberId); + + int inserted = postLikeRepository.savePostLike(idGenerator.nextId(), postId, memberId); + if (inserted == 1) { + postStatsRepository.increaseLike(postId); + } + } + + public void unlikePost(long studyId, long postId, long memberId) { + validateAccess(studyId, postId, memberId); + + int deleted = postLikeRepository.hardDelete(postId, memberId); + if (deleted > 0) { + postStatsRepository.decreaseLike(postId); + } + } + + private void validateAccess(long studyId, long postId, long memberId) { + accessValidator.validateStudyMember(studyId, memberId); + postRepository.getById(postId).validateBelongsToStudy(studyId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java index d2f9c7eb..9e28c9a9 100644 --- a/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java +++ b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import kr.spot.post.domain.Comment; import kr.spot.post.domain.Post; import kr.spot.post.domain.PostStats; @@ -45,8 +46,9 @@ public PostDetailResponse getPostDetail(long studyId, long postId, long viewerId PostStats stats = postQueryRepository.findStatsByPostId(postId); List comments = postQueryRepository.findCommentsByPostId(postId); long displayViewCount = calculateDisplayViewCount(stats, postId, viewerId); + boolean isLiked = postQueryRepository.isLiked(viewerId, postId); - return toDetailResponse(post, stats, displayViewCount, comments, viewerId); + return toDetailResponse(post, stats, displayViewCount, comments, viewerId, isLiked); } public PostListResponse getPostList(long studyId, Long cursor, long viewerId, int size) { @@ -59,8 +61,9 @@ public PostListResponse getPostList(long studyId, Long cursor, long viewerId, in List allPosts = mergePosts(pinnedPosts, paginationResult.posts()); Map statsMap = fetchStatsForPosts(allPosts); + Set likedPostIds = fetchLikedPostIds(allPosts, viewerId); - List postItems = mapToPostItems(allPosts, statsMap, isStudyMember); + List postItems = mapToPostItems(allPosts, statsMap, isStudyMember, likedPostIds); return buildListResponse(postItems, paginationResult); } @@ -106,11 +109,16 @@ private Map fetchStatsForPosts(List posts) { return postQueryRepository.findStatsByPostIds(postIds); } + private Set fetchLikedPostIds(List posts, long viewerId) { + List postIds = posts.stream().map(Post::getId).toList(); + return postQueryRepository.findLikedPostIds(viewerId, postIds); + } + private List mapToPostItems(List posts, Map statsMap, - boolean isStudyMember) { + boolean isStudyMember, Set likedPostIds) { return posts.stream() .map(post -> toPostItem(post, statsMap.get(post.getId()), DEFAULT_MAX_CONTENT_LENGTH, - isStudyMember)) + isStudyMember, likedPostIds.contains(post.getId()))) .toList(); } diff --git a/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java index 2508bcdf..d00e12de 100644 --- a/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java +++ b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java @@ -24,7 +24,8 @@ public static PostDetailResponse toDetailResponse( PostStats stats, long displayViewCount, List comments, - long viewerId + long viewerId, + boolean isLiked ) { return PostDetailResponse.builder() .postId(post.getId()) @@ -32,6 +33,7 @@ public static PostDetailResponse toDetailResponse( .content(post.getContent()) .isPinned(post.isPinned()) .isOwner(post.isOwnedBy(viewerId)) + .isLiked(isLiked) .writer(toDetailWriterInfoResponse(post.getWriterInfo())) .stats(toStatsResponse(stats, displayViewCount)) .createdAt(post.getCreatedAt()) @@ -39,31 +41,35 @@ public static PostDetailResponse toDetailResponse( .build(); } - public static PostItem toPostItem(Post post, PostStats stats, int maxContentLength, boolean isStudyMember) { + public static PostItem toPostItem(Post post, PostStats stats, int maxContentLength, + boolean isStudyMember, boolean isLiked) { if (post.isPrivate() && !isStudyMember) { - return toMaskedPostItem(post, stats); + return toMaskedPostItem(post, stats, isLiked); } - return toPublicPostItem(post, stats, maxContentLength); + return toPublicPostItem(post, stats, maxContentLength, isLiked); } - private static PostItem toPublicPostItem(Post post, PostStats stats, int maxContentLength) { + private static PostItem toPublicPostItem(Post post, PostStats stats, int maxContentLength, + boolean isLiked) { return PostItem.of( post.getId(), post.getTitle(), summarize(post.getContent(), maxContentLength), post.isPinned(), + isLiked, toStatsResponse(stats), toWriterInfoResponse(post.getWriterInfo()), post.getCreatedAt() ); } - private static PostItem toMaskedPostItem(Post post, PostStats stats) { + private static PostItem toMaskedPostItem(Post post, PostStats stats, boolean isLiked) { return PostItem.of( post.getId(), PRIVATE_POST_MESSAGE, PRIVATE_POST_MESSAGE, post.isPinned(), + isLiked, toStatsResponse(stats), toWriterInfoResponse(post.getWriterInfo()), post.getCreatedAt() diff --git a/modules/study/src/main/java/kr/spot/post/domain/PostLike.java b/modules/study/src/main/java/kr/spot/post/domain/PostLike.java new file mode 100644 index 00000000..a8b6ee95 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/domain/PostLike.java @@ -0,0 +1,36 @@ +package kr.spot.post.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.spot.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity(name = "StudyPostLike") +@SQLDelete(sql = "UPDATE study_post_like SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "study_post_like", + uniqueConstraints = @UniqueConstraint(name = "uk_study_post_member", columnNames = {"post_id", + "member_id"})) +public class PostLike extends BaseEntity { + + @Id + private Long id; + + private Long postId; + + private Long memberId; + + public static PostLike of(Long id, Long postId, Long memberId) { + return new PostLike(id, postId, memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java new file mode 100644 index 00000000..4bb4ee2b --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java @@ -0,0 +1,31 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.post.domain.PostLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository(value = "studyPostLikeRepository") +public interface PostLikeRepository extends JpaRepository { + + @Modifying + @Query( + value = """ + INSERT IGNORE INTO study_post_like(id, post_id, member_id, status, created_at, updated_at) + VALUES (:id, :postId, :memberId, 'ACTIVE', NOW(), NOW()) + """, nativeQuery = true) + int savePostLike(@Param("id") long id, + @Param("postId") long postId, + @Param("memberId") long memberId); + + @Modifying + @Query(value = """ + DELETE FROM study_post_like + WHERE post_id = :postId + AND member_id = :memberId + """, nativeQuery = true) + int hardDelete(@Param("postId") Long postId, + @Param("memberId") Long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java index ea15849c..f65e1ac8 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java @@ -26,4 +26,12 @@ public interface PostStatsRepository extends JpaRepository { where s.postId = :postId and s.status = 'ACTIVE' """) int increaseViewBy(@Param("postId") long postId, @Param("delta") long delta); + + @Modifying + @Query("update StudyPostStats s set s.likeCount = s.likeCount + 1 where s.postId = :postId") + int increaseLike(@Param("postId") long postId); + + @Modifying + @Query("update StudyPostStats s set s.likeCount = case when s.likeCount > 0 then s.likeCount - 1 else 0 end where s.postId = :postId") + int decreaseLike(@Param("postId") long postId); } diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java index 9266c3d2..b4e74e75 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java @@ -3,15 +3,18 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; +import kr.spot.post.domain.Comment; import kr.spot.post.domain.Post; import kr.spot.post.domain.PostStats; import kr.spot.post.domain.QComment; import kr.spot.post.domain.QPost; +import kr.spot.post.domain.QPostLike; import kr.spot.post.domain.QPostStats; -import kr.spot.post.domain.Comment; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -24,6 +27,7 @@ public class PostQueryRepository { private final QPost post = QPost.post; private final QPostStats postStats = QPostStats.postStats; private final QComment comment = QComment.comment; + private final QPostLike postLike = QPostLike.postLike; public List findPinnedPosts(long studyId) { return query @@ -80,4 +84,27 @@ public List findCommentsByPostId(long postId) { .orderBy(comment.createdAt.asc()) .fetch(); } + + public Set findLikedPostIds(Long viewerId, Collection postIds) { + if (viewerId == null || postIds.isEmpty()) { + return Set.of(); + } + return new HashSet<>( + query.select(postLike.postId) + .from(postLike) + .where(postLike.memberId.eq(viewerId), postLike.postId.in(postIds)) + .fetch() + ); + } + + public boolean isLiked(Long viewerId, Long postId) { + if (viewerId == null) { + return false; + } + Integer result = query.selectOne() + .from(postLike) + .where(postLike.memberId.eq(viewerId), postLike.postId.eq(postId)) + .fetchFirst(); + return result != null; + } } diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java index 3cf4fc82..610210c1 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java @@ -9,6 +9,7 @@ import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; import kr.spot.code.status.SuccessStatus; +import kr.spot.post.application.command.LikePostService; import kr.spot.post.application.command.ManagePostService; import kr.spot.post.presentation.command.dto.ManagePostRequest; import lombok.RequiredArgsConstructor; @@ -28,6 +29,7 @@ public class PostCommandController { private final ManagePostService managePostService; + private final LikePostService likePostService; @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ", description = "์Šคํ„ฐ๋””์— ์ƒˆ๋กœ์šด ๊ฒŒ์‹œ๊ธ€์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.") @ApiResponses({ @@ -108,4 +110,36 @@ public ResponseEntity> unpinPost( managePostService.unpinPost(studyId, postId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } + + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š”", description = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€์— ์ข‹์•„์š”๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์ข‹์•„์š” ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: ํ•ด๋‹น ์Šคํ„ฐ๋””์— ๋Œ€ํ•œ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{postId}/like") + public ResponseEntity> likePost( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + likePostService.likePost(studyId, postId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š” ์ทจ์†Œ", description = "์Šคํ„ฐ๋”” ๊ฒŒ์‹œ๊ธ€์˜ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์ข‹์•„์š” ์ทจ์†Œ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: ํ•ด๋‹น ์Šคํ„ฐ๋””์— ๋Œ€ํ•œ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/{postId}/like") + public ResponseEntity> unlikePost( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "๊ฒŒ์‹œ๊ธ€ ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + likePostService.unlikePost(studyId, postId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } } diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java index bc766a6f..4c729b70 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java @@ -11,6 +11,7 @@ public record PostDetailResponse( String content, boolean isPinned, boolean isOwner, + boolean isLiked, WriterInfoResponse writer, PostStatsResponse stats, LocalDateTime createdAt, diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java index 34713c89..c84eff4e 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java @@ -16,6 +16,7 @@ public record PostItem( String title, String content, boolean isPinned, + boolean isLiked, PostStatsResponse stats, WriterInfoResponse writer, LocalDateTime createdAt @@ -26,11 +27,12 @@ public static PostItem of( String title, String content, boolean isPinned, + boolean isLiked, PostStatsResponse stats, WriterInfoResponse writer, LocalDateTime createdAt ) { - return new PostItem(postId, title, content, isPinned, stats, writer, createdAt); + return new PostItem(postId, title, content, isPinned, isLiked, stats, writer, createdAt); } } diff --git a/modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java new file mode 100644 index 00000000..8982f9e1 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java @@ -0,0 +1,132 @@ +package kr.spot.post.application.command; + +import static kr.spot.post.common.PostFixture.STUDY_ID; +import static kr.spot.post.common.PostFixture.post; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.post.domain.Post; +import kr.spot.post.infrastructure.jpa.PostLikeRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LikePostServiceTest { + + static final long POST_ID = 1L; + static final long MEMBER_ID = 200L; + static final long GENERATED_ID = 999L; + + @Mock + IdGenerator idGenerator; + + @Mock + PostRepository postRepository; + + @Mock + PostLikeRepository postLikeRepository; + + @Mock + PostStatsRepository postStatsRepository; + + @Mock + StudyAccessValidator accessValidator; + + LikePostService likePostService; + + @BeforeEach + void setUp() { + likePostService = new LikePostService( + idGenerator, postRepository, postLikeRepository, postStatsRepository, accessValidator + ); + } + + @Nested + @DisplayName("์ข‹์•„์š” ์ถ”๊ฐ€ (likePost)") + class LikePost { + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ๊ฒŒ์‹œ๊ธ€์— ์ข‹์•„์š”๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค") + void should_like_post_when_member() { + // given + Post post = post(POST_ID, STUDY_ID, MEMBER_ID); + when(postRepository.getById(POST_ID)).thenReturn(post); + when(idGenerator.nextId()).thenReturn(GENERATED_ID); + when(postLikeRepository.savePostLike(GENERATED_ID, POST_ID, MEMBER_ID)).thenReturn(1); + + // when + likePostService.likePost(STUDY_ID, POST_ID, MEMBER_ID); + + // then + verify(accessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + verify(postLikeRepository).savePostLike(GENERATED_ID, POST_ID, MEMBER_ID); + verify(postStatsRepository).increaseLike(POST_ID); + } + + @Test + @DisplayName("์ด๋ฏธ ์ข‹์•„์š”ํ•œ ๊ฒŒ์‹œ๊ธ€์— ๋‹ค์‹œ ์ข‹์•„์š”ํ•˜๋ฉด ์นด์šดํŠธ๊ฐ€ ์ฆ๊ฐ€ํ•˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_increase_like_count_when_already_liked() { + // given + Post post = post(POST_ID, STUDY_ID, MEMBER_ID); + when(postRepository.getById(POST_ID)).thenReturn(post); + when(idGenerator.nextId()).thenReturn(GENERATED_ID); + when(postLikeRepository.savePostLike(GENERATED_ID, POST_ID, MEMBER_ID)).thenReturn(0); + + // when + likePostService.likePost(STUDY_ID, POST_ID, MEMBER_ID); + + // then + verify(postLikeRepository).savePostLike(GENERATED_ID, POST_ID, MEMBER_ID); + verify(postStatsRepository, never()).increaseLike(anyLong()); + } + } + + @Nested + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ (unlikePost)") + class UnlikePost { + + @Test + @DisplayName("์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•˜๋ฉด ์นด์šดํŠธ๊ฐ€ ๊ฐ์†Œํ•œ๋‹ค") + void should_unlike_post_and_decrease_count() { + // given + Post post = post(POST_ID, STUDY_ID, MEMBER_ID); + when(postRepository.getById(POST_ID)).thenReturn(post); + when(postLikeRepository.hardDelete(POST_ID, MEMBER_ID)).thenReturn(1); + + // when + likePostService.unlikePost(STUDY_ID, POST_ID, MEMBER_ID); + + // then + verify(accessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + verify(postLikeRepository).hardDelete(POST_ID, MEMBER_ID); + verify(postStatsRepository).decreaseLike(POST_ID); + } + + @Test + @DisplayName("์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ๊ฒŒ์‹œ๊ธ€์„ ์ทจ์†Œํ•ด๋„ ์นด์šดํŠธ๊ฐ€ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_decrease_count_when_not_liked() { + // given + Post post = post(POST_ID, STUDY_ID, MEMBER_ID); + when(postRepository.getById(POST_ID)).thenReturn(post); + when(postLikeRepository.hardDelete(POST_ID, MEMBER_ID)).thenReturn(0); + + // when + likePostService.unlikePost(STUDY_ID, POST_ID, MEMBER_ID); + + // then + verify(postLikeRepository).hardDelete(POST_ID, MEMBER_ID); + verify(postStatsRepository, never()).decreaseLike(anyLong()); + } + } +} From 475767233cfcbea05bfaecf282983f065e88f40c Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 00:12:50 +0900 Subject: [PATCH 032/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 5 + .../command/ManageTodoService.java | 50 ++++++++ .../application/query/GetTodoService.java | 49 ++++++++ .../main/java/kr/spot/todo/domain/Todo.java | 77 ++++++++++++ .../infrastructure/jpa/TodoRepository.java | 14 +++ .../jpa/querydsl/TodoQueryRepository.java | 34 ++++++ .../command/TodoCommandController.java | 112 ++++++++++++++++++ .../command/dto/CreateTodoRequest.java | 15 +++ .../command/dto/UpdateTodoRequest.java | 15 +++ .../query/TodoQueryController.java | 46 +++++++ .../query/dto/GetTodoListResponse.java | 40 +++++++ 11 files changed, 457 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java create mode 100644 modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java create mode 100644 modules/study/src/main/java/kr/spot/todo/domain/Todo.java create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index 49965980..8d8e4600 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -66,6 +66,11 @@ public enum ErrorStatus implements BaseErrorCode { // ์ผ์ • ๊ด€๋ จ _SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "์ผ์ •์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), _SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ์ผ์ •์ด ์•„๋‹™๋‹ˆ๋‹ค."), + + // ํˆฌ๋‘ ๊ด€๋ จ + _TODO_NOT_FOUND(404, "TODO404", "ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + _TODO_ACCESS_DENIED(403, "TODO403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ํˆฌ๋‘๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."), + _ONLY_TODO_OWNER_CAN_MODIFY(403, "TODO4030", "ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), ; private final int httpStatus; diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java new file mode 100644 index 00000000..e2aedb79 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java @@ -0,0 +1,50 @@ +package kr.spot.todo.application.command; + +import kr.spot.IdGenerator; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.TodoRepository; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageTodoService { + + private final IdGenerator idGenerator; + private final TodoRepository todoRepository; + + public void createTodo(long studyId, long memberId, CreateTodoRequest request) { + Todo todo = Todo.of( + idGenerator.nextId(), + studyId, + memberId, + request.dueDate(), + request.content() + ); + todoRepository.save(todo); + } + + public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) { + Todo todo = todoRepository.getById(todoId); + todo.update(request.content(), request.dueDate(), memberId); + } + + public void completeTodo(long studyId, long todoId, long memberId) { + Todo todo = todoRepository.getById(todoId); + todo.complete(memberId); + } + + public void uncompleteTodo(long studyId, long todoId, long memberId) { + Todo todo = todoRepository.getById(todoId); + todo.uncomplete(memberId); + } + + public void deleteTodo(long studyId, long todoId, long memberId) { + Todo todo = todoRepository.getById(todoId); + todo.delete(studyId, memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java new file mode 100644 index 00000000..c07c51b4 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java @@ -0,0 +1,49 @@ +package kr.spot.todo.application.query; + +import java.time.LocalDate; +import java.util.List; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetTodoService { + + private final TodoQueryRepository todoQueryRepository; + + public GetTodoListResponse getTodosByDate(long studyId, long memberId, LocalDate dueDate) { + List todos = todoQueryRepository.findByStudyIdAndMemberIdAndDueDate( + studyId, memberId, dueDate); + + return toResponse(todos); + } + + private GetTodoListResponse toResponse(List todos) { + List pending = todos.stream() + .filter(todo -> !Boolean.TRUE.equals(todo.getIsCompleted())) + .map(this::toTodoResponse) + .toList(); + + List completed = todos.stream() + .filter(todo -> Boolean.TRUE.equals(todo.getIsCompleted())) + .map(this::toTodoResponse) + .toList(); + + return GetTodoListResponse.from(pending, completed); + } + + private TodoResponse toTodoResponse(Todo todo) { + return TodoResponse.from( + todo.getId(), + todo.getContent(), + todo.getDueDate(), + todo.getIsCompleted() + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java new file mode 100644 index 00000000..a86ad8a3 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java @@ -0,0 +1,77 @@ +package kr.spot.todo.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDate; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.BaseEntity; +import kr.spot.exception.GeneralException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE todo SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Todo extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + private Long memberId; + + @Column(nullable = false) + private LocalDate dueDate; + + @Column(nullable = false) + private String content; + + private Boolean isCompleted; + + public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, String content) { + return new Todo(id, studyId, memberId, dueDate, content, false); + } + + public void update(String content, LocalDate dueDate, long memberId) { + validateOwner(memberId); + this.content = content; + this.dueDate = dueDate; + } + + public void complete(long memberId) { + validateOwner(memberId); + this.isCompleted = true; + } + + public void uncomplete(long memberId) { + validateOwner(memberId); + this.isCompleted = false; + } + + public void delete(long studyId, long memberId) { + validateAccess(studyId); + validateOwner(memberId); + super.delete(); + } + + private void validateAccess(long studyId) { + if (this.studyId != studyId) { + throw new GeneralException(ErrorStatus._TODO_ACCESS_DENIED); + } + } + + private void validateOwner(long memberId) { + if (this.memberId != memberId) { + throw new GeneralException(ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java new file mode 100644 index 00000000..623fe3c7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java @@ -0,0 +1,14 @@ +package kr.spot.todo.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.todo.domain.Todo; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TodoRepository extends JpaRepository { + + default Todo getById(long todoId) { + return findById(todoId).orElseThrow( + () -> new GeneralException(ErrorStatus._TODO_NOT_FOUND)); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java new file mode 100644 index 00000000..64d21f3a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java @@ -0,0 +1,34 @@ +package kr.spot.todo.infrastructure.jpa.querydsl; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.util.List; +import kr.spot.todo.domain.QTodo; +import kr.spot.todo.domain.Todo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TodoQueryRepository { + + private final JPAQueryFactory query; + + public List findByStudyIdAndMemberIdAndDueDate(Long studyId, Long memberId, + LocalDate dueDate) { + QTodo todo = QTodo.todo; + + return query + .selectFrom(todo) + .where( + todo.studyId.eq(studyId), + todo.memberId.eq(memberId), + todo.dueDate.eq(dueDate) + ) + .orderBy( + todo.isCompleted.asc(), + todo.createdAt.desc() + ) + .fetch(); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java new file mode 100644 index 00000000..4f7ab5a7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java @@ -0,0 +1,112 @@ +package kr.spot.todo.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.todo.application.command.ManageTodoService; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ํˆฌ๋‘") +@RestController +@RequestMapping("/api/studies/{studyId}/todos") +@RequiredArgsConstructor +public class TodoCommandController { + + private final ManageTodoService manageTodoService; + + @Operation(summary = "ํˆฌ๋‘ ์ƒ์„ฑ", description = "์Šคํ„ฐ๋””์— ์ƒˆ๋กœ์šด ํˆฌ๋‘๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์ƒ์„ฑ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`STUDY404`: ์Šคํ„ฐ๋””๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping + public ResponseEntity> createTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestBody CreateTodoRequest request) { + manageTodoService.createTodo(studyId, memberId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @Operation(summary = "ํˆฌ๋‘ ์ˆ˜์ •", description = "ํˆฌ๋‘ ๋‚ด์šฉ๊ณผ ๋งˆ๊ฐ์ผ์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์ˆ˜์ • ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO4030`: ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PatchMapping("/{todoId}") + public ResponseEntity> updateTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestBody UpdateTodoRequest request) { + manageTodoService.updateTodo(studyId, todoId, memberId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "ํˆฌ๋‘ ์™„๋ฃŒ", description = "ํˆฌ๋‘๋ฅผ ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์™„๋ฃŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO4030`: ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{todoId}/complete") + public ResponseEntity> completeTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + manageTodoService.completeTodo(studyId, todoId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ", description = "ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO4030`: ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{todoId}/uncomplete") + public ResponseEntity> uncompleteTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + manageTodoService.uncompleteTodo(studyId, todoId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "ํˆฌ๋‘ ์‚ญ์ œ", description = "ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์‚ญ์ œ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO403`: ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ํˆฌ๋‘๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/{todoId}") + public ResponseEntity> deleteTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + manageTodoService.deleteTodo(studyId, todoId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java new file mode 100644 index 00000000..72e8f4f6 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java @@ -0,0 +1,15 @@ +package kr.spot.todo.presentation.command.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +@Schema(description = "ํˆฌ๋‘ ์ƒ์„ฑ ์š”์ฒญ") +public record CreateTodoRequest( + @Schema(description = "ํˆฌ๋‘ ๋‚ด์šฉ", example = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ") + String content, + + @Schema(description = "๋งˆ๊ฐ์ผ", example = "2025-01-15") + LocalDate dueDate +) { + +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java new file mode 100644 index 00000000..33e21561 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java @@ -0,0 +1,15 @@ +package kr.spot.todo.presentation.command.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +@Schema(description = "ํˆฌ๋‘ ์ˆ˜์ • ์š”์ฒญ") +public record UpdateTodoRequest( + @Schema(description = "ํˆฌ๋‘ ๋‚ด์šฉ", example = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ") + String content, + + @Schema(description = "๋งˆ๊ฐ์ผ", example = "2025-01-15") + LocalDate dueDate +) { + +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java new file mode 100644 index 00000000..8d977128 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java @@ -0,0 +1,46 @@ +package kr.spot.todo.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import kr.spot.ApiResponse; +import kr.spot.code.status.SuccessStatus; +import kr.spot.todo.application.query.GetTodoService; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ํˆฌ๋‘") +@RestController +@RequestMapping("/api/studies/{studyId}/todos") +@RequiredArgsConstructor +public class TodoQueryController { + + private final GetTodoService getTodoService; + + @Operation(summary = "๋‚ ์งœ๋ณ„ ํˆฌ๋‘ ์กฐํšŒ", description = "ํŠน์ • ๋‚ ์งœ์˜ ํˆฌ๋‘๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ์ด ๋จผ์ €, ์™„๋ฃŒ ํ•ญ๋ชฉ์€ ๋ณ„๋„๋กœ ์กฐํšŒ๋ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/members/{memberId}") + public ResponseEntity> getTodosByDate( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @PathVariable @Parameter(hidden = true) Long memberId, + @Parameter(description = "์กฐํšŒํ•  ๋‚ ์งœ (ISO 8601 ํ‘œ์ค€)", example = "2025-01-15") + @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) { + GetTodoListResponse response = getTodoService.getTodosByDate(studyId, memberId, date); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java new file mode 100644 index 00000000..e937d0c0 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java @@ -0,0 +1,40 @@ +package kr.spot.todo.presentation.query.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "ํˆฌ๋‘ ๋ชฉ๋ก ์‘๋‹ต") +public record GetTodoListResponse( + @Schema(description = "๋ฏธ์™„๋ฃŒ ํˆฌ๋‘ ๋ชฉ๋ก") + List pending, + + @Schema(description = "์™„๋ฃŒ๋œ ํˆฌ๋‘ ๋ชฉ๋ก") + List completed +) { + + public static GetTodoListResponse from(List pending, List completed) { + return new GetTodoListResponse(pending, completed); + } + + @Schema(description = "ํˆฌ๋‘ ์ƒ์„ธ ์ •๋ณด") + public record TodoResponse( + @Schema(description = "ํˆฌ๋‘ ID", example = "1234567890") + Long id, + + @Schema(description = "ํˆฌ๋‘ ๋‚ด์šฉ", example = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ") + String content, + + @Schema(description = "๋งˆ๊ฐ์ผ", example = "2025-01-15") + LocalDate dueDate, + + @Schema(description = "์™„๋ฃŒ ์—ฌ๋ถ€", example = "false") + Boolean isCompleted + ) { + + public static TodoResponse from(Long id, String content, LocalDate dueDate, + Boolean isCompleted) { + return new TodoResponse(id, content, dueDate, isCompleted); + } + } +} From e11d3262192dee4a227375b37c03ea11bb832e9e Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 13:15:27 +0900 Subject: [PATCH 033/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageTodoService.java | 6 +- .../main/java/kr/spot/todo/domain/Todo.java | 9 +- .../command/ManageTodoServiceTest.java | 334 ++++++++++++++++++ .../application/query/GetTodoServiceTest.java | 139 ++++++++ .../java/kr/spot/todo/common/TodoFixture.java | 49 +++ .../java/kr/spot/todo/domain/TodoTest.java | 218 ++++++++++++ 6 files changed, 749 insertions(+), 6 deletions(-) create mode 100644 modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java create mode 100644 modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java index e2aedb79..a8631b31 100644 --- a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java +++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java @@ -30,17 +30,17 @@ public void createTodo(long studyId, long memberId, CreateTodoRequest request) { public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) { Todo todo = todoRepository.getById(todoId); - todo.update(request.content(), request.dueDate(), memberId); + todo.update(studyId, request.content(), request.dueDate(), memberId); } public void completeTodo(long studyId, long todoId, long memberId) { Todo todo = todoRepository.getById(todoId); - todo.complete(memberId); + todo.complete(studyId, memberId); } public void uncompleteTodo(long studyId, long todoId, long memberId) { Todo todo = todoRepository.getById(todoId); - todo.uncomplete(memberId); + todo.uncomplete(studyId, memberId); } public void deleteTodo(long studyId, long todoId, long memberId) { diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java index a86ad8a3..2058d06e 100644 --- a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java +++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java @@ -41,18 +41,21 @@ public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, S return new Todo(id, studyId, memberId, dueDate, content, false); } - public void update(String content, LocalDate dueDate, long memberId) { + public void update(long studyId, String content, LocalDate dueDate, long memberId) { + validateAccess(studyId); validateOwner(memberId); this.content = content; this.dueDate = dueDate; } - public void complete(long memberId) { + public void complete(long studyId, long memberId) { + validateAccess(studyId); validateOwner(memberId); this.isCompleted = true; } - public void uncomplete(long memberId) { + public void uncomplete(long studyId, long memberId) { + validateAccess(studyId); validateOwner(memberId); this.isCompleted = false; } diff --git a/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java new file mode 100644 index 00000000..5f52049c --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java @@ -0,0 +1,334 @@ +package kr.spot.todo.application.command; + +import static kr.spot.todo.common.TodoFixture.CONTENT; +import static kr.spot.todo.common.TodoFixture.DUE_DATE; +import static kr.spot.todo.common.TodoFixture.MEMBER_ID; +import static kr.spot.todo.common.TodoFixture.STUDY_ID; +import static kr.spot.todo.common.TodoFixture.createTodoRequest; +import static kr.spot.todo.common.TodoFixture.todo; +import static kr.spot.todo.common.TodoFixture.updateTodoRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.TodoRepository; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManageTodoServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + TodoRepository todoRepository; + + @Captor + ArgumentCaptor todoCaptor; + + ManageTodoService manageTodoService; + + @BeforeEach + void setUp() { + manageTodoService = new ManageTodoService(idGenerator, todoRepository); + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์ƒ์„ฑ (createTodo)") + class CreateTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_todo_successfully() { + // given + Long generatedId = 1L; + CreateTodoRequest request = createTodoRequest(); + + when(idGenerator.nextId()).thenReturn(generatedId); + when(todoRepository.save(any(Todo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageTodoService.createTodo(STUDY_ID, MEMBER_ID, request); + + // then + verify(todoRepository).save(todoCaptor.capture()); + + Todo capturedTodo = todoCaptor.getValue(); + assertThat(capturedTodo.getId()).isEqualTo(generatedId); + assertThat(capturedTodo.getStudyId()).isEqualTo(STUDY_ID); + assertThat(capturedTodo.getMemberId()).isEqualTo(MEMBER_ID); + assertThat(capturedTodo.getContent()).isEqualTo(CONTENT); + assertThat(capturedTodo.getDueDate()).isEqualTo(DUE_DATE); + assertThat(capturedTodo.getIsCompleted()).isFalse(); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์ˆ˜์ • (updateTodo)") + class UpdateTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request); + + // then + verify(todoRepository).getById(todoId); + assertThat(todo.getContent()).isEqualTo(request.content()); + assertThat(todo.getDueDate()).isEqualTo(request.dueDate()); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.updateTodo(otherStudyId, todoId, MEMBER_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, otherMemberId, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํˆฌ๋‘๋ฅผ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_todo_not_found() { + // given + long todoId = 999L; + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์™„๋ฃŒ (completeTodo)") + class CompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์™„๋ฃŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_complete_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.completeTodo(STUDY_ID, todoId, MEMBER_ID); + + // then + verify(todoRepository).getById(todoId); + assertThat(todo.getIsCompleted()).isTrue(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.completeTodo(otherStudyId, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.completeTodo(STUDY_ID, todoId, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ (uncompleteTodo)") + class UncompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_uncomplete_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + todo.complete(STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.uncompleteTodo(STUDY_ID, todoId, MEMBER_ID); + + // then + verify(todoRepository).getById(todoId); + assertThat(todo.getIsCompleted()).isFalse(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.uncompleteTodo(otherStudyId, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.uncompleteTodo(STUDY_ID, todoId, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์‚ญ์ œ (deleteTodo)") + class DeleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID); + + // then + verify(todoRepository).getById(todoId); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.deleteTodo(otherStudyId, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_todo_not_found() { + // given + long todoId = 999L; + + when(todoRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java new file mode 100644 index 00000000..648e9043 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java @@ -0,0 +1,139 @@ +package kr.spot.todo.application.query; + +import static kr.spot.todo.common.TodoFixture.DUE_DATE; +import static kr.spot.todo.common.TodoFixture.MEMBER_ID; +import static kr.spot.todo.common.TodoFixture.STUDY_ID; +import static kr.spot.todo.common.TodoFixture.completedTodo; +import static kr.spot.todo.common.TodoFixture.todo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetTodoServiceTest { + + @Mock + TodoQueryRepository todoQueryRepository; + + GetTodoService getTodoService; + + @BeforeEach + void setUp() { + getTodoService = new GetTodoService(todoQueryRepository); + } + + @Nested + @DisplayName("๋‚ ์งœ๋ณ„ ํˆฌ๋‘ ์กฐํšŒ (getTodosByDate)") + class GetTodosByDate { + + @Test + @DisplayName("ํŠน์ • ๋‚ ์งœ์˜ ํˆฌ๋‘ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_get_todos_by_date_successfully() { + // given + Todo pendingTodo = todo(1L); + Todo completedTodo = completedTodo(); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(pendingTodo, completedTodo)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + verify(todoQueryRepository).findByStudyIdAndMemberIdAndDueDate(STUDY_ID, MEMBER_ID, DUE_DATE); + assertThat(response.pending()).hasSize(1); + assertThat(response.completed()).hasSize(1); + } + + @Test + @DisplayName("๋ฏธ์™„๋ฃŒ ํˆฌ๋‘๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ pending ๋ชฉ๋ก๋งŒ ๋ฐ˜ํ™˜๋œ๋‹ค") + void should_return_only_pending_todos_when_no_completed() { + // given + Todo pendingTodo1 = todo(1L); + Todo pendingTodo2 = todo(2L); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(pendingTodo1, pendingTodo2)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).hasSize(2); + assertThat(response.completed()).isEmpty(); + } + + @Test + @DisplayName("์™„๋ฃŒ๋œ ํˆฌ๋‘๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ completed ๋ชฉ๋ก๋งŒ ๋ฐ˜ํ™˜๋œ๋‹ค") + void should_return_only_completed_todos_when_no_pending() { + // given + Todo completedTodo1 = todo(1L); + completedTodo1.complete(STUDY_ID, MEMBER_ID); + Todo completedTodo2 = todo(2L); + completedTodo2.complete(STUDY_ID, MEMBER_ID); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(completedTodo1, completedTodo2)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).isEmpty(); + assertThat(response.completed()).hasSize(2); + } + + @Test + @DisplayName("ํˆฌ๋‘๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ๋นˆ ๋ชฉ๋ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค") + void should_return_empty_lists_when_no_todos() { + // given + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(Collections.emptyList()); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).isEmpty(); + assertThat(response.completed()).isEmpty(); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์‘๋‹ต์— ์˜ฌ๋ฐ”๋ฅธ ์ •๋ณด๊ฐ€ ํฌํ•จ๋œ๋‹ค") + void should_return_correct_todo_information() { + // given + Todo todo = todo(1L); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(todo)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).hasSize(1); + TodoResponse todoResponse = response.pending().get(0); + assertThat(todoResponse.id()).isEqualTo(todo.getId()); + assertThat(todoResponse.content()).isEqualTo(todo.getContent()); + assertThat(todoResponse.dueDate()).isEqualTo(todo.getDueDate()); + assertThat(todoResponse.isCompleted()).isEqualTo(todo.getIsCompleted()); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java new file mode 100644 index 00000000..9580143c --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java @@ -0,0 +1,49 @@ +package kr.spot.todo.common; + +import java.time.LocalDate; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; + +public class TodoFixture { + + public static final Long ID = 1L; + public static final Long STUDY_ID = 100L; + public static final Long MEMBER_ID = 200L; + public static final LocalDate DUE_DATE = LocalDate.of(2025, 1, 15); + public static final String CONTENT = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ"; + + public static Todo todo() { + return Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT); + } + + public static Todo todo(Long id) { + return Todo.of(id, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT); + } + + public static Todo todo(Long id, Long studyId, Long memberId) { + return Todo.of(id, studyId, memberId, DUE_DATE, CONTENT); + } + + public static Todo completedTodo() { + Todo todo = todo(); + todo.complete(STUDY_ID, MEMBER_ID); + return todo; + } + + public static CreateTodoRequest createTodoRequest() { + return new CreateTodoRequest(CONTENT, DUE_DATE); + } + + public static CreateTodoRequest createTodoRequest(String content, LocalDate dueDate) { + return new CreateTodoRequest(content, dueDate); + } + + public static UpdateTodoRequest updateTodoRequest() { + return new UpdateTodoRequest("์ˆ˜์ •๋œ ๋‚ด์šฉ", LocalDate.of(2025, 1, 20)); + } + + public static UpdateTodoRequest updateTodoRequest(String content, LocalDate dueDate) { + return new UpdateTodoRequest(content, dueDate); + } +} diff --git a/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java new file mode 100644 index 00000000..c352b3c4 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java @@ -0,0 +1,218 @@ +package kr.spot.todo.domain; + +import static kr.spot.todo.common.TodoFixture.CONTENT; +import static kr.spot.todo.common.TodoFixture.DUE_DATE; +import static kr.spot.todo.common.TodoFixture.ID; +import static kr.spot.todo.common.TodoFixture.MEMBER_ID; +import static kr.spot.todo.common.TodoFixture.STUDY_ID; +import static kr.spot.todo.common.TodoFixture.todo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class TodoTest { + + @Nested + @DisplayName("ํˆฌ๋‘ ์ƒ์„ฑ (of)") + class CreateTodo { + + @Test + @DisplayName("ํˆฌ๋‘ ๊ฐ์ฒด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_todo_successfully() { + // when + Todo todo = Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT); + + // then + assertThat(todo).isNotNull(); + assertThat(todo.getId()).isEqualTo(ID); + assertThat(todo.getStudyId()).isEqualTo(STUDY_ID); + assertThat(todo.getMemberId()).isEqualTo(MEMBER_ID); + assertThat(todo.getDueDate()).isEqualTo(DUE_DATE); + assertThat(todo.getContent()).isEqualTo(CONTENT); + assertThat(todo.getIsCompleted()).isFalse(); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์ˆ˜์ • (update)") + class UpdateTodo { + + @Test + @DisplayName("ํˆฌ๋‘ ๋‚ด์šฉ๊ณผ ๋งˆ๊ฐ์ผ์„ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_todo_successfully() { + // given + Todo todo = todo(); + String newContent = "์ˆ˜์ •๋œ ๋‚ด์šฉ"; + LocalDate newDueDate = LocalDate.of(2025, 1, 20); + + // when + todo.update(STUDY_ID, newContent, newDueDate, MEMBER_ID); + + // then + assertThat(todo.getContent()).isEqualTo(newContent); + assertThat(todo.getDueDate()).isEqualTo(newDueDate); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํˆฌ๋‘๋ฅผ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.update(otherStudyId, "๋‚ด์šฉ", DUE_DATE, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.update(STUDY_ID, "๋‚ด์šฉ", DUE_DATE, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์™„๋ฃŒ (complete)") + class CompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_complete_todo_successfully() { + // given + Todo todo = todo(); + + // when + todo.complete(STUDY_ID, MEMBER_ID); + + // then + assertThat(todo.getIsCompleted()).isTrue(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํˆฌ๋‘๋ฅผ ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.complete(otherStudyId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.complete(STUDY_ID, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ (uncomplete)") + class UncompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_uncomplete_todo_successfully() { + // given + Todo todo = todo(); + todo.complete(STUDY_ID, MEMBER_ID); + + // when + todo.uncomplete(STUDY_ID, MEMBER_ID); + + // then + assertThat(todo.getIsCompleted()).isFalse(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.uncomplete(otherStudyId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.uncomplete(STUDY_ID, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์‚ญ์ œ (delete)") + class DeleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_todo_successfully() { + // given + Todo todo = todo(); + + // when & then (์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์œผ๋ฉด ์„ฑ๊ณต) + todo.delete(STUDY_ID, MEMBER_ID); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.delete(otherStudyId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.delete(STUDY_ID, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } +} From 1402fb5247e43c283745f504d227411e0afb76b4 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 13:23:30 +0900 Subject: [PATCH 034/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC=EC=A1=B0=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/review/domain/Review.java | 36 +++++++++++++++++ .../domain/associations/ReviewReaction.java | 34 ++++++++++++++++ .../kr/spot/review/domain/enums/Reaction.java | 8 ++++ .../kr/spot/review/domain/vo/Content.java | 32 +++++++++++++++ .../kr/spot/review/domain/vo/WriterInfo.java | 40 +++++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/review/domain/Review.java create mode 100644 modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java create mode 100644 modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java create mode 100644 modules/study/src/main/java/kr/spot/review/domain/vo/Content.java create mode 100644 modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java diff --git a/modules/study/src/main/java/kr/spot/review/domain/Review.java b/modules/study/src/main/java/kr/spot/review/domain/Review.java new file mode 100644 index 00000000..1a0c755c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java @@ -0,0 +1,36 @@ +package kr.spot.review.domain; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import kr.spot.review.domain.vo.Content; +import kr.spot.review.domain.vo.WriterInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE review SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Review extends BaseEntity { + + @Id + private Long id; + + @Embedded + private WriterInfo writerInfo; + + @Embedded + private Content content; + + public static Review of(Long id, WriterInfo writerInfo, Content content) { + return new Review(id, writerInfo, content); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java new file mode 100644 index 00000000..7063b6ff --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java @@ -0,0 +1,34 @@ +package kr.spot.review.domain.associations; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import kr.spot.review.domain.enums.Reaction; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE review_reaction SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ReviewReaction extends BaseEntity { + + @Id + private Long id; + + private Long reviewId; + + private Long memberId; + + private Reaction reaction; + + public static ReviewReaction of(Long id, Long reviewId, Long memberId, Reaction reaction) { + return new ReviewReaction(id, reviewId, memberId, reaction); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java b/modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java new file mode 100644 index 00000000..a8539692 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java @@ -0,0 +1,8 @@ +package kr.spot.review.domain.enums; + +public enum Reaction { + FIRE, + HEART, + STAR, + SMILE +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java b/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java new file mode 100644 index 00000000..8219e968 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java @@ -0,0 +1,32 @@ +package kr.spot.review.domain.vo; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Content { + + // ์˜ค๋Š˜์€ ๋ฌด์—‡์„ ํ–ˆ๋‚˜์š”? + private String activity; + + // ์˜ค๋Š˜ ์ƒˆ๋กญ๊ฒŒ ๋ฐฐ์šด ์ ์€ ๋ฌด์—‡์ธ๊ฐ€์š”? + private String learned; + + // ๊ณ ์ƒํ•œ ๋‚˜์—๊ฒŒ ํ•œ๋งˆ๋”” + private String encouragement; + + private String imageUrl; + + public static Content of(String activity, String learned, + String encouragement, String imageUrl) { + return new Content(activity, learned, encouragement, imageUrl); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java b/modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java new file mode 100644 index 00000000..02b0c712 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java @@ -0,0 +1,40 @@ +package kr.spot.review.domain.vo; + +import jakarta.persistence.Embeddable; +import java.util.Objects; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class WriterInfo { + + private Long writerId; + + private String writerName; + + private String WriterProfileImageUrl; + + public static WriterInfo of(Long writerId, String writerName, + String writerProfileImageUrl) { + return new WriterInfo(writerId, writerName, writerProfileImageUrl); + } + + public void validateIsOwnMember(Long currentUserId) { + if (!Objects.equals(writerId, currentUserId)) { + throw new GeneralException(ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + } + + public boolean isSameWriter(Long memberId) { + return Objects.equals(this.writerId, memberId); + } +} From e2b23e9ab4306ad871bf29c3f215691a1c9fedda Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 13:55:47 +0900 Subject: [PATCH 035/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20API=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 11 ++ .../command/ManageReviewReactionService.java | 52 ++++++++ .../command/ManageReviewService.java | 67 ++++++++++ .../application/query/GetReviewService.java | 108 ++++++++++++++++ .../java/kr/spot/review/domain/Review.java | 13 +- .../jpa/ReviewReactionRepository.java | 29 +++++ .../infrastructure/jpa/ReviewRepository.java | 14 ++ .../jpa/querydsl/ReviewQueryRepository.java | 121 ++++++++++++++++++ .../command/ReviewCommandController.java | 74 +++++++++++ .../command/dto/CreateReviewRequest.java | 13 ++ .../query/ReviewQueryController.java | 38 ++++++ .../query/dto/GetReviewListResponse.java | 82 ++++++++++++ 12 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java create mode 100644 modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java create mode 100644 modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java create mode 100644 modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java create mode 100644 modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java create mode 100644 modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index 49965980..e7a8a4a3 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -66,6 +66,17 @@ public enum ErrorStatus implements BaseErrorCode { // ์ผ์ • ๊ด€๋ จ _SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "์ผ์ •์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), _SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ์ผ์ •์ด ์•„๋‹™๋‹ˆ๋‹ค."), + + // ํˆฌ๋‘ ๊ด€๋ จ + _TODO_NOT_FOUND(404, "TODO404", "ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + _TODO_ACCESS_DENIED(403, "TODO403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ํˆฌ๋‘๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."), + _ONLY_TODO_OWNER_CAN_MODIFY(403, "TODO4030", "ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."), + + // ํšŒ๊ณ  ๊ด€๋ จ + _REVIEW_NOT_FOUND(404, "REVIEW404", "ํšŒ๊ณ ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + _REVIEW_ACCESS_DENIED(403, "REVIEW403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ํšŒ๊ณ ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."), + _ALREADY_REACTED(400, "REVIEW4000", "์ด๋ฏธ ๋ฐ˜์‘์„ ๋ˆ„๋ฅธ ํšŒ๊ณ ์ž…๋‹ˆ๋‹ค."), + _REACTION_NOT_FOUND(404, "REVIEW4040", "๋ฐ˜์‘์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), ; private final int httpStatus; diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java new file mode 100644 index 00000000..5ba2a402 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java @@ -0,0 +1,52 @@ +package kr.spot.review.application.command; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.review.domain.associations.ReviewReaction; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageReviewReactionService { + + private final IdGenerator idGenerator; + private final ReviewRepository reviewRepository; + private final ReviewReactionRepository reviewReactionRepository; + private final StudyAccessValidator studyAccessValidator; + + public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) { + studyAccessValidator.validateStudyMember(studyId, memberId); + validateReviewExists(reviewId); + + if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId, + reaction)) { + throw new GeneralException(ErrorStatus._ALREADY_REACTED); + } + + ReviewReaction reviewReaction = ReviewReaction.of( + idGenerator.nextId(), reviewId, memberId, reaction); + reviewReactionRepository.save(reviewReaction); + } + + public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) { + studyAccessValidator.validateStudyMember(studyId, memberId); + validateReviewExists(reviewId); + + int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); + if (deleted == 0) { + throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND); + } + } + + private void validateReviewExists(long reviewId) { + reviewRepository.getById(reviewId); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java new file mode 100644 index 00000000..e8d83492 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java @@ -0,0 +1,67 @@ +package kr.spot.review.application.command; + +import kr.spot.IdGenerator; +import kr.spot.ports.FileStoragePort; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.UploadResult; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.vo.Content; +import kr.spot.review.domain.vo.WriterInfo; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import kr.spot.review.presentation.command.dto.CreateReviewRequest; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageReviewService { + + private static final String REVIEW_IMAGE_DIR = "reviews"; + + private final IdGenerator idGenerator; + private final GetWriterInfoPort getWriterInfoPort; + private final FileStoragePort fileStoragePort; + private final ReviewRepository reviewRepository; + private final StudyAccessValidator studyAccessValidator; + + public void createReview(long studyId, long memberId, CreateReviewRequest request, + MultipartFile imageFile) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + WriterInfo writerInfo = getWriterInfo(memberId); + String imageUrl = uploadImage(imageFile); + Content content = Content.of(request.activity(), request.learned(), request.encouragement(), + imageUrl); + + Review review = Review.of(idGenerator.nextId(), studyId, writerInfo, content, + request.isPrivateReview()); + reviewRepository.save(review); + } + + public void deleteReview(long studyId, long reviewId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + Review review = reviewRepository.getById(reviewId); + review.getWriterInfo().validateIsOwnMember(memberId); + + reviewRepository.delete(review); + } + + private WriterInfo getWriterInfo(long memberId) { + WriterInfoResponse response = getWriterInfoPort.get(memberId); + return WriterInfo.of(response.writerId(), response.nickname(), response.profileImageUrl()); + } + + private String uploadImage(MultipartFile imageFile) { + if (imageFile == null || imageFile.isEmpty()) { + return null; + } + UploadResult result = fileStoragePort.upload(imageFile, REVIEW_IMAGE_DIR); + return result.url(); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java new file mode 100644 index 00000000..5a00b99e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java @@ -0,0 +1,108 @@ +package kr.spot.review.application.query; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.domain.vo.Content; +import kr.spot.review.domain.vo.WriterInfo; +import kr.spot.review.infrastructure.jpa.querydsl.ReviewQueryRepository; +import kr.spot.review.infrastructure.jpa.querydsl.ReviewQueryRepository.ReactionCounts; +import kr.spot.review.presentation.query.dto.GetReviewListResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ContentResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ReactionCountResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ReactionResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ReviewResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.WriterInfoResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetReviewService { + + private static final String PRIVATE_CONTENT_MESSAGE = "์ด ๊ธ€์€ ์Šคํ„ฐ๋””์›์—๊ฒŒ๋งŒ ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค."; + + private final ReviewQueryRepository reviewQueryRepository; + private final StudyAccessValidator studyAccessValidator; + + public GetReviewListResponse getReviewList(long studyId, Long viewerId, Long cursor, int size) { + boolean isStudyMember = + viewerId != null && studyAccessValidator.isStudyMember(studyId, viewerId); + List reviews = reviewQueryRepository.findByStudyIdWithCursor(studyId, cursor, + size + 1); + + boolean hasNext = reviews.size() > size; + if (hasNext) { + reviews = reviews.subList(0, size); + } + + List reviewIds = reviews.stream().map(Review::getId).toList(); + Map reactionCountsMap = reviewQueryRepository.findReactionCountsByReviewIds( + reviewIds); + Map> memberReactionsMap = reviewQueryRepository.findMemberReactionsByReviewIds( + viewerId, reviewIds); + + long totalElements = reviewQueryRepository.countByStudyId(studyId); + Long nextCursor = hasNext && !reviews.isEmpty() ? reviews.getLast().getId() + : null; + + List reviewResponses = reviews.stream() + .map(review -> toReviewResponse(review, reactionCountsMap, memberReactionsMap, + isStudyMember)) + .toList(); + + return new GetReviewListResponse(reviewResponses, hasNext, nextCursor, totalElements); + } + + private ReviewResponse toReviewResponse(Review review, + Map reactionCountsMap, + Map> memberReactionsMap, + boolean isStudyMember) { + + WriterInfo writerInfo = review.getWriterInfo(); + Content content = review.getContent(); + ReactionCounts counts = reactionCountsMap.getOrDefault(review.getId(), + new ReactionCounts(0, 0, 0, 0)); + Set memberReactions = memberReactionsMap.getOrDefault(review.getId(), Set.of()); + + ContentResponse contentResponse = createContentResponse(review, content, isStudyMember); + + return ReviewResponse.from( + review.getId(), + WriterInfoResponse.from(writerInfo.getWriterId(), writerInfo.getWriterName(), + writerInfo.getWriterProfileImageUrl()), + contentResponse, + ReactionCountResponse.from(counts.fire(), counts.heart(), counts.star(), counts.smile()), + ReactionResponse.from( + memberReactions.contains(Reaction.FIRE), + memberReactions.contains(Reaction.HEART), + memberReactions.contains(Reaction.STAR), + memberReactions.contains(Reaction.SMILE) + ), + review.isPrivate() + ); + } + + private ContentResponse createContentResponse(Review review, Content content, + boolean isStudyMember) { + if (review.isPrivate() && !isStudyMember) { + return ContentResponse.from( + PRIVATE_CONTENT_MESSAGE, + PRIVATE_CONTENT_MESSAGE, + PRIVATE_CONTENT_MESSAGE, + null + ); + } + return ContentResponse.from( + content.getActivity(), + content.getLearned(), + content.getEncouragement(), + content.getImageUrl() + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/Review.java b/modules/study/src/main/java/kr/spot/review/domain/Review.java index 1a0c755c..36d93735 100644 --- a/modules/study/src/main/java/kr/spot/review/domain/Review.java +++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java @@ -24,13 +24,22 @@ public class Review extends BaseEntity { @Id private Long id; + private Long studyId; + @Embedded private WriterInfo writerInfo; @Embedded private Content content; - public static Review of(Long id, WriterInfo writerInfo, Content content) { - return new Review(id, writerInfo, content); + private Boolean isPrivate; + + public static Review of(Long id, Long studyId, WriterInfo writerInfo, Content content, + Boolean isPrivate) { + return new Review(id, studyId, writerInfo, content, isPrivate); + } + + public boolean isPrivate() { + return Boolean.TRUE.equals(isPrivate); } } diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java new file mode 100644 index 00000000..c791345a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java @@ -0,0 +1,29 @@ +package kr.spot.review.infrastructure.jpa; + +import java.util.Optional; +import kr.spot.review.domain.associations.ReviewReaction; +import kr.spot.review.domain.enums.Reaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReviewReactionRepository extends JpaRepository { + + Optional findByReviewIdAndMemberIdAndReaction( + Long reviewId, Long memberId, Reaction reaction); + + boolean existsByReviewIdAndMemberIdAndReaction( + Long reviewId, Long memberId, Reaction reaction); + + @Modifying + @Query(value = """ + DELETE FROM review_reaction + WHERE review_id = :reviewId + AND member_id = :memberId + AND reaction = :reaction + """, nativeQuery = true) + int hardDelete(@Param("reviewId") Long reviewId, + @Param("memberId") Long memberId, + @Param("reaction") String reaction); +} diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java new file mode 100644 index 00000000..191788db --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java @@ -0,0 +1,14 @@ +package kr.spot.review.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.review.domain.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { + + default Review getById(long reviewId) { + return findById(reviewId).orElseThrow( + () -> new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java new file mode 100644 index 00000000..aea9bfcf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java @@ -0,0 +1,121 @@ +package kr.spot.review.infrastructure.jpa.querydsl; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import kr.spot.review.domain.QReview; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.associations.QReviewReaction; +import kr.spot.review.domain.enums.Reaction; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ReviewQueryRepository { + + private final JPAQueryFactory query; + + private final QReview review = QReview.review; + private final QReviewReaction reviewReaction = QReviewReaction.reviewReaction; + + public List findByStudyIdWithCursor(long studyId, Long cursor, int limit) { + return query + .selectFrom(review) + .where( + review.studyId.eq(studyId), + ltCursor(cursor) + ) + .orderBy(review.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression ltCursor(Long cursor) { + return (cursor == null) ? null : review.id.lt(cursor); + } + + public long countByStudyId(long studyId) { + Long count = query + .select(review.count()) + .from(review) + .where(review.studyId.eq(studyId)) + .fetchOne(); + return count != null ? count : 0L; + } + + public Map findReactionCountsByReviewIds(Collection reviewIds) { + if (reviewIds.isEmpty()) { + return Map.of(); + } + + List rows = query + .select(Projections.constructor(ReactionCountRow.class, + reviewReaction.reviewId, + reviewReaction.reaction, + reviewReaction.count())) + .from(reviewReaction) + .where(reviewReaction.reviewId.in(reviewIds)) + .groupBy(reviewReaction.reviewId, reviewReaction.reaction) + .fetch(); + + return rows.stream() + .collect(Collectors.groupingBy( + ReactionCountRow::reviewId, + Collectors.collectingAndThen( + Collectors.toList(), + this::toReactionCounts + ) + )); + } + + private ReactionCounts toReactionCounts(List rows) { + long fire = 0, heart = 0, star = 0, smile = 0; + for (ReactionCountRow row : rows) { + switch (row.reaction()) { + case FIRE -> fire = row.count(); + case HEART -> heart = row.count(); + case STAR -> star = row.count(); + case SMILE -> smile = row.count(); + } + } + return new ReactionCounts(fire, heart, star, smile); + } + + public Map> findMemberReactionsByReviewIds( + Long memberId, Collection reviewIds) { + if (memberId == null || reviewIds.isEmpty()) { + return Map.of(); + } + + return query + .select(reviewReaction.reviewId, reviewReaction.reaction) + .from(reviewReaction) + .where( + reviewReaction.memberId.eq(memberId), + reviewReaction.reviewId.in(reviewIds) + ) + .fetch() + .stream() + .collect(Collectors.groupingBy( + tuple -> tuple.get(reviewReaction.reviewId), + Collectors.mapping( + tuple -> tuple.get(reviewReaction.reaction), + Collectors.toSet() + ) + )); + } + + public record ReactionCountRow(Long reviewId, Reaction reaction, Long count) { + + } + + public record ReactionCounts(long fire, long heart, long star, long smile) { + + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java new file mode 100644 index 00000000..2ddbbd33 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java @@ -0,0 +1,74 @@ +package kr.spot.review.presentation.command; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.review.application.command.ManageReviewReactionService; +import kr.spot.review.application.command.ManageReviewService; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.presentation.command.dto.CreateReviewRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก") +@RestController +@RequestMapping("/api/studies/{studyId}/reviews") +@RequiredArgsConstructor +public class ReviewCommandController { + + private final ManageReviewService manageReviewService; + private final ManageReviewReactionService manageReviewReactionService; + + @PostMapping + public ResponseEntity> createReview( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestPart CreateReviewRequest request, + @RequestPart(required = false) MultipartFile imageFile + ) { + manageReviewService.createReview(studyId, memberId, request, imageFile); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @DeleteMapping("/{reviewId}") + public ResponseEntity> deleteReview( + @PathVariable Long studyId, + @PathVariable Long reviewId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + manageReviewService.deleteReview(studyId, reviewId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } + + @PostMapping("/{reviewId}/reactions") + public ResponseEntity> addReaction( + @PathVariable Long studyId, + @PathVariable Long reviewId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestParam Reaction reaction + ) { + manageReviewReactionService.addReaction(studyId, reviewId, memberId, reaction); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @DeleteMapping("/{reviewId}/reactions") + public ResponseEntity> removeReaction( + @PathVariable Long studyId, + @PathVariable Long reviewId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestParam Reaction reaction + ) { + manageReviewReactionService.removeReaction(studyId, reviewId, memberId, reaction); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java b/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java new file mode 100644 index 00000000..3fecef6c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java @@ -0,0 +1,13 @@ +package kr.spot.review.presentation.command.dto; + +public record CreateReviewRequest( + String activity, + String learned, + String encouragement, + Boolean isPrivate +) { + + public boolean isPrivateReview() { + return Boolean.TRUE.equals(isPrivate); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java b/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java new file mode 100644 index 00000000..b544186f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java @@ -0,0 +1,38 @@ +package kr.spot.review.presentation.query; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.review.application.query.GetReviewService; +import kr.spot.review.presentation.query.dto.GetReviewListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก") +@RestController +@RequestMapping("/api/studies/{studyId}/reviews") +@RequiredArgsConstructor +public class ReviewQueryController { + + private final GetReviewService getReviewService; + + @GetMapping + public ResponseEntity> getReviewList( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + GetReviewListResponse response = getReviewService.getReviewList(studyId, memberId, cursor, size); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java b/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java new file mode 100644 index 00000000..0caf8217 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java @@ -0,0 +1,82 @@ +package kr.spot.review.presentation.query.dto; + +import java.util.List; + +public record GetReviewListResponse( + List reviews, + boolean hasNext, + Long nextCursor, + Long totalElements +) { + + public record ReviewResponse( + Long reviewId, + WriterInfoResponse writer, + ContentResponse content, + ReactionCountResponse reactionCounts, + ReactionResponse reactions, + boolean isPrivate + ) { + + public static ReviewResponse from( + Long reviewId, + WriterInfoResponse writer, + ContentResponse content, + ReactionCountResponse reactionCounts, + ReactionResponse reactions, + boolean isPrivate + ) { + return new ReviewResponse(reviewId, writer, content, reactionCounts, reactions, isPrivate); + } + } + + public record WriterInfoResponse( + Long memberId, + String nickname, + String profileImageUrl + ) { + + public static WriterInfoResponse from(Long memberId, String nickname, String profileImageUrl) { + return new WriterInfoResponse(memberId, nickname, profileImageUrl); + } + } + + public record ContentResponse( + String activity, + String learned, + String encouragement, + String imageUrl + ) { + + public static ContentResponse from(String activity, String learned, String encouragement, + String imageUrl) { + return new ContentResponse(activity, learned, encouragement, imageUrl); + } + } + + public record ReactionCountResponse( + Long fireCount, + Long heartCount, + Long starCount, + Long smileCount + ) { + + public static ReactionCountResponse from(Long fireCount, Long heartCount, Long starCount, + Long smileCount) { + return new ReactionCountResponse(fireCount, heartCount, starCount, smileCount); + } + } + + public record ReactionResponse( + boolean isFired, + boolean isHearted, + boolean isStarred, + boolean isSmiled + ) { + + public static ReactionResponse from(boolean isFired, boolean isHearted, boolean isStarred, + boolean isSmiled) { + return new ReactionResponse(isFired, isHearted, isStarred, isSmiled); + } + } +} From 6d7aa36c17fba421fc2d74b940bb51f919983df8 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:02:48 +0900 Subject: [PATCH 036/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20API=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/query/GetReviewService.java | 3 +- .../ManageReviewReactionServiceTest.java | 239 +++++++++++++++ .../command/ManageReviewServiceTest.java | 259 ++++++++++++++++ .../query/GetReviewServiceTest.java | 284 ++++++++++++++++++ .../kr/spot/review/common/ReviewFixture.java | 73 +++++ .../kr/spot/review/domain/ReviewTest.java | 81 +++++ .../spot/review/domain/vo/WriterInfoTest.java | 74 +++++ 7 files changed, 1011 insertions(+), 2 deletions(-) create mode 100644 modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java create mode 100644 modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java create mode 100644 modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java diff --git a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java index 5a00b99e..b60f9446 100644 --- a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java +++ b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java @@ -31,8 +31,7 @@ public class GetReviewService { private final StudyAccessValidator studyAccessValidator; public GetReviewListResponse getReviewList(long studyId, Long viewerId, Long cursor, int size) { - boolean isStudyMember = - viewerId != null && studyAccessValidator.isStudyMember(studyId, viewerId); + boolean isStudyMember = studyAccessValidator.isStudyMember(studyId, viewerId); List reviews = reviewQueryRepository.findByStudyIdWithCursor(studyId, cursor, size + 1); diff --git a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java new file mode 100644 index 00000000..c42fb5de --- /dev/null +++ b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java @@ -0,0 +1,239 @@ +package kr.spot.review.application.command; + +import static kr.spot.review.common.ReviewFixture.MEMBER_ID; +import static kr.spot.review.common.ReviewFixture.STUDY_ID; +import static kr.spot.review.common.ReviewFixture.review; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.associations.ReviewReaction; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManageReviewReactionServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + ReviewRepository reviewRepository; + + @Mock + ReviewReactionRepository reviewReactionRepository; + + @Mock + StudyAccessValidator studyAccessValidator; + + @Captor + ArgumentCaptor reactionCaptor; + + ManageReviewReactionService manageReviewReactionService; + + @BeforeEach + void setUp() { + manageReviewReactionService = new ManageReviewReactionService( + idGenerator, reviewRepository, reviewReactionRepository, studyAccessValidator); + } + + @Nested + @DisplayName("๋ฐ˜์‘ ์ถ”๊ฐ€ (addReaction)") + class AddReaction { + + @Test + @DisplayName("๋ฐ˜์‘์„ ์ •์ƒ์ ์œผ๋กœ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_add_reaction_successfully() { + // given + long reviewId = 1L; + long generatedId = 100L; + Reaction reaction = Reaction.FIRE; + Review review = review(reviewId); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())).thenReturn(review); + when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(), + any())).thenReturn(false); + when(idGenerator.nextId()).thenReturn(generatedId); + when(reviewReactionRepository.save(any(ReviewReaction.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction); + + // then + verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + verify(reviewRepository).getById(reviewId); + verify(reviewReactionRepository).save(reactionCaptor.capture()); + + ReviewReaction capturedReaction = reactionCaptor.getValue(); + assertThat(capturedReaction.getId()).isEqualTo(generatedId); + assertThat(capturedReaction.getReviewId()).isEqualTo(reviewId); + assertThat(capturedReaction.getMemberId()).isEqualTo(MEMBER_ID); + assertThat(capturedReaction.getReaction()).isEqualTo(reaction); + } + + @Test + @DisplayName("์ด๋ฏธ ๋™์ผํ•œ ๋ฐ˜์‘์„ ๋ˆ„๋ฅธ ๊ฒฝ์šฐ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_already_reacted() { + // given + long reviewId = 1L; + Reaction reaction = Reaction.HEART; + Review review = review(reviewId); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())).thenReturn(review); + when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(), + any())).thenReturn(true); + + // when & then + assertThatThrownBy( + () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ALREADY_REACTED); + + verify(reviewReactionRepository, never()).save(any()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํšŒ๊ณ ์— ๋ฐ˜์‘์„ ์ถ”๊ฐ€ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_review_not_found() { + // given + long reviewId = 999L; + Reaction reaction = Reaction.STAR; + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); + + // when & then + assertThatThrownBy( + () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._REVIEW_NOT_FOUND); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด ๋ฐ˜์‘์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throw_exception_when_not_study_member() { + // given + long reviewId = 1L; + Reaction reaction = Reaction.SMILE; + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + + // when & then + assertThatThrownBy( + () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + + verify(reviewReactionRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("๋ฐ˜์‘ ์ œ๊ฑฐ (removeReaction)") + class RemoveReaction { + + @Test + @DisplayName("๋ฐ˜์‘์„ ์ •์ƒ์ ์œผ๋กœ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_remove_reaction_successfully() { + // given + long reviewId = 1L; + Reaction reaction = Reaction.FIRE; + Review review = review(reviewId); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())).thenReturn(review); + when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(1); + + // when + manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction); + + // then + verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + verify(reviewRepository).getById(reviewId); + verify(reviewReactionRepository).hardDelete(reviewId, MEMBER_ID, reaction.name()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฐ˜์‘์„ ์ œ๊ฑฐํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_reaction_not_found() { + // given + long reviewId = 1L; + Reaction reaction = Reaction.HEART; + Review review = review(reviewId); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())).thenReturn(review); + when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(0); + + // when & then + assertThatThrownBy( + () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._REACTION_NOT_FOUND); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํšŒ๊ณ ์˜ ๋ฐ˜์‘์„ ์ œ๊ฑฐํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_review_not_found() { + // given + long reviewId = 999L; + Reaction reaction = Reaction.STAR; + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); + + // when & then + assertThatThrownBy( + () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._REVIEW_NOT_FOUND); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด ๋ฐ˜์‘์„ ์ œ๊ฑฐํ•  ์ˆ˜ ์—†๋‹ค") + void should_throw_exception_when_not_study_member() { + // given + long reviewId = 1L; + Reaction reaction = Reaction.SMILE; + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + + // when & then + assertThatThrownBy( + () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + + verify(reviewReactionRepository, never()).hardDelete(anyLong(), anyLong(), anyString()); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java new file mode 100644 index 00000000..8fb318fa --- /dev/null +++ b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java @@ -0,0 +1,259 @@ +package kr.spot.review.application.command; + +import static kr.spot.review.common.ReviewFixture.ACTIVITY; +import static kr.spot.review.common.ReviewFixture.ENCOURAGEMENT; +import static kr.spot.review.common.ReviewFixture.LEARNED; +import static kr.spot.review.common.ReviewFixture.MEMBER_ID; +import static kr.spot.review.common.ReviewFixture.OTHER_MEMBER_ID; +import static kr.spot.review.common.ReviewFixture.STUDY_ID; +import static kr.spot.review.common.ReviewFixture.WRITER_NAME; +import static kr.spot.review.common.ReviewFixture.WRITER_PROFILE_IMAGE_URL; +import static kr.spot.review.common.ReviewFixture.createPrivateReviewRequest; +import static kr.spot.review.common.ReviewFixture.createReviewRequest; +import static kr.spot.review.common.ReviewFixture.review; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.FileStoragePort; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.UploadResult; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.review.domain.Review; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import kr.spot.review.presentation.command.dto.CreateReviewRequest; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class ManageReviewServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + GetWriterInfoPort getWriterInfoPort; + + @Mock + FileStoragePort fileStoragePort; + + @Mock + ReviewRepository reviewRepository; + + @Mock + StudyAccessValidator studyAccessValidator; + + @Captor + ArgumentCaptor reviewCaptor; + + ManageReviewService manageReviewService; + + @BeforeEach + void setUp() { + manageReviewService = new ManageReviewService( + idGenerator, getWriterInfoPort, fileStoragePort, reviewRepository, studyAccessValidator); + } + + @Nested + @DisplayName("ํšŒ๊ณ  ์ƒ์„ฑ (createReview)") + class CreateReview { + + @Test + @DisplayName("ํšŒ๊ณ ๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_review_successfully() { + // given + Long generatedId = 1L; + CreateReviewRequest request = createReviewRequest(); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME, + WRITER_PROFILE_IMAGE_URL); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId); + when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse); + when(reviewRepository.save(any(Review.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, null); + + // then + verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + verify(reviewRepository).save(reviewCaptor.capture()); + + Review capturedReview = reviewCaptor.getValue(); + assertThat(capturedReview.getId()).isEqualTo(generatedId); + assertThat(capturedReview.getStudyId()).isEqualTo(STUDY_ID); + assertThat(capturedReview.getWriterInfo().getWriterId()).isEqualTo(MEMBER_ID); + assertThat(capturedReview.getContent().getActivity()).isEqualTo(ACTIVITY); + assertThat(capturedReview.getContent().getLearned()).isEqualTo(LEARNED); + assertThat(capturedReview.getContent().getEncouragement()).isEqualTo(ENCOURAGEMENT); + assertThat(capturedReview.isPrivate()).isFalse(); + } + + @Test + @DisplayName("์Šคํ„ฐ๋””์› ์ „์šฉ ํšŒ๊ณ ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_private_review_successfully() { + // given + Long generatedId = 1L; + CreateReviewRequest request = createPrivateReviewRequest(); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME, + WRITER_PROFILE_IMAGE_URL); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId); + when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse); + when(reviewRepository.save(any(Review.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, null); + + // then + verify(reviewRepository).save(reviewCaptor.capture()); + assertThat(reviewCaptor.getValue().isPrivate()).isTrue(); + } + + @Test + @DisplayName("์ด๋ฏธ์ง€์™€ ํ•จ๊ป˜ ํšŒ๊ณ ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_review_with_image() { + // given + Long generatedId = 1L; + CreateReviewRequest request = createReviewRequest(); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME, + WRITER_PROFILE_IMAGE_URL); + MultipartFile imageFile = new MockMultipartFile("image", "test.jpg", "image/jpeg", + "test".getBytes()); + String uploadedImageUrl = "https://s3.example.com/reviews/test.jpg"; + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId); + when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse); + when(fileStoragePort.upload(any(MultipartFile.class), anyString())) + .thenReturn(new UploadResult(uploadedImageUrl, "test.jpg")); + when(reviewRepository.save(any(Review.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, imageFile); + + // then + verify(fileStoragePort).upload(imageFile, "reviews"); + verify(reviewRepository).save(reviewCaptor.capture()); + assertThat(reviewCaptor.getValue().getContent().getImageUrl()).isEqualTo(uploadedImageUrl); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_not_study_member() { + // given + CreateReviewRequest request = createReviewRequest(); + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + + // when & then + assertThatThrownBy( + () -> manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, null)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + + verify(reviewRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("ํšŒ๊ณ  ์‚ญ์ œ (deleteReview)") + class DeleteReview { + + @Test + @DisplayName("ํšŒ๊ณ ๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_review_successfully() { + // given + long reviewId = 1L; + Review review = review(reviewId, STUDY_ID, MEMBER_ID); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())).thenReturn(review); + + // when + manageReviewService.deleteReview(STUDY_ID, reviewId, MEMBER_ID); + + // then + verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + verify(reviewRepository).getById(reviewId); + verify(reviewRepository).delete(review); + } + + @Test + @DisplayName("์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ์‚ญ์ œํ•  ์ˆ˜ ์—†๋‹ค") + void should_throw_exception_when_not_author() { + // given + long reviewId = 1L; + Review review = review(reviewId, STUDY_ID, MEMBER_ID); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())).thenReturn(review); + + // when & then + assertThatThrownBy( + () -> manageReviewService.deleteReview(STUDY_ID, reviewId, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + + verify(reviewRepository, never()).delete(any()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํšŒ๊ณ ๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_review_not_found() { + // given + long reviewId = 999L; + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(reviewRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> manageReviewService.deleteReview(STUDY_ID, reviewId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._REVIEW_NOT_FOUND); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด ์‚ญ์ œํ•  ์ˆ˜ ์—†๋‹ค") + void should_throw_exception_when_not_study_member() { + // given + long reviewId = 1L; + + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + + // when & then + assertThatThrownBy(() -> manageReviewService.deleteReview(STUDY_ID, reviewId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED); + + verify(reviewRepository, never()).delete(any()); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java new file mode 100644 index 00000000..2e44f7f7 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java @@ -0,0 +1,284 @@ +package kr.spot.review.application.query; + +import static kr.spot.review.common.ReviewFixture.ACTIVITY; +import static kr.spot.review.common.ReviewFixture.ENCOURAGEMENT; +import static kr.spot.review.common.ReviewFixture.LEARNED; +import static kr.spot.review.common.ReviewFixture.MEMBER_ID; +import static kr.spot.review.common.ReviewFixture.OTHER_MEMBER_ID; +import static kr.spot.review.common.ReviewFixture.STUDY_ID; +import static kr.spot.review.common.ReviewFixture.privateReview; +import static kr.spot.review.common.ReviewFixture.review; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.infrastructure.jpa.querydsl.ReviewQueryRepository; +import kr.spot.review.infrastructure.jpa.querydsl.ReviewQueryRepository.ReactionCounts; +import kr.spot.review.presentation.query.dto.GetReviewListResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ReviewResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetReviewServiceTest { + + private static final String PRIVATE_CONTENT_MESSAGE = "์ด ๊ธ€์€ ์Šคํ„ฐ๋””์›์—๊ฒŒ๋งŒ ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค."; + + @Mock + ReviewQueryRepository reviewQueryRepository; + + @Mock + StudyAccessValidator studyAccessValidator; + + GetReviewService getReviewService; + + @BeforeEach + void setUp() { + getReviewService = new GetReviewService(reviewQueryRepository, studyAccessValidator); + } + + @Nested + @DisplayName("ํšŒ๊ณ  ๋ชฉ๋ก ์กฐํšŒ (getReviewList)") + class GetReviewList { + + @Test + @DisplayName("ํšŒ๊ณ  ๋ชฉ๋ก์„ ์ •์ƒ์ ์œผ๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_get_review_list_successfully() { + // given + Review review1 = review(1L); + Review review2 = review(2L); + + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(review1, review2)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(2L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null, + 10); + + // then + assertThat(response.reviews()).hasSize(2); + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + assertThat(response.totalElements()).isEqualTo(2L); + } + + @Test + @DisplayName("ํŽ˜์ด์ง€๋„ค์ด์…˜์ด ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•œ๋‹ค") + void should_paginate_correctly() { + // given + Review review1 = review(3L); + Review review2 = review(2L); + Review review3 = review(1L); + + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(review1, review2, review3)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(5L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null, 2); + + // then + assertThat(response.reviews()).hasSize(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextCursor()).isEqualTo(2L); + } + + @Test + @DisplayName("ํšŒ๊ณ ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_no_reviews() { + // given + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(Collections.emptyList()); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(0L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null, + 10); + + // then + assertThat(response.reviews()).isEmpty(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.totalElements()).isZero(); + } + + @Test + @DisplayName("๋ฐ˜์‘ ์นด์šดํŠธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์กฐํšŒ๋œ๋‹ค") + void should_get_reaction_counts_correctly() { + // given + Review review1 = review(1L); + ReactionCounts counts = new ReactionCounts(5, 3, 2, 1); + + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(review1)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())) + .thenReturn(Map.of(1L, counts)); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null, + 10); + + // then + ReviewResponse reviewResponse = response.reviews().getFirst(); + assertThat(reviewResponse.reactionCounts().fireCount()).isEqualTo(5L); + assertThat(reviewResponse.reactionCounts().heartCount()).isEqualTo(3L); + assertThat(reviewResponse.reactionCounts().starCount()).isEqualTo(2L); + assertThat(reviewResponse.reactionCounts().smileCount()).isEqualTo(1L); + } + + @Test + @DisplayName("์‚ฌ์šฉ์ž์˜ ๋ฐ˜์‘ ์—ฌ๋ถ€๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์กฐํšŒ๋œ๋‹ค") + void should_get_member_reactions_correctly() { + // given + Review review1 = review(1L); + + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(review1)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())) + .thenReturn(Map.of(1L, Set.of(Reaction.FIRE, Reaction.HEART))); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null, + 10); + + // then + ReviewResponse reviewResponse = response.reviews().getFirst(); + assertThat(reviewResponse.reactions().isFired()).isTrue(); + assertThat(reviewResponse.reactions().isHearted()).isTrue(); + assertThat(reviewResponse.reactions().isStarred()).isFalse(); + assertThat(reviewResponse.reactions().isSmiled()).isFalse(); + } + } + + @Nested + @DisplayName("๋น„๊ณต๊ฐœ ํšŒ๊ณ  ๋งˆ์Šคํ‚น") + class PrivateReviewMasking { + + @Test + @DisplayName("์Šคํ„ฐ๋””์›์ด ๋น„๊ณต๊ฐœ ํšŒ๊ณ ๋ฅผ ์กฐํšŒํ•˜๋ฉด ์›๋ณธ ๋‚ด์šฉ์ด ๋ณด์ธ๋‹ค") + void should_show_original_content_for_study_member() { + // given + Review privateReview = privateReview(1L, STUDY_ID, MEMBER_ID); + + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(privateReview)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null, + 10); + + // then + ReviewResponse reviewResponse = response.reviews().getFirst(); + assertThat(reviewResponse.isPrivate()).isTrue(); + assertThat(reviewResponse.content().activity()).isEqualTo(ACTIVITY); + assertThat(reviewResponse.content().learned()).isEqualTo(LEARNED); + assertThat(reviewResponse.content().encouragement()).isEqualTo(ENCOURAGEMENT); + } + + @Test + @DisplayName("์Šคํ„ฐ๋””์›์ด ์•„๋‹Œ ์‚ฌ์šฉ์ž๊ฐ€ ๋น„๊ณต๊ฐœ ํšŒ๊ณ ๋ฅผ ์กฐํšŒํ•˜๋ฉด ๋งˆ์Šคํ‚น๋œ ๋‚ด์šฉ์ด ๋ณด์ธ๋‹ค") + void should_mask_content_for_non_study_member() { + // given + Review privateReview = privateReview(1L, STUDY_ID, MEMBER_ID); + + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(false); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(privateReview)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, OTHER_MEMBER_ID, + null, 10); + + // then + ReviewResponse reviewResponse = response.reviews().getFirst(); + assertThat(reviewResponse.isPrivate()).isTrue(); + assertThat(reviewResponse.content().activity()).isEqualTo(PRIVATE_CONTENT_MESSAGE); + assertThat(reviewResponse.content().learned()).isEqualTo(PRIVATE_CONTENT_MESSAGE); + assertThat(reviewResponse.content().encouragement()).isEqualTo(PRIVATE_CONTENT_MESSAGE); + assertThat(reviewResponse.content().imageUrl()).isNull(); + } + + @Test + @DisplayName("๊ณต๊ฐœ ํšŒ๊ณ ๋Š” ์Šคํ„ฐ๋””์›์ด ์•„๋‹ˆ์–ด๋„ ์›๋ณธ ๋‚ด์šฉ์ด ๋ณด์ธ๋‹ค") + void should_show_original_content_for_public_review() { + // given + Review publicReview = review(1L); + + when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(false); + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(publicReview)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L); + + // when + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, OTHER_MEMBER_ID, + null, 10); + + // then + ReviewResponse reviewResponse = response.reviews().getFirst(); + assertThat(reviewResponse.isPrivate()).isFalse(); + assertThat(reviewResponse.content().activity()).isEqualTo(ACTIVITY); + assertThat(reviewResponse.content().learned()).isEqualTo(LEARNED); + assertThat(reviewResponse.content().encouragement()).isEqualTo(ENCOURAGEMENT); + } + + @Test + @DisplayName("๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๊ฐ€ ๋น„๊ณต๊ฐœ ํšŒ๊ณ ๋ฅผ ์กฐํšŒํ•˜๋ฉด ๋งˆ์Šคํ‚น๋œ ๋‚ด์šฉ์ด ๋ณด์ธ๋‹ค") + void should_mask_content_for_anonymous_user() { + // given + Review privateReview = privateReview(1L, STUDY_ID, MEMBER_ID); + + when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt())) + .thenReturn(List.of(privateReview)); + when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of()); + when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of()); + when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L); + + // when (viewerId๊ฐ€ null์ธ ๊ฒฝ์šฐ) + GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, null, null, 10); + + // then + ReviewResponse reviewResponse = response.reviews().getFirst(); + assertThat(reviewResponse.content().activity()).isEqualTo(PRIVATE_CONTENT_MESSAGE); + assertThat(reviewResponse.content().learned()).isEqualTo(PRIVATE_CONTENT_MESSAGE); + assertThat(reviewResponse.content().encouragement()).isEqualTo(PRIVATE_CONTENT_MESSAGE); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java b/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java new file mode 100644 index 00000000..2025d176 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java @@ -0,0 +1,73 @@ +package kr.spot.review.common; + +import kr.spot.review.domain.Review; +import kr.spot.review.domain.associations.ReviewReaction; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.domain.vo.Content; +import kr.spot.review.domain.vo.WriterInfo; +import kr.spot.review.presentation.command.dto.CreateReviewRequest; + +public class ReviewFixture { + + public static final Long ID = 1L; + public static final Long STUDY_ID = 100L; + public static final Long MEMBER_ID = 200L; + public static final Long OTHER_MEMBER_ID = 999L; + + public static final String WRITER_NAME = "ํ™๊ธธ๋™"; + public static final String WRITER_PROFILE_IMAGE_URL = "https://example.com/profile.jpg"; + + public static final String ACTIVITY = "์˜ค๋Š˜์€ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ๋ฌธ์ œ 3๊ฐœ๋ฅผ ํ’€์—ˆ์Šต๋‹ˆ๋‹ค."; + public static final String LEARNED = "๋‹ค์ด๋‚˜๋ฏน ํ”„๋กœ๊ทธ๋ž˜๋ฐ์˜ ๊ฐœ๋…์„ ์ดํ•ดํ–ˆ์Šต๋‹ˆ๋‹ค."; + public static final String ENCOURAGEMENT = "์˜ค๋Š˜๋„ ์ˆ˜๊ณ ํ–ˆ์–ด!"; + public static final String IMAGE_URL = "https://example.com/review-image.jpg"; + + public static WriterInfo writerInfo() { + return WriterInfo.of(MEMBER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); + } + + public static WriterInfo writerInfo(Long memberId) { + return WriterInfo.of(memberId, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); + } + + public static Content content() { + return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, IMAGE_URL); + } + + public static Content content(String imageUrl) { + return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, imageUrl); + } + + public static Review review() { + return Review.of(ID, STUDY_ID, writerInfo(), content(), false); + } + + public static Review review(Long id) { + return Review.of(id, STUDY_ID, writerInfo(), content(), false); + } + + public static Review review(Long id, Long studyId, Long memberId) { + return Review.of(id, studyId, writerInfo(memberId), content(), false); + } + + public static Review privateReview() { + return Review.of(ID, STUDY_ID, writerInfo(), content(), true); + } + + public static Review privateReview(Long id, Long studyId, Long memberId) { + return Review.of(id, studyId, writerInfo(memberId), content(), true); + } + + public static ReviewReaction reviewReaction(Long id, Long reviewId, Long memberId, + Reaction reaction) { + return ReviewReaction.of(id, reviewId, memberId, reaction); + } + + public static CreateReviewRequest createReviewRequest() { + return new CreateReviewRequest(ACTIVITY, LEARNED, ENCOURAGEMENT, false); + } + + public static CreateReviewRequest createPrivateReviewRequest() { + return new CreateReviewRequest(ACTIVITY, LEARNED, ENCOURAGEMENT, true); + } +} diff --git a/modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java b/modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java new file mode 100644 index 00000000..dd330bf9 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java @@ -0,0 +1,81 @@ +package kr.spot.review.domain; + +import static kr.spot.review.common.ReviewFixture.ID; +import static kr.spot.review.common.ReviewFixture.STUDY_ID; +import static kr.spot.review.common.ReviewFixture.content; +import static kr.spot.review.common.ReviewFixture.privateReview; +import static kr.spot.review.common.ReviewFixture.review; +import static kr.spot.review.common.ReviewFixture.writerInfo; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ReviewTest { + + @Nested + @DisplayName("ํšŒ๊ณ  ์ƒ์„ฑ (of)") + class CreateReview { + + @Test + @DisplayName("ํšŒ๊ณ  ๊ฐ์ฒด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_review_successfully() { + // when + Review review = Review.of(ID, STUDY_ID, writerInfo(), content(), false); + + // then + assertThat(review).isNotNull(); + assertThat(review.getId()).isEqualTo(ID); + assertThat(review.getStudyId()).isEqualTo(STUDY_ID); + assertThat(review.getWriterInfo()).isNotNull(); + assertThat(review.getContent()).isNotNull(); + assertThat(review.isPrivate()).isFalse(); + } + + @Test + @DisplayName("์Šคํ„ฐ๋””์› ์ „์šฉ ํšŒ๊ณ ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_private_review_successfully() { + // when + Review review = Review.of(ID, STUDY_ID, writerInfo(), content(), true); + + // then + assertThat(review.isPrivate()).isTrue(); + } + + @Test + @DisplayName("isPrivate๊ฐ€ null์ด๋ฉด ๊ณต๊ฐœ ํšŒ๊ณ ๋กœ ๊ฐ„์ฃผํ•œ๋‹ค") + void should_treat_null_as_public() { + // when + Review review = Review.of(ID, STUDY_ID, writerInfo(), content(), null); + + // then + assertThat(review.isPrivate()).isFalse(); + } + } + + @Nested + @DisplayName("๊ณต๊ฐœ ์—ฌ๋ถ€ ํ™•์ธ (isPrivate)") + class IsPrivate { + + @Test + @DisplayName("๊ณต๊ฐœ ํšŒ๊ณ ๋Š” false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_false_for_public_review() { + // given + Review review = review(); + + // when & then + assertThat(review.isPrivate()).isFalse(); + } + + @Test + @DisplayName("๋น„๊ณต๊ฐœ ํšŒ๊ณ ๋Š” true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_true_for_private_review() { + // given + Review review = privateReview(); + + // when & then + assertThat(review.isPrivate()).isTrue(); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java b/modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java new file mode 100644 index 00000000..d3b6941b --- /dev/null +++ b/modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java @@ -0,0 +1,74 @@ +package kr.spot.review.domain.vo; + +import static kr.spot.review.common.ReviewFixture.MEMBER_ID; +import static kr.spot.review.common.ReviewFixture.OTHER_MEMBER_ID; +import static kr.spot.review.common.ReviewFixture.writerInfo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class WriterInfoTest { + + @Nested + @DisplayName("์ž‘์„ฑ์ž ๊ฒ€์ฆ (validateIsOwnMember)") + class ValidateIsOwnMember { + + @Test + @DisplayName("๋ณธ์ธ์ธ ๊ฒฝ์šฐ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_throw_when_owner() { + // given + WriterInfo writerInfo = writerInfo(); + + // when & then (์˜ˆ์™ธ ๋ฐœ์ƒ ์•ˆํ•จ) + writerInfo.validateIsOwnMember(MEMBER_ID); + } + + @Test + @DisplayName("๋ณธ์ธ์ด ์•„๋‹Œ ๊ฒฝ์šฐ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_when_not_owner() { + // given + WriterInfo writerInfo = writerInfo(); + + // when & then + assertThatThrownBy(() -> writerInfo.validateIsOwnMember(OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + } + + @Nested + @DisplayName("๋™์ผ ์ž‘์„ฑ์ž ํ™•์ธ (isSameWriter)") + class IsSameWriter { + + @Test + @DisplayName("๋™์ผํ•œ ์ž‘์„ฑ์ž์ธ ๊ฒฝ์šฐ true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_true_when_same_writer() { + // given + WriterInfo writerInfo = writerInfo(); + + // when + boolean result = writerInfo.isSameWriter(MEMBER_ID); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์ž‘์„ฑ์ž์ธ ๊ฒฝ์šฐ false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_false_when_different_writer() { + // given + WriterInfo writerInfo = writerInfo(); + + // when + boolean result = writerInfo.isSameWriter(OTHER_MEMBER_ID); + + // then + assertThat(result).isFalse(); + } + } +} From 8e3288b0705393a2c85e548dd986cfc500b85985 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:08:24 +0900 Subject: [PATCH 037/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EB=A6=AC=EC=95=A1=EC=85=98=20?= =?UTF-8?q?API=20=EB=A9=B1=EB=93=B1=EC=A0=81=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageReviewReactionService.java | 17 ++++------------- .../command/ManageReviewService.java | 2 +- .../main/java/kr/spot/review/domain/Review.java | 4 ++++ .../domain/associations/ReviewReaction.java | 9 +++++++++ .../infrastructure/jpa/ReviewRepository.java | 6 ++++++ 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java index 5ba2a402..f74de2da 100644 --- a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java @@ -1,8 +1,6 @@ package kr.spot.review.application.command; import kr.spot.IdGenerator; -import kr.spot.code.status.ErrorStatus; -import kr.spot.exception.GeneralException; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; @@ -24,11 +22,11 @@ public class ManageReviewReactionService { public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) { studyAccessValidator.validateStudyMember(studyId, memberId); - validateReviewExists(reviewId); + reviewRepository.validateExists(reviewId); if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId, reaction)) { - throw new GeneralException(ErrorStatus._ALREADY_REACTED); + return; } ReviewReaction reviewReaction = ReviewReaction.of( @@ -38,15 +36,8 @@ public void addReaction(long studyId, long reviewId, long memberId, Reaction rea public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) { studyAccessValidator.validateStudyMember(studyId, memberId); - validateReviewExists(reviewId); + reviewRepository.validateExists(reviewId); - int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); - if (deleted == 0) { - throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND); - } - } - - private void validateReviewExists(long reviewId) { - reviewRepository.getById(reviewId); + reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); } } diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java index e8d83492..dd87719d 100644 --- a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java @@ -47,7 +47,7 @@ public void deleteReview(long studyId, long reviewId, long memberId) { studyAccessValidator.validateStudyMember(studyId, memberId); Review review = reviewRepository.getById(reviewId); - review.getWriterInfo().validateIsOwnMember(memberId); + review.validateIsOwnMember(memberId); reviewRepository.delete(review); } diff --git a/modules/study/src/main/java/kr/spot/review/domain/Review.java b/modules/study/src/main/java/kr/spot/review/domain/Review.java index 36d93735..87254f98 100644 --- a/modules/study/src/main/java/kr/spot/review/domain/Review.java +++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java @@ -42,4 +42,8 @@ public static Review of(Long id, Long studyId, WriterInfo writerInfo, Content co public boolean isPrivate() { return Boolean.TRUE.equals(isPrivate); } + + public void validateIsOwnMember(Long currentUserId) { + writerInfo.validateIsOwnMember(currentUserId); + } } diff --git a/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java index 7063b6ff..70d0d7b7 100644 --- a/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java +++ b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java @@ -2,6 +2,8 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import kr.spot.domain.BaseEntity; import kr.spot.review.domain.enums.Reaction; import lombok.AccessLevel; @@ -17,6 +19,13 @@ @SQLRestriction("status = 'ACTIVE'") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table( + name = "review_reaction", + uniqueConstraints = @UniqueConstraint( + name = "uk_review_member_reaction", + columnNames = {"reviewId", "memberId", "reaction"} + ) +) public class ReviewReaction extends BaseEntity { @Id diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java index 191788db..866fdcfa 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java @@ -11,4 +11,10 @@ default Review getById(long reviewId) { return findById(reviewId).orElseThrow( () -> new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); } + + default void validateExists(long reviewId) { + if (!existsById(reviewId)) { + throw new GeneralException(ErrorStatus._REVIEW_NOT_FOUND); + } + } } From 0001d73007f1b4e7bb066bcfeffc086f991baf8d Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:09:24 +0900 Subject: [PATCH 038/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EB=A6=AC=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=88=84=EB=9D=BD=EB=90=9C=20Enu?= =?UTF-8?q?m=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/spot/review/domain/associations/ReviewReaction.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java index 70d0d7b7..788c4be4 100644 --- a/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java +++ b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java @@ -1,6 +1,8 @@ package kr.spot.review.domain.associations; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -35,6 +37,7 @@ public class ReviewReaction extends BaseEntity { private Long memberId; + @Enumerated(EnumType.STRING) private Reaction reaction; public static ReviewReaction of(Long id, Long reviewId, Long memberId, Reaction reaction) { From 12a7642ef736ad918261f62f74f103d77e8f7b04 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:10:16 +0900 Subject: [PATCH 039/126] =?UTF-8?q?[Refactor]=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/infrastructure/jpa/ReviewReactionRepository.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java index c791345a..53d81390 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java @@ -1,6 +1,5 @@ package kr.spot.review.infrastructure.jpa; -import java.util.Optional; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,9 +9,6 @@ public interface ReviewReactionRepository extends JpaRepository { - Optional findByReviewIdAndMemberIdAndReaction( - Long reviewId, Long memberId, Reaction reaction); - boolean existsByReviewIdAndMemberIdAndReaction( Long reviewId, Long memberId, Reaction reaction); From 828e23add860e472afed005a1902ae1f14a484e0 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 00:12:50 +0900 Subject: [PATCH 040/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # common/api/src/main/java/kr/spot/code/status/ErrorStatus.java --- .../command/ManageTodoService.java | 50 ++++++++ .../application/query/GetTodoService.java | 49 ++++++++ .../main/java/kr/spot/todo/domain/Todo.java | 77 ++++++++++++ .../infrastructure/jpa/TodoRepository.java | 14 +++ .../jpa/querydsl/TodoQueryRepository.java | 34 ++++++ .../command/TodoCommandController.java | 112 ++++++++++++++++++ .../command/dto/CreateTodoRequest.java | 15 +++ .../command/dto/UpdateTodoRequest.java | 15 +++ .../query/TodoQueryController.java | 46 +++++++ .../query/dto/GetTodoListResponse.java | 40 +++++++ 10 files changed, 452 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java create mode 100644 modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java create mode 100644 modules/study/src/main/java/kr/spot/todo/domain/Todo.java create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java new file mode 100644 index 00000000..e2aedb79 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java @@ -0,0 +1,50 @@ +package kr.spot.todo.application.command; + +import kr.spot.IdGenerator; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.TodoRepository; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageTodoService { + + private final IdGenerator idGenerator; + private final TodoRepository todoRepository; + + public void createTodo(long studyId, long memberId, CreateTodoRequest request) { + Todo todo = Todo.of( + idGenerator.nextId(), + studyId, + memberId, + request.dueDate(), + request.content() + ); + todoRepository.save(todo); + } + + public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) { + Todo todo = todoRepository.getById(todoId); + todo.update(request.content(), request.dueDate(), memberId); + } + + public void completeTodo(long studyId, long todoId, long memberId) { + Todo todo = todoRepository.getById(todoId); + todo.complete(memberId); + } + + public void uncompleteTodo(long studyId, long todoId, long memberId) { + Todo todo = todoRepository.getById(todoId); + todo.uncomplete(memberId); + } + + public void deleteTodo(long studyId, long todoId, long memberId) { + Todo todo = todoRepository.getById(todoId); + todo.delete(studyId, memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java new file mode 100644 index 00000000..c07c51b4 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java @@ -0,0 +1,49 @@ +package kr.spot.todo.application.query; + +import java.time.LocalDate; +import java.util.List; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetTodoService { + + private final TodoQueryRepository todoQueryRepository; + + public GetTodoListResponse getTodosByDate(long studyId, long memberId, LocalDate dueDate) { + List todos = todoQueryRepository.findByStudyIdAndMemberIdAndDueDate( + studyId, memberId, dueDate); + + return toResponse(todos); + } + + private GetTodoListResponse toResponse(List todos) { + List pending = todos.stream() + .filter(todo -> !Boolean.TRUE.equals(todo.getIsCompleted())) + .map(this::toTodoResponse) + .toList(); + + List completed = todos.stream() + .filter(todo -> Boolean.TRUE.equals(todo.getIsCompleted())) + .map(this::toTodoResponse) + .toList(); + + return GetTodoListResponse.from(pending, completed); + } + + private TodoResponse toTodoResponse(Todo todo) { + return TodoResponse.from( + todo.getId(), + todo.getContent(), + todo.getDueDate(), + todo.getIsCompleted() + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java new file mode 100644 index 00000000..a86ad8a3 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java @@ -0,0 +1,77 @@ +package kr.spot.todo.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDate; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.BaseEntity; +import kr.spot.exception.GeneralException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE todo SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Todo extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + private Long memberId; + + @Column(nullable = false) + private LocalDate dueDate; + + @Column(nullable = false) + private String content; + + private Boolean isCompleted; + + public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, String content) { + return new Todo(id, studyId, memberId, dueDate, content, false); + } + + public void update(String content, LocalDate dueDate, long memberId) { + validateOwner(memberId); + this.content = content; + this.dueDate = dueDate; + } + + public void complete(long memberId) { + validateOwner(memberId); + this.isCompleted = true; + } + + public void uncomplete(long memberId) { + validateOwner(memberId); + this.isCompleted = false; + } + + public void delete(long studyId, long memberId) { + validateAccess(studyId); + validateOwner(memberId); + super.delete(); + } + + private void validateAccess(long studyId) { + if (this.studyId != studyId) { + throw new GeneralException(ErrorStatus._TODO_ACCESS_DENIED); + } + } + + private void validateOwner(long memberId) { + if (this.memberId != memberId) { + throw new GeneralException(ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java new file mode 100644 index 00000000..623fe3c7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java @@ -0,0 +1,14 @@ +package kr.spot.todo.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.todo.domain.Todo; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TodoRepository extends JpaRepository { + + default Todo getById(long todoId) { + return findById(todoId).orElseThrow( + () -> new GeneralException(ErrorStatus._TODO_NOT_FOUND)); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java new file mode 100644 index 00000000..64d21f3a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java @@ -0,0 +1,34 @@ +package kr.spot.todo.infrastructure.jpa.querydsl; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.util.List; +import kr.spot.todo.domain.QTodo; +import kr.spot.todo.domain.Todo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TodoQueryRepository { + + private final JPAQueryFactory query; + + public List findByStudyIdAndMemberIdAndDueDate(Long studyId, Long memberId, + LocalDate dueDate) { + QTodo todo = QTodo.todo; + + return query + .selectFrom(todo) + .where( + todo.studyId.eq(studyId), + todo.memberId.eq(memberId), + todo.dueDate.eq(dueDate) + ) + .orderBy( + todo.isCompleted.asc(), + todo.createdAt.desc() + ) + .fetch(); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java new file mode 100644 index 00000000..4f7ab5a7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java @@ -0,0 +1,112 @@ +package kr.spot.todo.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.todo.application.command.ManageTodoService; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ํˆฌ๋‘") +@RestController +@RequestMapping("/api/studies/{studyId}/todos") +@RequiredArgsConstructor +public class TodoCommandController { + + private final ManageTodoService manageTodoService; + + @Operation(summary = "ํˆฌ๋‘ ์ƒ์„ฑ", description = "์Šคํ„ฐ๋””์— ์ƒˆ๋กœ์šด ํˆฌ๋‘๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์ƒ์„ฑ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`STUDY404`: ์Šคํ„ฐ๋””๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping + public ResponseEntity> createTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestBody CreateTodoRequest request) { + manageTodoService.createTodo(studyId, memberId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @Operation(summary = "ํˆฌ๋‘ ์ˆ˜์ •", description = "ํˆฌ๋‘ ๋‚ด์šฉ๊ณผ ๋งˆ๊ฐ์ผ์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์ˆ˜์ • ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO4030`: ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PatchMapping("/{todoId}") + public ResponseEntity> updateTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestBody UpdateTodoRequest request) { + manageTodoService.updateTodo(studyId, todoId, memberId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "ํˆฌ๋‘ ์™„๋ฃŒ", description = "ํˆฌ๋‘๋ฅผ ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์™„๋ฃŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO4030`: ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{todoId}/complete") + public ResponseEntity> completeTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + manageTodoService.completeTodo(studyId, todoId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ", description = "ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO4030`: ํˆฌ๋‘ ์ˆ˜์ •์€ ์ž‘์„ฑ์ž๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{todoId}/uncomplete") + public ResponseEntity> uncompleteTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + manageTodoService.uncompleteTodo(studyId, todoId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "ํˆฌ๋‘ ์‚ญ์ œ", description = "ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํˆฌ๋‘ ์‚ญ์ œ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`TODO403`: ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ํˆฌ๋‘๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/{todoId}") + public ResponseEntity> deleteTodo( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "ํˆฌ๋‘ ID", required = true) @PathVariable Long todoId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + manageTodoService.deleteTodo(studyId, todoId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java new file mode 100644 index 00000000..72e8f4f6 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java @@ -0,0 +1,15 @@ +package kr.spot.todo.presentation.command.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +@Schema(description = "ํˆฌ๋‘ ์ƒ์„ฑ ์š”์ฒญ") +public record CreateTodoRequest( + @Schema(description = "ํˆฌ๋‘ ๋‚ด์šฉ", example = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ") + String content, + + @Schema(description = "๋งˆ๊ฐ์ผ", example = "2025-01-15") + LocalDate dueDate +) { + +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java new file mode 100644 index 00000000..33e21561 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java @@ -0,0 +1,15 @@ +package kr.spot.todo.presentation.command.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +@Schema(description = "ํˆฌ๋‘ ์ˆ˜์ • ์š”์ฒญ") +public record UpdateTodoRequest( + @Schema(description = "ํˆฌ๋‘ ๋‚ด์šฉ", example = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ") + String content, + + @Schema(description = "๋งˆ๊ฐ์ผ", example = "2025-01-15") + LocalDate dueDate +) { + +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java new file mode 100644 index 00000000..8d977128 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java @@ -0,0 +1,46 @@ +package kr.spot.todo.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import kr.spot.ApiResponse; +import kr.spot.code.status.SuccessStatus; +import kr.spot.todo.application.query.GetTodoService; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ํˆฌ๋‘") +@RestController +@RequestMapping("/api/studies/{studyId}/todos") +@RequiredArgsConstructor +public class TodoQueryController { + + private final GetTodoService getTodoService; + + @Operation(summary = "๋‚ ์งœ๋ณ„ ํˆฌ๋‘ ์กฐํšŒ", description = "ํŠน์ • ๋‚ ์งœ์˜ ํˆฌ๋‘๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ์ด ๋จผ์ €, ์™„๋ฃŒ ํ•ญ๋ชฉ์€ ๋ณ„๋„๋กœ ์กฐํšŒ๋ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/members/{memberId}") + public ResponseEntity> getTodosByDate( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @PathVariable @Parameter(hidden = true) Long memberId, + @Parameter(description = "์กฐํšŒํ•  ๋‚ ์งœ (ISO 8601 ํ‘œ์ค€)", example = "2025-01-15") + @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) { + GetTodoListResponse response = getTodoService.getTodosByDate(studyId, memberId, date); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java new file mode 100644 index 00000000..e937d0c0 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java @@ -0,0 +1,40 @@ +package kr.spot.todo.presentation.query.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "ํˆฌ๋‘ ๋ชฉ๋ก ์‘๋‹ต") +public record GetTodoListResponse( + @Schema(description = "๋ฏธ์™„๋ฃŒ ํˆฌ๋‘ ๋ชฉ๋ก") + List pending, + + @Schema(description = "์™„๋ฃŒ๋œ ํˆฌ๋‘ ๋ชฉ๋ก") + List completed +) { + + public static GetTodoListResponse from(List pending, List completed) { + return new GetTodoListResponse(pending, completed); + } + + @Schema(description = "ํˆฌ๋‘ ์ƒ์„ธ ์ •๋ณด") + public record TodoResponse( + @Schema(description = "ํˆฌ๋‘ ID", example = "1234567890") + Long id, + + @Schema(description = "ํˆฌ๋‘ ๋‚ด์šฉ", example = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ") + String content, + + @Schema(description = "๋งˆ๊ฐ์ผ", example = "2025-01-15") + LocalDate dueDate, + + @Schema(description = "์™„๋ฃŒ ์—ฌ๋ถ€", example = "false") + Boolean isCompleted + ) { + + public static TodoResponse from(Long id, String content, LocalDate dueDate, + Boolean isCompleted) { + return new TodoResponse(id, content, dueDate, isCompleted); + } + } +} From 71e7f59b5e345e1ada192cb173aade0f1f7c5812 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 13:15:27 +0900 Subject: [PATCH 041/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageTodoService.java | 6 +- .../main/java/kr/spot/todo/domain/Todo.java | 9 +- .../command/ManageTodoServiceTest.java | 334 ++++++++++++++++++ .../application/query/GetTodoServiceTest.java | 139 ++++++++ .../java/kr/spot/todo/common/TodoFixture.java | 49 +++ .../java/kr/spot/todo/domain/TodoTest.java | 218 ++++++++++++ 6 files changed, 749 insertions(+), 6 deletions(-) create mode 100644 modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java create mode 100644 modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java index e2aedb79..a8631b31 100644 --- a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java +++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java @@ -30,17 +30,17 @@ public void createTodo(long studyId, long memberId, CreateTodoRequest request) { public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) { Todo todo = todoRepository.getById(todoId); - todo.update(request.content(), request.dueDate(), memberId); + todo.update(studyId, request.content(), request.dueDate(), memberId); } public void completeTodo(long studyId, long todoId, long memberId) { Todo todo = todoRepository.getById(todoId); - todo.complete(memberId); + todo.complete(studyId, memberId); } public void uncompleteTodo(long studyId, long todoId, long memberId) { Todo todo = todoRepository.getById(todoId); - todo.uncomplete(memberId); + todo.uncomplete(studyId, memberId); } public void deleteTodo(long studyId, long todoId, long memberId) { diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java index a86ad8a3..2058d06e 100644 --- a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java +++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java @@ -41,18 +41,21 @@ public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, S return new Todo(id, studyId, memberId, dueDate, content, false); } - public void update(String content, LocalDate dueDate, long memberId) { + public void update(long studyId, String content, LocalDate dueDate, long memberId) { + validateAccess(studyId); validateOwner(memberId); this.content = content; this.dueDate = dueDate; } - public void complete(long memberId) { + public void complete(long studyId, long memberId) { + validateAccess(studyId); validateOwner(memberId); this.isCompleted = true; } - public void uncomplete(long memberId) { + public void uncomplete(long studyId, long memberId) { + validateAccess(studyId); validateOwner(memberId); this.isCompleted = false; } diff --git a/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java new file mode 100644 index 00000000..5f52049c --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java @@ -0,0 +1,334 @@ +package kr.spot.todo.application.command; + +import static kr.spot.todo.common.TodoFixture.CONTENT; +import static kr.spot.todo.common.TodoFixture.DUE_DATE; +import static kr.spot.todo.common.TodoFixture.MEMBER_ID; +import static kr.spot.todo.common.TodoFixture.STUDY_ID; +import static kr.spot.todo.common.TodoFixture.createTodoRequest; +import static kr.spot.todo.common.TodoFixture.todo; +import static kr.spot.todo.common.TodoFixture.updateTodoRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.TodoRepository; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManageTodoServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + TodoRepository todoRepository; + + @Captor + ArgumentCaptor todoCaptor; + + ManageTodoService manageTodoService; + + @BeforeEach + void setUp() { + manageTodoService = new ManageTodoService(idGenerator, todoRepository); + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์ƒ์„ฑ (createTodo)") + class CreateTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_todo_successfully() { + // given + Long generatedId = 1L; + CreateTodoRequest request = createTodoRequest(); + + when(idGenerator.nextId()).thenReturn(generatedId); + when(todoRepository.save(any(Todo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageTodoService.createTodo(STUDY_ID, MEMBER_ID, request); + + // then + verify(todoRepository).save(todoCaptor.capture()); + + Todo capturedTodo = todoCaptor.getValue(); + assertThat(capturedTodo.getId()).isEqualTo(generatedId); + assertThat(capturedTodo.getStudyId()).isEqualTo(STUDY_ID); + assertThat(capturedTodo.getMemberId()).isEqualTo(MEMBER_ID); + assertThat(capturedTodo.getContent()).isEqualTo(CONTENT); + assertThat(capturedTodo.getDueDate()).isEqualTo(DUE_DATE); + assertThat(capturedTodo.getIsCompleted()).isFalse(); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์ˆ˜์ • (updateTodo)") + class UpdateTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request); + + // then + verify(todoRepository).getById(todoId); + assertThat(todo.getContent()).isEqualTo(request.content()); + assertThat(todo.getDueDate()).isEqualTo(request.dueDate()); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.updateTodo(otherStudyId, todoId, MEMBER_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, otherMemberId, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํˆฌ๋‘๋ฅผ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_todo_not_found() { + // given + long todoId = 999L; + UpdateTodoRequest request = updateTodoRequest(); + + when(todoRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์™„๋ฃŒ (completeTodo)") + class CompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์™„๋ฃŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_complete_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.completeTodo(STUDY_ID, todoId, MEMBER_ID); + + // then + verify(todoRepository).getById(todoId); + assertThat(todo.getIsCompleted()).isTrue(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.completeTodo(otherStudyId, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.completeTodo(STUDY_ID, todoId, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ (uncompleteTodo)") + class UncompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_uncomplete_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + todo.complete(STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.uncompleteTodo(STUDY_ID, todoId, MEMBER_ID); + + // then + verify(todoRepository).getById(todoId); + assertThat(todo.getIsCompleted()).isFalse(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.uncompleteTodo(otherStudyId, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.uncompleteTodo(STUDY_ID, todoId, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์‚ญ์ œ (deleteTodo)") + class DeleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_todo_successfully() { + // given + long todoId = 1L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when + manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID); + + // then + verify(todoRepository).getById(todoId); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_other_study_todo() { + // given + long todoId = 1L; + long otherStudyId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.deleteTodo(otherStudyId, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_by_non_owner() { + // given + long todoId = 1L; + long otherMemberId = 999L; + Todo todo = todo(todoId, STUDY_ID, MEMBER_ID); + + when(todoRepository.getById(anyLong())).thenReturn(todo); + + // when & then + assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_todo_not_found() { + // given + long todoId = 999L; + + when(todoRepository.getById(anyLong())) + .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java new file mode 100644 index 00000000..648e9043 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java @@ -0,0 +1,139 @@ +package kr.spot.todo.application.query; + +import static kr.spot.todo.common.TodoFixture.DUE_DATE; +import static kr.spot.todo.common.TodoFixture.MEMBER_ID; +import static kr.spot.todo.common.TodoFixture.STUDY_ID; +import static kr.spot.todo.common.TodoFixture.completedTodo; +import static kr.spot.todo.common.TodoFixture.todo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse; +import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetTodoServiceTest { + + @Mock + TodoQueryRepository todoQueryRepository; + + GetTodoService getTodoService; + + @BeforeEach + void setUp() { + getTodoService = new GetTodoService(todoQueryRepository); + } + + @Nested + @DisplayName("๋‚ ์งœ๋ณ„ ํˆฌ๋‘ ์กฐํšŒ (getTodosByDate)") + class GetTodosByDate { + + @Test + @DisplayName("ํŠน์ • ๋‚ ์งœ์˜ ํˆฌ๋‘ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_get_todos_by_date_successfully() { + // given + Todo pendingTodo = todo(1L); + Todo completedTodo = completedTodo(); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(pendingTodo, completedTodo)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + verify(todoQueryRepository).findByStudyIdAndMemberIdAndDueDate(STUDY_ID, MEMBER_ID, DUE_DATE); + assertThat(response.pending()).hasSize(1); + assertThat(response.completed()).hasSize(1); + } + + @Test + @DisplayName("๋ฏธ์™„๋ฃŒ ํˆฌ๋‘๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ pending ๋ชฉ๋ก๋งŒ ๋ฐ˜ํ™˜๋œ๋‹ค") + void should_return_only_pending_todos_when_no_completed() { + // given + Todo pendingTodo1 = todo(1L); + Todo pendingTodo2 = todo(2L); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(pendingTodo1, pendingTodo2)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).hasSize(2); + assertThat(response.completed()).isEmpty(); + } + + @Test + @DisplayName("์™„๋ฃŒ๋œ ํˆฌ๋‘๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ completed ๋ชฉ๋ก๋งŒ ๋ฐ˜ํ™˜๋œ๋‹ค") + void should_return_only_completed_todos_when_no_pending() { + // given + Todo completedTodo1 = todo(1L); + completedTodo1.complete(STUDY_ID, MEMBER_ID); + Todo completedTodo2 = todo(2L); + completedTodo2.complete(STUDY_ID, MEMBER_ID); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(completedTodo1, completedTodo2)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).isEmpty(); + assertThat(response.completed()).hasSize(2); + } + + @Test + @DisplayName("ํˆฌ๋‘๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ๋นˆ ๋ชฉ๋ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค") + void should_return_empty_lists_when_no_todos() { + // given + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(Collections.emptyList()); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).isEmpty(); + assertThat(response.completed()).isEmpty(); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์‘๋‹ต์— ์˜ฌ๋ฐ”๋ฅธ ์ •๋ณด๊ฐ€ ํฌํ•จ๋œ๋‹ค") + void should_return_correct_todo_information() { + // given + Todo todo = todo(1L); + + when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class))) + .thenReturn(List.of(todo)); + + // when + GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE); + + // then + assertThat(response.pending()).hasSize(1); + TodoResponse todoResponse = response.pending().get(0); + assertThat(todoResponse.id()).isEqualTo(todo.getId()); + assertThat(todoResponse.content()).isEqualTo(todo.getContent()); + assertThat(todoResponse.dueDate()).isEqualTo(todo.getDueDate()); + assertThat(todoResponse.isCompleted()).isEqualTo(todo.getIsCompleted()); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java new file mode 100644 index 00000000..9580143c --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java @@ -0,0 +1,49 @@ +package kr.spot.todo.common; + +import java.time.LocalDate; +import kr.spot.todo.domain.Todo; +import kr.spot.todo.presentation.command.dto.CreateTodoRequest; +import kr.spot.todo.presentation.command.dto.UpdateTodoRequest; + +public class TodoFixture { + + public static final Long ID = 1L; + public static final Long STUDY_ID = 100L; + public static final Long MEMBER_ID = 200L; + public static final LocalDate DUE_DATE = LocalDate.of(2025, 1, 15); + public static final String CONTENT = "๊ณผ์ œ ์ œ์ถœํ•˜๊ธฐ"; + + public static Todo todo() { + return Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT); + } + + public static Todo todo(Long id) { + return Todo.of(id, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT); + } + + public static Todo todo(Long id, Long studyId, Long memberId) { + return Todo.of(id, studyId, memberId, DUE_DATE, CONTENT); + } + + public static Todo completedTodo() { + Todo todo = todo(); + todo.complete(STUDY_ID, MEMBER_ID); + return todo; + } + + public static CreateTodoRequest createTodoRequest() { + return new CreateTodoRequest(CONTENT, DUE_DATE); + } + + public static CreateTodoRequest createTodoRequest(String content, LocalDate dueDate) { + return new CreateTodoRequest(content, dueDate); + } + + public static UpdateTodoRequest updateTodoRequest() { + return new UpdateTodoRequest("์ˆ˜์ •๋œ ๋‚ด์šฉ", LocalDate.of(2025, 1, 20)); + } + + public static UpdateTodoRequest updateTodoRequest(String content, LocalDate dueDate) { + return new UpdateTodoRequest(content, dueDate); + } +} diff --git a/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java new file mode 100644 index 00000000..c352b3c4 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java @@ -0,0 +1,218 @@ +package kr.spot.todo.domain; + +import static kr.spot.todo.common.TodoFixture.CONTENT; +import static kr.spot.todo.common.TodoFixture.DUE_DATE; +import static kr.spot.todo.common.TodoFixture.ID; +import static kr.spot.todo.common.TodoFixture.MEMBER_ID; +import static kr.spot.todo.common.TodoFixture.STUDY_ID; +import static kr.spot.todo.common.TodoFixture.todo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class TodoTest { + + @Nested + @DisplayName("ํˆฌ๋‘ ์ƒ์„ฑ (of)") + class CreateTodo { + + @Test + @DisplayName("ํˆฌ๋‘ ๊ฐ์ฒด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_create_todo_successfully() { + // when + Todo todo = Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT); + + // then + assertThat(todo).isNotNull(); + assertThat(todo.getId()).isEqualTo(ID); + assertThat(todo.getStudyId()).isEqualTo(STUDY_ID); + assertThat(todo.getMemberId()).isEqualTo(MEMBER_ID); + assertThat(todo.getDueDate()).isEqualTo(DUE_DATE); + assertThat(todo.getContent()).isEqualTo(CONTENT); + assertThat(todo.getIsCompleted()).isFalse(); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์ˆ˜์ • (update)") + class UpdateTodo { + + @Test + @DisplayName("ํˆฌ๋‘ ๋‚ด์šฉ๊ณผ ๋งˆ๊ฐ์ผ์„ ์ •์ƒ์ ์œผ๋กœ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_update_todo_successfully() { + // given + Todo todo = todo(); + String newContent = "์ˆ˜์ •๋œ ๋‚ด์šฉ"; + LocalDate newDueDate = LocalDate.of(2025, 1, 20); + + // when + todo.update(STUDY_ID, newContent, newDueDate, MEMBER_ID); + + // then + assertThat(todo.getContent()).isEqualTo(newContent); + assertThat(todo.getDueDate()).isEqualTo(newDueDate); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํˆฌ๋‘๋ฅผ ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.update(otherStudyId, "๋‚ด์šฉ", DUE_DATE, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ˆ˜์ •ํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_update_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.update(STUDY_ID, "๋‚ด์šฉ", DUE_DATE, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์™„๋ฃŒ (complete)") + class CompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_complete_todo_successfully() { + // given + Todo todo = todo(); + + // when + todo.complete(STUDY_ID, MEMBER_ID); + + // then + assertThat(todo.getIsCompleted()).isTrue(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํˆฌ๋‘๋ฅผ ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.complete(otherStudyId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์™„๋ฃŒํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_complete_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.complete(STUDY_ID, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ๋ฏธ์™„๋ฃŒ (uncomplete)") + class UncompleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_uncomplete_todo_successfully() { + // given + Todo todo = todo(); + todo.complete(STUDY_ID, MEMBER_ID); + + // when + todo.uncomplete(STUDY_ID, MEMBER_ID); + + // then + assertThat(todo.getIsCompleted()).isFalse(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์—์„œ ํˆฌ๋‘๋ฅผ ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.uncomplete(otherStudyId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ๋ฏธ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_uncomplete_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.uncomplete(STUDY_ID, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } + + @Nested + @DisplayName("ํˆฌ๋‘ ์‚ญ์ œ (delete)") + class DeleteTodo { + + @Test + @DisplayName("ํˆฌ๋‘๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_delete_todo_successfully() { + // given + Todo todo = todo(); + + // when & then (์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์œผ๋ฉด ์„ฑ๊ณต) + todo.delete(STUDY_ID, MEMBER_ID); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_with_different_study_id() { + // given + Todo todo = todo(); + long otherStudyId = 999L; + + // when & then + assertThatThrownBy(() -> todo.delete(otherStudyId, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED); + } + + @Test + @DisplayName("ํˆฌ๋‘ ์ž‘์„ฑ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_delete_by_non_owner() { + // given + Todo todo = todo(); + long otherMemberId = 999L; + + // when & then + assertThatThrownBy(() -> todo.delete(STUDY_ID, otherMemberId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY); + } + } +} From 5ccd4e000df662bfc77a794afe30fba8c55aa90f Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 13:55:47 +0900 Subject: [PATCH 042/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20API=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # common/api/src/main/java/kr/spot/code/status/ErrorStatus.java --- .../java/kr/spot/code/status/ErrorStatus.java | 1 + .../command/ManageReviewReactionService.java | 17 +++++++++++++---- .../command/ManageReviewService.java | 2 +- .../application/query/GetReviewService.java | 3 ++- .../main/java/kr/spot/review/domain/Review.java | 4 ---- .../jpa/ReviewReactionRepository.java | 4 ++++ .../infrastructure/jpa/ReviewRepository.java | 6 ------ 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index e7a8a4a3..280d3845 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -77,6 +77,7 @@ public enum ErrorStatus implements BaseErrorCode { _REVIEW_ACCESS_DENIED(403, "REVIEW403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ํšŒ๊ณ ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."), _ALREADY_REACTED(400, "REVIEW4000", "์ด๋ฏธ ๋ฐ˜์‘์„ ๋ˆ„๋ฅธ ํšŒ๊ณ ์ž…๋‹ˆ๋‹ค."), _REACTION_NOT_FOUND(404, "REVIEW4040", "๋ฐ˜์‘์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + ; private final int httpStatus; diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java index f74de2da..5ba2a402 100644 --- a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java @@ -1,6 +1,8 @@ package kr.spot.review.application.command; import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; @@ -22,11 +24,11 @@ public class ManageReviewReactionService { public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) { studyAccessValidator.validateStudyMember(studyId, memberId); - reviewRepository.validateExists(reviewId); + validateReviewExists(reviewId); if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId, reaction)) { - return; + throw new GeneralException(ErrorStatus._ALREADY_REACTED); } ReviewReaction reviewReaction = ReviewReaction.of( @@ -36,8 +38,15 @@ public void addReaction(long studyId, long reviewId, long memberId, Reaction rea public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) { studyAccessValidator.validateStudyMember(studyId, memberId); - reviewRepository.validateExists(reviewId); + validateReviewExists(reviewId); - reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); + int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); + if (deleted == 0) { + throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND); + } + } + + private void validateReviewExists(long reviewId) { + reviewRepository.getById(reviewId); } } diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java index dd87719d..e8d83492 100644 --- a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java @@ -47,7 +47,7 @@ public void deleteReview(long studyId, long reviewId, long memberId) { studyAccessValidator.validateStudyMember(studyId, memberId); Review review = reviewRepository.getById(reviewId); - review.validateIsOwnMember(memberId); + review.getWriterInfo().validateIsOwnMember(memberId); reviewRepository.delete(review); } diff --git a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java index b60f9446..5a00b99e 100644 --- a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java +++ b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java @@ -31,7 +31,8 @@ public class GetReviewService { private final StudyAccessValidator studyAccessValidator; public GetReviewListResponse getReviewList(long studyId, Long viewerId, Long cursor, int size) { - boolean isStudyMember = studyAccessValidator.isStudyMember(studyId, viewerId); + boolean isStudyMember = + viewerId != null && studyAccessValidator.isStudyMember(studyId, viewerId); List reviews = reviewQueryRepository.findByStudyIdWithCursor(studyId, cursor, size + 1); diff --git a/modules/study/src/main/java/kr/spot/review/domain/Review.java b/modules/study/src/main/java/kr/spot/review/domain/Review.java index 87254f98..36d93735 100644 --- a/modules/study/src/main/java/kr/spot/review/domain/Review.java +++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java @@ -42,8 +42,4 @@ public static Review of(Long id, Long studyId, WriterInfo writerInfo, Content co public boolean isPrivate() { return Boolean.TRUE.equals(isPrivate); } - - public void validateIsOwnMember(Long currentUserId) { - writerInfo.validateIsOwnMember(currentUserId); - } } diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java index 53d81390..c791345a 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java @@ -1,5 +1,6 @@ package kr.spot.review.infrastructure.jpa; +import java.util.Optional; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,6 +10,9 @@ public interface ReviewReactionRepository extends JpaRepository { + Optional findByReviewIdAndMemberIdAndReaction( + Long reviewId, Long memberId, Reaction reaction); + boolean existsByReviewIdAndMemberIdAndReaction( Long reviewId, Long memberId, Reaction reaction); diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java index 866fdcfa..191788db 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java @@ -11,10 +11,4 @@ default Review getById(long reviewId) { return findById(reviewId).orElseThrow( () -> new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); } - - default void validateExists(long reviewId) { - if (!existsById(reviewId)) { - throw new GeneralException(ErrorStatus._REVIEW_NOT_FOUND); - } - } } From e35c6033c36d6c3167011a6561d25125bfa3e18a Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:11:32 +0900 Subject: [PATCH 043/126] =?UTF-8?q?[Refactor]=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ManageReviewReactionServiceTest.java | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java index c42fb5de..a944f0fd 100644 --- a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java +++ b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java @@ -2,7 +2,6 @@ import static kr.spot.review.common.ReviewFixture.MEMBER_ID; import static kr.spot.review.common.ReviewFixture.STUDY_ID; -import static kr.spot.review.common.ReviewFixture.review; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -17,7 +16,6 @@ import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; -import kr.spot.review.domain.Review; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; @@ -70,10 +68,9 @@ void should_add_reaction_successfully() { long reviewId = 1L; long generatedId = 100L; Reaction reaction = Reaction.FIRE; - Review review = review(reviewId); doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); - when(reviewRepository.getById(anyLong())).thenReturn(review); + doNothing().when(reviewRepository).validateExists(anyLong()); when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(), any())).thenReturn(false); when(idGenerator.nextId()).thenReturn(generatedId); @@ -85,7 +82,7 @@ void should_add_reaction_successfully() { // then verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); - verify(reviewRepository).getById(reviewId); + verify(reviewRepository).validateExists(reviewId); verify(reviewReactionRepository).save(reactionCaptor.capture()); ReviewReaction capturedReaction = reactionCaptor.getValue(); @@ -96,24 +93,21 @@ void should_add_reaction_successfully() { } @Test - @DisplayName("์ด๋ฏธ ๋™์ผํ•œ ๋ฐ˜์‘์„ ๋ˆ„๋ฅธ ๊ฒฝ์šฐ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void should_throw_exception_when_already_reacted() { + @DisplayName("์ด๋ฏธ ๋™์ผํ•œ ๋ฐ˜์‘์ด ์žˆ์œผ๋ฉด ๋ฌด์‹œํ•œ๋‹ค (๋ฉฑ๋“ฑ์„ฑ)") + void should_ignore_when_already_reacted() { // given long reviewId = 1L; Reaction reaction = Reaction.HEART; - Review review = review(reviewId); doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); - when(reviewRepository.getById(anyLong())).thenReturn(review); + doNothing().when(reviewRepository).validateExists(anyLong()); when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(), any())).thenReturn(true); - // when & then - assertThatThrownBy( - () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus._ALREADY_REACTED); + // when + manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction); + // then verify(reviewReactionRepository, never()).save(any()); } @@ -125,8 +119,8 @@ void should_throw_exception_when_review_not_found() { Reaction reaction = Reaction.STAR; doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); - when(reviewRepository.getById(anyLong())) - .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); + doThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)) + .when(reviewRepository).validateExists(anyLong()); // when & then assertThatThrownBy( @@ -165,10 +159,9 @@ void should_remove_reaction_successfully() { // given long reviewId = 1L; Reaction reaction = Reaction.FIRE; - Review review = review(reviewId); doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); - when(reviewRepository.getById(anyLong())).thenReturn(review); + doNothing().when(reviewRepository).validateExists(anyLong()); when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(1); // when @@ -176,27 +169,25 @@ void should_remove_reaction_successfully() { // then verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); - verify(reviewRepository).getById(reviewId); + verify(reviewRepository).validateExists(reviewId); verify(reviewReactionRepository).hardDelete(reviewId, MEMBER_ID, reaction.name()); } @Test - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฐ˜์‘์„ ์ œ๊ฑฐํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void should_throw_exception_when_reaction_not_found() { + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฐ˜์‘์„ ์ œ๊ฑฐํ•ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค (๋ฉฑ๋“ฑ์„ฑ)") + void should_not_throw_when_reaction_not_found() { // given long reviewId = 1L; Reaction reaction = Reaction.HEART; - Review review = review(reviewId); doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); - when(reviewRepository.getById(anyLong())).thenReturn(review); + doNothing().when(reviewRepository).validateExists(anyLong()); when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(0); - // when & then - assertThatThrownBy( - () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus._REACTION_NOT_FOUND); + // when & then (์˜ˆ์™ธ ๋ฐœ์ƒ ์•ˆํ•จ) + manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction); + + verify(reviewReactionRepository).hardDelete(reviewId, MEMBER_ID, reaction.name()); } @Test @@ -207,8 +198,8 @@ void should_throw_exception_when_review_not_found() { Reaction reaction = Reaction.STAR; doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); - when(reviewRepository.getById(anyLong())) - .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); + doThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)) + .when(reviewRepository).validateExists(anyLong()); // when & then assertThatThrownBy( From 102a733f93b768b9668ed928d4c5e4eefd879ecb Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:23:20 +0900 Subject: [PATCH 044/126] =?UTF-8?q?[Refactor]=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20=EB=A1=A4=EB=B0=B1=EB=90=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EC=9E=AC=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManageReviewReactionService.java | 17 ++++------------- .../jpa/ReviewReactionRepository.java | 4 ---- .../infrastructure/jpa/ReviewRepository.java | 6 ++++++ 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java index 5ba2a402..f74de2da 100644 --- a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java @@ -1,8 +1,6 @@ package kr.spot.review.application.command; import kr.spot.IdGenerator; -import kr.spot.code.status.ErrorStatus; -import kr.spot.exception.GeneralException; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; @@ -24,11 +22,11 @@ public class ManageReviewReactionService { public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) { studyAccessValidator.validateStudyMember(studyId, memberId); - validateReviewExists(reviewId); + reviewRepository.validateExists(reviewId); if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId, reaction)) { - throw new GeneralException(ErrorStatus._ALREADY_REACTED); + return; } ReviewReaction reviewReaction = ReviewReaction.of( @@ -38,15 +36,8 @@ public void addReaction(long studyId, long reviewId, long memberId, Reaction rea public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) { studyAccessValidator.validateStudyMember(studyId, memberId); - validateReviewExists(reviewId); + reviewRepository.validateExists(reviewId); - int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); - if (deleted == 0) { - throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND); - } - } - - private void validateReviewExists(long reviewId) { - reviewRepository.getById(reviewId); + reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); } } diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java index c791345a..53d81390 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java @@ -1,6 +1,5 @@ package kr.spot.review.infrastructure.jpa; -import java.util.Optional; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,9 +9,6 @@ public interface ReviewReactionRepository extends JpaRepository { - Optional findByReviewIdAndMemberIdAndReaction( - Long reviewId, Long memberId, Reaction reaction); - boolean existsByReviewIdAndMemberIdAndReaction( Long reviewId, Long memberId, Reaction reaction); diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java index 191788db..866fdcfa 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java @@ -11,4 +11,10 @@ default Review getById(long reviewId) { return findById(reviewId).orElseThrow( () -> new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); } + + default void validateExists(long reviewId) { + if (!existsById(reviewId)) { + throw new GeneralException(ErrorStatus._REVIEW_NOT_FOUND); + } + } } From c20c1cc0fcceb7a338f6fb0827ffa50cb6281ddf Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:32:34 +0900 Subject: [PATCH 045/126] =?UTF-8?q?[Feature]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=8B=A0=EA=B3=A0=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManagePostService.java | 12 ++++++- .../src/main/java/kr/spot/domain/Report.java | 33 +++++++++++++++++++ .../infrastructure/jpa/PostRepository.java | 9 +++-- .../infrastructure/jpa/ReportRepository.java | 8 +++++ .../command/PostCommandController.java | 17 ++++++++++ .../dto/request/ReportPostRequest.java | 7 ++++ 6 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 modules/post/src/main/java/kr/spot/domain/Report.java create mode 100644 modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java create mode 100644 modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java diff --git a/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java b/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java index c23bb689..0f7d3a7f 100644 --- a/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java +++ b/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java @@ -4,17 +4,20 @@ import kr.spot.code.status.ErrorStatus; import kr.spot.domain.Post; import kr.spot.domain.PostStats; +import kr.spot.domain.Report; import kr.spot.domain.association.PostImage; import kr.spot.domain.vo.WriterInfo; import kr.spot.exception.GeneralException; import kr.spot.infrastructure.jpa.PostImageRepository; import kr.spot.infrastructure.jpa.PostRepository; import kr.spot.infrastructure.jpa.PostStatsRepository; +import kr.spot.infrastructure.jpa.ReportRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.GetWriterInfoPort; import kr.spot.ports.dto.UploadResult; import kr.spot.ports.dto.WriterInfoResponse; import kr.spot.presentation.command.dto.request.ManagePostRequest; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import kr.spot.presentation.command.dto.response.CreatePostResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,6 +37,7 @@ public class ManagePostService { private final PostRepository postRepository; private final PostStatsRepository postStatsRepository; private final PostImageRepository postImageRepository; + private final ReportRepository reportRepository; public CreatePostResponse createPost(ManagePostRequest request, Long writerId, MultipartFile imageFile) { @@ -62,6 +66,12 @@ public void deletePost(Long postId, Long writerId) { } } + public void reportPost(long postId, long reporterId, ReportPostRequest request) { + postRepository.validateExists(postId); + Report report = Report.of(idGenerator.nextId(), postId, reporterId, request.reason()); + reportRepository.save(report); + } + private WriterInfo getWriterInfo(Long writerId) { WriterInfoResponse writerInfoResponse = getWriterInfoPort.get(writerId); return WriterInfo.of(writerInfoResponse.writerId(), writerInfoResponse.nickname(), @@ -79,7 +89,7 @@ private void updatePostImage(Long postId, MultipartFile imageFiles) { if (imageFiles == null || imageFiles.isEmpty()) { return; } - + UploadResult upload = fileStoragePort.upload(imageFiles, FILE_DIR); postImageRepository.deleteByPostId(postId); PostImage postImage = PostImage.of(idGenerator.nextId(), postId, upload.url()); diff --git a/modules/post/src/main/java/kr/spot/domain/Report.java b/modules/post/src/main/java/kr/spot/domain/Report.java new file mode 100644 index 00000000..2aed4d92 --- /dev/null +++ b/modules/post/src/main/java/kr/spot/domain/Report.java @@ -0,0 +1,33 @@ +package kr.spot.domain; + + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE report SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Report extends BaseEntity { + + @Id + private Long id; + + private Long reportedPostId; + + private Long reporterId; + + private String reason; + + public static Report of(Long id, Long reportedPostId, Long reporterId, String reason) { + return new Report(id, reportedPostId, reporterId, reason); + } +} diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java index e78bff12..050a127c 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java @@ -19,9 +19,6 @@ public interface PostRepository extends JpaRepository { @Query("select p from Post p where p.id = :id") Optional findByIdWithLock(long id); - @Query("select p from Post p where p.postType = :postType order by p.id desc") - Optional findTopByPostTypeOrderByIdDesc(PostType postType); - @Modifying @Query(""" update Post p @@ -62,4 +59,10 @@ default Post getPostByIdWithLock(long id) { default Post getPostById(long id) { return findById(id).orElseThrow(() -> new GeneralException(ErrorStatus._POST_NOT_FOUND)); } + + default void validateExists(long postId) { + if (!existsById(postId)) { + throw new GeneralException(ErrorStatus._POST_NOT_FOUND); + } + } } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java new file mode 100644 index 00000000..73a9281a --- /dev/null +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java @@ -0,0 +1,8 @@ +package kr.spot.infrastructure.jpa; + +import kr.spot.domain.Report; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + +} diff --git a/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java b/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java index a551e088..384f64bc 100644 --- a/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java +++ b/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java @@ -13,6 +13,7 @@ import kr.spot.application.command.ManagePostService; import kr.spot.code.status.SuccessStatus; import kr.spot.presentation.command.dto.request.ManagePostRequest; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import kr.spot.presentation.command.dto.response.CreatePostResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; @@ -113,4 +115,19 @@ public ResponseEntity> unlikePost( likePostService.unlikePost(postId, writerId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); } + + @Operation(summary = "๊ฒŒ์‹œ๊ธ€ ์‹ ๊ณ ", description = "ํŠน์ • ๊ฒŒ์‹œ๊ธ€์„ ์‹ ๊ณ ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "์‹ ๊ณ  ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @PostMapping("/{postId}/report") + public ResponseEntity> reportPost( + @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long reporterId, + @RequestBody ReportPostRequest request) { + managePostService.reportPost(postId, reporterId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } } diff --git a/modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java b/modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java new file mode 100644 index 00000000..c3e12f5f --- /dev/null +++ b/modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java @@ -0,0 +1,7 @@ +package kr.spot.presentation.command.dto.request; + +public record ReportPostRequest( + String reason +) { + +} From a49a501f20e9200cf2e92baebdfe83e11e5c643b Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:35:54 +0900 Subject: [PATCH 046/126] =?UTF-8?q?[Feature]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=8B=A0=EA=B3=A0=20API=20=EA=B2=80=EC=A6=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ManagePostServiceImageTest.java | 7 +- .../command/ManagePostServiceTest.java | 95 ++++++++++++++++++- .../test/java/kr/spot/common/PostFixture.java | 19 ++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java index b22a6fc9..49530330 100644 --- a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java +++ b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java @@ -21,6 +21,7 @@ import kr.spot.infrastructure.jpa.PostImageRepository; import kr.spot.infrastructure.jpa.PostRepository; import kr.spot.infrastructure.jpa.PostStatsRepository; +import kr.spot.infrastructure.jpa.ReportRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.GetWriterInfoPort; import kr.spot.ports.dto.UploadResult; @@ -59,6 +60,9 @@ class ManagePostServiceImageTest { @Mock PostImageRepository postImageRepository; + @Mock + ReportRepository reportRepository; + @Captor ArgumentCaptor postCaptor; @@ -73,8 +77,7 @@ class ManagePostServiceImageTest { @BeforeEach void setUp() { managePostService = new ManagePostService(idGenerator, getWriterInfoPort, fileStoragePort, - postRepository, - postStatsRepository, postImageRepository); + postRepository, postStatsRepository, postImageRepository, reportRepository); } @Test diff --git a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java index da0da492..e6a3f37e 100644 --- a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java +++ b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java @@ -3,17 +3,27 @@ import static kr.spot.common.PostFixture.CONTENT; import static kr.spot.common.PostFixture.OTHER_WRITER_ID; import static kr.spot.common.PostFixture.POST_ID; +import static kr.spot.common.PostFixture.REPORTER_ID; +import static kr.spot.common.PostFixture.REPORT_REASON; import static kr.spot.common.PostFixture.TITLE; import static kr.spot.common.PostFixture.WRITER_ID; +import static kr.spot.common.PostFixture.reportPostRequest; import static kr.spot.common.PostFixture.writerInfo; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; import kr.spot.common.PostFixture; import kr.spot.domain.Post; +import kr.spot.domain.Report; import kr.spot.domain.enums.PostType; import kr.spot.domain.vo.WriterInfo; import kr.spot.exception.GeneralException; @@ -21,13 +31,18 @@ import kr.spot.infrastructure.jpa.PostLikeRepository; import kr.spot.infrastructure.jpa.PostRepository; import kr.spot.infrastructure.jpa.PostStatsRepository; +import kr.spot.infrastructure.jpa.ReportRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.GetWriterInfoPort; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -56,14 +71,19 @@ class ManagePostServiceTest { @Mock PostImageRepository postImageRepository; + @Mock + ReportRepository reportRepository; + + @Captor + ArgumentCaptor reportCaptor; + ManagePostService managePostService; LikePostService likePostService; @BeforeEach void setUp() { managePostService = new ManagePostService(idGenerator, getWriterInfoPort, fileStoragePort, - postRepository, - postStatsRepository, postImageRepository); + postRepository, postStatsRepository, postImageRepository, reportRepository); likePostService = new LikePostService(idGenerator, postLikeRepository, postStatsRepository); } @@ -138,4 +158,75 @@ void should_process_successfully_when_unliking_not_liked_post() { // when & then likePostService.unlikePost(POST_ID, WRITER_ID); } + + @Nested + @DisplayName("๊ฒŒ์‹œ๊ธ€ ์‹ ๊ณ  (reportPost)") + class ReportPost { + + @Test + @DisplayName("๊ฒŒ์‹œ๊ธ€์„ ์ •์ƒ์ ์œผ๋กœ ์‹ ๊ณ ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_report_post_successfully() { + // given + long generatedId = 100L; + ReportPostRequest request = reportPostRequest(); + + doNothing().when(postRepository).validateExists(anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId); + when(reportRepository.save(any(Report.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + managePostService.reportPost(POST_ID, REPORTER_ID, request); + + // then + verify(postRepository).validateExists(POST_ID); + verify(reportRepository).save(reportCaptor.capture()); + + Report capturedReport = reportCaptor.getValue(); + assertThat(capturedReport.getId()).isEqualTo(generatedId); + assertThat(capturedReport.getReportedPostId()).isEqualTo(POST_ID); + assertThat(capturedReport.getReporterId()).isEqualTo(REPORTER_ID); + assertThat(capturedReport.getReason()).isEqualTo(REPORT_REASON); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒŒ์‹œ๊ธ€์„ ์‹ ๊ณ ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void should_throw_exception_when_post_not_found() { + // given + long nonExistentPostId = 999L; + ReportPostRequest request = reportPostRequest(); + + doThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)) + .when(postRepository).validateExists(anyLong()); + + // when & then + assertThatThrownBy( + () -> managePostService.reportPost(nonExistentPostId, REPORTER_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); + + verify(reportRepository, never()).save(any()); + } + + @Test + @DisplayName("๋™์ผํ•œ ๊ฒŒ์‹œ๊ธ€์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์‹ ๊ณ ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_allow_multiple_reports_on_same_post() { + // given + long generatedId1 = 100L; + long generatedId2 = 101L; + ReportPostRequest request = reportPostRequest(); + + doNothing().when(postRepository).validateExists(anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId1, generatedId2); + when(reportRepository.save(any(Report.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + managePostService.reportPost(POST_ID, REPORTER_ID, request); + managePostService.reportPost(POST_ID, OTHER_WRITER_ID, request); + + // then + verify(reportRepository, org.mockito.Mockito.times(2)).save(any(Report.class)); + } + } } diff --git a/modules/post/src/test/java/kr/spot/common/PostFixture.java b/modules/post/src/test/java/kr/spot/common/PostFixture.java index a80df7b8..357efaaa 100644 --- a/modules/post/src/test/java/kr/spot/common/PostFixture.java +++ b/modules/post/src/test/java/kr/spot/common/PostFixture.java @@ -2,10 +2,12 @@ import kr.spot.domain.Post; import kr.spot.domain.PostStats; +import kr.spot.domain.Report; import kr.spot.domain.association.PostImage; import kr.spot.domain.enums.PostType; import kr.spot.domain.vo.WriterInfo; import kr.spot.presentation.command.dto.request.ManagePostRequest; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import org.springframework.test.util.ReflectionTestUtils; public class PostFixture { @@ -59,4 +61,21 @@ public static WriterInfo writerInfo() { public static ManagePostRequest updatePostRequest() { return new ManagePostRequest(UPDATED_TITLE, UPDATED_CONTENT, PostType.COUNSELING); } + + // Report ๊ด€๋ จ + public static Long REPORT_ID = 100L; + public static Long REPORTER_ID = 3L; + public static String REPORT_REASON = "๋ถ€์ ์ ˆํ•œ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค."; + + public static ReportPostRequest reportPostRequest() { + return new ReportPostRequest(REPORT_REASON); + } + + public static Report report() { + return Report.of(REPORT_ID, POST_ID, REPORTER_ID, REPORT_REASON); + } + + public static Report report(Long reportId, Long postId, Long reporterId) { + return Report.of(reportId, postId, reporterId, REPORT_REASON); + } } From 96a509315c5687fc4770d18db64a590e312325ae Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 14:45:08 +0900 Subject: [PATCH 047/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95=20-=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=EC=9E=A5=EC=9D=B4=20=EC=8A=B9=EC=9D=B8=20=EC=8B=9C,?= =?UTF-8?q?=20=EC=B5=9C=EC=A2=85=20=ED=95=A9=EB=A5=98=20-=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=B5=9C=EC=A2=85=20=EC=B0=B8=EC=97=AC=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EB=A1=9C=EC=A7=81=EC=9D=80=20Deprecated=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/spot/study/application/command/ApplyStudyService.java | 3 +++ .../java/kr/spot/study/domain/associations/StudyMember.java | 2 +- .../presentation/command/StudyApplyCommandController.java | 4 +++- .../src/test/java/kr/spot/study/domain/StudyMemberTest.java | 4 +++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java index df7136dc..f39672c2 100644 --- a/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java @@ -39,8 +39,11 @@ public void processStudyApplication( Study study = studyRepository.getStudyById(application.getStudyId()); study.processApplication(application, requesterId, decision); + + // ์•Œ๋ฆผ ์ „์†ก ๋กœ์ง ์ถ”๊ฐ€ } + @Deprecated(forRemoval = true) public void decideFinalParticipation( Long applicationId, Long requesterId, diff --git a/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java index 93b743c6..6b531f8e 100644 --- a/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java @@ -46,7 +46,7 @@ public static StudyMember apply(Long id, Long studyId, Long memberId, String mes public void decide(Decision decision) { if (decision == Decision.APPROVE) { - this.studyMemberStatus = StudyMemberStatus.AWAITING_SELF_APPROVAL; + this.studyMemberStatus = StudyMemberStatus.APPROVED; } else if (decision == Decision.REJECT) { this.studyMemberStatus = StudyMemberStatus.REJECTED; } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java index 9531b206..8d5b0421 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java @@ -54,7 +54,9 @@ public ResponseEntity> approveStudyApplication( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); } - @Operation(summary = "์Šคํ„ฐ๋”” ์ตœ์ข… ์ฐธ๊ฐ€ ์—ฌ๋ถ€ ๊ฒฐ์ •", description = "์Šน์ธ๋œ ์Šคํ„ฐ๋””์— ์ตœ์ข… ์ฐธ๊ฐ€ํ• ์ง€ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.") + // ๋น„ํ™œ์„ฑํ™” + @Deprecated + @Operation(summary = "[์‚ฌ์šฉ X] ์Šคํ„ฐ๋”” ์ตœ์ข… ์ฐธ๊ฐ€ ์—ฌ๋ถ€ ๊ฒฐ์ •", description = "์Šน์ธ๋œ ์Šคํ„ฐ๋””์— ์ตœ์ข… ์ฐธ๊ฐ€ํ• ์ง€ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "์Šคํ„ฐ๋”” ์ฐธ๊ฐ€ ์—ฌ๋ถ€ ๊ฒฐ์ • ์„ฑ๊ณต") }) diff --git a/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java b/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java index 33ad9fb3..2f16fc24 100644 --- a/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java +++ b/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java @@ -15,6 +15,7 @@ import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.Decision; import kr.spot.study.domain.enums.StudyMemberStatus; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -75,7 +76,7 @@ void should_change_to_awaiting_self_approval_when_approved() { // then assertThat(application.getStudyMemberStatus()) - .isEqualTo(StudyMemberStatus.AWAITING_SELF_APPROVAL); + .isEqualTo(StudyMemberStatus.APPROVED); } @Test @@ -93,6 +94,7 @@ void should_change_to_rejected_when_rejected() { } @Nested + @Disabled @DisplayName("์‹ ์ฒญ์ž์˜ ์ตœ์ข… ๊ฒฐ์ • (decideFinalByApplicant)") class DecideFinalByApplicant { From fbfea5e0c560065af4de2f542acb6752a0153cb2 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 16:04:01 +0900 Subject: [PATCH 048/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C,=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?-=20FCM=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=80=20=EC=B6=94=ED=9B=84=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=98=88=EC=A0=95=20-=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EB=84=88=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/boot/build.gradle | 1 + modules/notification/build.gradle | 4 + .../StudyApplicationNotificationListener.java | 66 +++++ .../kr/spot/domain/vo/NotificationTarget.java | 6 +- .../jpa/NotificationRepository.java | 8 + ...dyApplicationNotificationListenerTest.java | 92 ++++++ .../event/StudyApplicationProcessedEvent.java | 27 ++ .../command/ApplyStudyService.java | 14 +- .../command/ApplyStudyServiceTest.java | 277 ++++-------------- 9 files changed, 266 insertions(+), 229 deletions(-) create mode 100644 modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java create mode 100644 modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java create mode 100644 modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java create mode 100644 modules/study-api/src/main/java/kr/spot/event/StudyApplicationProcessedEvent.java diff --git a/app/boot/build.gradle b/app/boot/build.gradle index ff5eee51..d27cc9c5 100644 --- a/app/boot/build.gradle +++ b/app/boot/build.gradle @@ -18,6 +18,7 @@ dependencies { implementation project(":modules:member") implementation project(":modules:post") implementation project(":modules:region") + implementation project(":modules:notification") implementation project(":modules:member-api") implementation project(":modules:auth-api") diff --git a/modules/notification/build.gradle b/modules/notification/build.gradle index 3784339b..279ee047 100644 --- a/modules/notification/build.gradle +++ b/modules/notification/build.gradle @@ -8,8 +8,12 @@ dependencies { implementation project(":common:snowflake") implementation project(":modules:shared") + implementation project(":modules:study-api") implementation "org.springframework.boot:spring-boot-starter-data-jpa" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } diff --git a/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java b/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java new file mode 100644 index 00000000..4f24de92 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java @@ -0,0 +1,66 @@ +package kr.spot.application.event; + +import kr.spot.IdGenerator; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationType; +import kr.spot.domain.vo.Content; +import kr.spot.domain.vo.NotificationTarget; +import kr.spot.event.StudyApplicationProcessedEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyApplicationNotificationListener { + + private final IdGenerator idGenerator; + private final NotificationRepository notificationRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(StudyApplicationProcessedEvent event) { + try { + Notification notification = createNotification(event); + notificationRepository.save(notification); + log.info("Notification saved: studyId={}, applicantId={}, decision={}", + event.studyId(), event.applicantId(), event.decision()); + } catch (Exception e) { + log.error("Failed to save notification: {}", event, e); + } + } + + private Notification createNotification(StudyApplicationProcessedEvent event) { + Content content = Content.of( + buildTitle(event), + buildMessage(event), + event.studyThumbnailUrl() + ); + + NotificationTarget target = NotificationTarget.of( + event.applicantId(), + event.studyId(), + NotificationType.STUDY_APPLICATION_RESULT, + null + ); + + return Notification.of(idGenerator.nextId(), content, target); + } + + private String buildTitle(StudyApplicationProcessedEvent event) { + return event.studyName() + (event.isApproved() ? " ์‹ ์ฒญ์ด ์ˆ˜๋ฝ๋˜์—ˆ์–ด์š”!" : " ์‹ ์ฒญ์ด ๊ฑฐ์ ˆ๋˜์—ˆ์–ด์š”."); + } + + private String buildMessage(StudyApplicationProcessedEvent event) { + if (event.isApproved()) { + return String.format("'%s' ์Šคํ„ฐ๋”” ๊ฐ€์ž…์ด ์Šน์ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", event.studyName()); + } + return String.format("'%s' ์Šคํ„ฐ๋”” ๊ฐ€์ž…์ด ๊ฑฐ์ ˆ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", event.studyName()); + } +} diff --git a/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java b/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java index 73078bc6..9cfd95b8 100644 --- a/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java +++ b/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java @@ -24,8 +24,10 @@ public class NotificationTarget { @Enumerated(EnumType.STRING) private NotificationType notificationType; + private String token; + public static NotificationTarget of(Long targetMemberId, Long linkStudyId, - NotificationType notificationType) { - return new NotificationTarget(targetMemberId, linkStudyId, notificationType); + NotificationType notificationType, String token) { + return new NotificationTarget(targetMemberId, linkStudyId, notificationType, token); } } diff --git a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java new file mode 100644 index 00000000..fd7191eb --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java @@ -0,0 +1,8 @@ +package kr.spot.infrastructure.jpa; + +import kr.spot.domain.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { + +} diff --git a/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java b/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java new file mode 100644 index 00000000..a53aad1c --- /dev/null +++ b/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java @@ -0,0 +1,92 @@ +package kr.spot.application.event; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationType; +import kr.spot.event.StudyApplicationProcessedEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StudyApplicationNotificationListenerTest { + + @Mock + IdGenerator idGenerator; + + @Mock + NotificationRepository notificationRepository; + + @Captor + ArgumentCaptor notificationCaptor; + + StudyApplicationNotificationListener listener; + + @BeforeEach + void setUp() { + listener = new StudyApplicationNotificationListener(idGenerator, notificationRepository); + } + + @Test + @DisplayName("์Šน์ธ ์ด๋ฒคํŠธ ์ˆ˜์‹  ์‹œ ์•Œ๋ฆผ์ด ์ €์žฅ๋œ๋‹ค") + void should_save_notification_when_approved() { + // given + Long notificationId = 100L; + StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( + 1L, 2L, 3L, "APPROVE", "์ž๋ฐ” ์Šคํ„ฐ๋””", "http://image.url/java.png"); + + when(idGenerator.nextId()).thenReturn(notificationId); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + listener.handle(event); + + // then + verify(notificationRepository).save(notificationCaptor.capture()); + + Notification notification = notificationCaptor.getValue(); + assertThat(notification.getId()).isEqualTo(notificationId); + assertThat(notification.getContent().getTitle()).isEqualTo("์ž๋ฐ” ์Šคํ„ฐ๋”” ์‹ ์ฒญ์ด ์ˆ˜๋ฝ๋˜์—ˆ์–ด์š”!"); + assertThat(notification.getContent().getContent()).contains("์ž๋ฐ” ์Šคํ„ฐ๋””"); + assertThat(notification.getContent().getContent()).contains("์Šน์ธ"); + assertThat(notification.getTarget().getTargetMemberId()).isEqualTo(2L); + assertThat(notification.getTarget().getLinkStudyId()).isEqualTo(1L); + assertThat(notification.getTarget().getNotificationType()) + .isEqualTo(NotificationType.STUDY_APPLICATION_RESULT); + } + + @Test + @DisplayName("๊ฑฐ์ ˆ ์ด๋ฒคํŠธ ์ˆ˜์‹  ์‹œ ์•Œ๋ฆผ์ด ์ €์žฅ๋œ๋‹ค") + void should_save_notification_when_rejected() { + // given + Long notificationId = 100L; + StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( + 1L, 2L, 3L, "REJECT", "์ž๋ฐ” ์Šคํ„ฐ๋””", "http://image.url"); + + when(idGenerator.nextId()).thenReturn(notificationId); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + listener.handle(event); + + // then + verify(notificationRepository).save(notificationCaptor.capture()); + + Notification notification = notificationCaptor.getValue(); + assertThat(notification.getContent().getTitle()).isEqualTo("์ž๋ฐ” ์Šคํ„ฐ๋”” ์‹ ์ฒญ์ด ๊ฑฐ์ ˆ๋˜์—ˆ์–ด์š”."); + assertThat(notification.getContent().getContent()).contains("๊ฑฐ์ ˆ"); + } +} diff --git a/modules/study-api/src/main/java/kr/spot/event/StudyApplicationProcessedEvent.java b/modules/study-api/src/main/java/kr/spot/event/StudyApplicationProcessedEvent.java new file mode 100644 index 00000000..3ed33b7c --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/event/StudyApplicationProcessedEvent.java @@ -0,0 +1,27 @@ +package kr.spot.event; + +public record StudyApplicationProcessedEvent( + Long studyId, + Long applicantId, + Long processedBy, + String decision, + String studyName, + String studyThumbnailUrl +) { + + public static StudyApplicationProcessedEvent of( + Long studyId, + Long applicantId, + Long processedBy, + String decision, + String studyName, + String studyThumbnailUrl + ) { + return new StudyApplicationProcessedEvent( + studyId, applicantId, processedBy, decision, studyName, studyThumbnailUrl); + } + + public boolean isApproved() { + return "APPROVE".equals(decision); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java index f39672c2..aaa5fbde 100644 --- a/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java @@ -3,6 +3,7 @@ import java.util.List; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; +import kr.spot.event.StudyApplicationProcessedEvent; import kr.spot.exception.GeneralException; import kr.spot.study.domain.Study; import kr.spot.study.domain.associations.StudyMember; @@ -12,6 +13,7 @@ import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,6 +29,7 @@ public class ApplyStudyService { ); private final IdGenerator idGenerator; + private final ApplicationEventPublisher eventPublisher; private final StudyRepository studyRepository; private final StudyMemberRepository studyMemberRepository; @@ -40,7 +43,16 @@ public void processStudyApplication( study.processApplication(application, requesterId, decision); - // ์•Œ๋ฆผ ์ „์†ก ๋กœ์ง ์ถ”๊ฐ€ + eventPublisher.publishEvent( + StudyApplicationProcessedEvent.of( + study.getId(), + application.getMemberId(), + requesterId, + decision.name(), + study.getName(), + study.getImageUrl() + ) + ); } @Deprecated(forRemoval = true) diff --git a/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java index 6f1d3fde..42fc9671 100644 --- a/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java @@ -1,284 +1,109 @@ package kr.spot.study.application.command; -import static kr.spot.study.common.ApplyStudyRequestFixture.create; +import static kr.spot.study.common.StudyFixture.LEADER_ID; import static kr.spot.study.common.StudyFixture.study; +import static kr.spot.study.common.StudyMemberFixture.MEMBER_ID; +import static kr.spot.study.common.StudyMemberFixture.STUDY_ID; import static kr.spot.study.common.StudyMemberFixture.applied; -import static kr.spot.study.common.StudyMemberFixture.awaitingSelfApproval; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -import java.util.List; import kr.spot.IdGenerator; -import kr.spot.code.status.ErrorStatus; -import kr.spot.exception.GeneralException; -import kr.spot.study.common.ApplyStudyRequestFixture; -import kr.spot.study.common.StudyFixture; -import kr.spot.study.common.StudyMemberFixture; +import kr.spot.event.StudyApplicationProcessedEvent; import kr.spot.study.domain.Study; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.Decision; -import kr.spot.study.domain.enums.StudyMemberStatus; import kr.spot.study.infrastructure.jpa.StudyRepository; import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; -import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) -@DisplayName("ApplyStudyService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") class ApplyStudyServiceTest { - @InjectMocks - private ApplyStudyService applyStudyService; - @Mock - private IdGenerator idGenerator; + IdGenerator idGenerator; @Mock - private StudyRepository studyRepository; + ApplicationEventPublisher eventPublisher; @Mock - private StudyMemberRepository studyMemberRepository; - - @Nested - @DisplayName("์Šคํ„ฐ๋”” ์‹ ์ฒญ ์ฒ˜๋ฆฌ (processStudyApplication)") - class ProcessStudyApplication { - - private final Long applicationId = 1L; - private final Long studyId = StudyMemberFixture.STUDY_ID; - private final Long leaderId = StudyFixture.LEADER_ID; - - @Test - @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์‹ ์ฒญ์„ ์Šน์ธํ•˜๋ฉด AWAITING_SELF_APPROVAL ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") - void should_approve_application_when_leader_approves() { - // given - StudyMember application = applied(applicationId, studyId, 100L, "์ฐธ์—ฌํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค"); - Study study = study(); - - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); - given(studyRepository.getStudyById(studyId)).willReturn(study); - - // when - applyStudyService.processStudyApplication(applicationId, leaderId, Decision.APPROVE); - - // then - assertThat(application.getStudyMemberStatus()) - .isEqualTo(StudyMemberStatus.AWAITING_SELF_APPROVAL); - } - - @Test - @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์‹ ์ฒญ์„ ๊ฑฐ์ ˆํ•˜๋ฉด REJECTED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") - void should_reject_application_when_leader_rejects() { - // given - StudyMember application = applied(applicationId, studyId, 100L, "์ฐธ์—ฌํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค"); - Study study = study(); - - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); - given(studyRepository.getStudyById(studyId)).willReturn(study); + StudyRepository studyRepository; - // when - applyStudyService.processStudyApplication(applicationId, leaderId, Decision.REJECT); - - // then - assertThat(application.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.REJECTED); - } - - @Test - @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ฒ˜๋ฆฌํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void should_throw_exception_when_non_leader_processes() { - // given - StudyMember application = applied(applicationId, studyId, 100L, "์ฐธ์—ฌํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค"); - Study study = study(); - Long nonLeaderId = 999L; - - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); - given(studyRepository.getStudyById(studyId)).willReturn(study); - - // when & then - assertThatThrownBy( - () -> applyStudyService.processStudyApplication(applicationId, nonLeaderId, - Decision.APPROVE)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_LEADER_CAN_ACCESS); - } + @Mock + StudyMemberRepository studyMemberRepository; - @Test - @DisplayName("APPLIED ์ƒํƒœ๊ฐ€ ์•„๋‹Œ ์‹ ์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void should_throw_exception_when_not_applied_status() { - // given - StudyMember application = awaitingSelfApproval(applicationId, studyId, 100L); - Study study = study(); + @Captor + ArgumentCaptor eventCaptor; - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); - given(studyRepository.getStudyById(studyId)).willReturn(study); + ApplyStudyService applyStudyService; - // when & then - assertThatThrownBy( - () -> applyStudyService.processStudyApplication(applicationId, leaderId, - Decision.APPROVE)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus._NOT_PENDING_APPLICATION); - } + @BeforeEach + void setUp() { + applyStudyService = new ApplyStudyService( + idGenerator, eventPublisher, studyRepository, studyMemberRepository); } @Nested - @DisplayName("์ตœ์ข… ์ฐธ๊ฐ€ ๊ฒฐ์ • (decideFinalParticipation)") - class DecideFinalParticipation { - - private final Long applicationId = 1L; - private final Long studyId = StudyMemberFixture.STUDY_ID; - private final Long applicantId = StudyMemberFixture.MEMBER_ID; - - @Test - @DisplayName("์‹ ์ฒญ์ž๊ฐ€ ์ตœ์ข… ์Šน์ธํ•˜๋ฉด APPROVED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") - void should_approve_when_applicant_confirms() { - // given - StudyMember application = awaitingSelfApproval(applicationId, studyId, applicantId); - - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); - - // when - applyStudyService.decideFinalParticipation(applicationId, applicantId, Decision.APPROVE); - - // then - assertThat(application.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.APPROVED); - } + @DisplayName("์Šคํ„ฐ๋”” ์‹ ์ฒญ ์ฒ˜๋ฆฌ (processStudyApplication)") + class ProcessStudyApplication { @Test - @DisplayName("์‹ ์ฒญ์ž๊ฐ€ ์ตœ์ข… ๊ฑฐ์ ˆํ•˜๋ฉด SELF_REJECTED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") - void should_self_reject_when_applicant_rejects() { + @DisplayName("์‹ ์ฒญ ์Šน์ธ ์‹œ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœํ–‰๋œ๋‹ค") + void should_publish_event_when_application_approved() { // given - StudyMember application = awaitingSelfApproval(applicationId, studyId, applicantId); + Long applicationId = 1L; + StudyMember application = applied(applicationId, STUDY_ID, MEMBER_ID, "์ฐธ์—ฌํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค"); + Study study = study(); - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); + when(studyMemberRepository.getStudyMemberById(anyLong())).thenReturn(application); + when(studyRepository.getStudyById(anyLong())).thenReturn(study); // when - applyStudyService.decideFinalParticipation(applicationId, applicantId, Decision.REJECT); + applyStudyService.processStudyApplication(applicationId, LEADER_ID, Decision.APPROVE); // then - assertThat(application.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.SELF_REJECTED); - } - - @Test - @DisplayName("์‹ ์ฒญ์ž๊ฐ€ ์•„๋‹Œ ์‚ฌ๋žŒ์ด ์ตœ์ข… ๊ฒฐ์ •ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void should_throw_exception_when_non_applicant_decides() { - // given - StudyMember application = awaitingSelfApproval(applicationId, studyId, applicantId); - Long otherMemberId = 999L; - - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); - - // when & then - assertThatThrownBy( - () -> applyStudyService.decideFinalParticipation(applicationId, otherMemberId, - Decision.APPROVE)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_APPLICANT_CAN_SELF_APPROVE); - } - - @Test - @DisplayName("AWAITING_SELF_APPROVAL ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void should_throw_exception_when_not_awaiting_self_approval() { - // given - StudyMember application = applied(applicationId, studyId, applicantId, "์ฐธ์—ฌํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค"); - - given(studyMemberRepository.getStudyMemberById(applicationId)).willReturn(application); - - // when & then - assertThatThrownBy( - () -> applyStudyService.decideFinalParticipation(applicationId, applicantId, - Decision.APPROVE)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", - ErrorStatus._INVALID_STUDY_MEMBER_STATUS_FOR_SELF_APPROVAL); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + StudyApplicationProcessedEvent event = eventCaptor.getValue(); + assertThat(event.studyId()).isEqualTo(study.getId()); + assertThat(event.applicantId()).isEqualTo(MEMBER_ID); + assertThat(event.processedBy()).isEqualTo(LEADER_ID); + assertThat(event.decision()).isEqualTo("APPROVE"); + assertThat(event.studyName()).isEqualTo(study.getName()); + assertThat(event.isApproved()).isTrue(); } - } - - @Nested - @DisplayName("์Šคํ„ฐ๋”” ์‹ ์ฒญ (applyStudy)") - class ApplyStudy { - - private final Long studyId = StudyFixture.ID; - private final Long memberId = 100L; - private final Long generatedId = 999L; @Test - @DisplayName("์‹ ์ฒญ ์ด๋ ฅ์ด ์—†์œผ๋ฉด ์‹ ์ฒญ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค") - void should_create_application_when_not_already_applied() { + @DisplayName("์‹ ์ฒญ ๊ฑฐ์ ˆ ์‹œ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœํ–‰๋œ๋‹ค") + void should_publish_event_when_application_rejected() { // given + Long applicationId = 1L; + StudyMember application = applied(applicationId, STUDY_ID, MEMBER_ID, "์ฐธ์—ฌํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค"); Study study = study(); - ApplyStudyRequest request = create(); - given(studyRepository.getStudyById(studyId)).willReturn(study); - given(studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn( - eq(studyId), eq(memberId), any())).willReturn(false); - given(idGenerator.nextId()).willReturn(generatedId); + when(studyMemberRepository.getStudyMemberById(anyLong())).thenReturn(application); + when(studyRepository.getStudyById(anyLong())).thenReturn(study); // when - applyStudyService.applyStudy(studyId, memberId, request); + applyStudyService.processStudyApplication(applicationId, LEADER_ID, Decision.REJECT); // then - ArgumentCaptor captor = ArgumentCaptor.forClass(StudyMember.class); - verify(studyMemberRepository).save(captor.capture()); - - StudyMember savedApplication = captor.getValue(); - assertThat(savedApplication.getId()).isEqualTo(generatedId); - assertThat(savedApplication.getStudyId()).isEqualTo(studyId); - assertThat(savedApplication.getMemberId()).isEqualTo(memberId); - assertThat(savedApplication.getMessage()).isEqualTo(ApplyStudyRequestFixture.DEFAULT_MESSAGE); - assertThat(savedApplication.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.APPLIED); - } - - @Test - @DisplayName("์ด๋ฏธ ์‹ ์ฒญํ•œ ์Šคํ„ฐ๋””์— ๋‹ค์‹œ ์‹ ์ฒญํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void should_throw_exception_when_already_applied() { - // given - Study study = study(); - ApplyStudyRequest request = create(); - - given(studyRepository.getStudyById(studyId)).willReturn(study); - given(studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn( - eq(studyId), eq(memberId), any())).willReturn(true); - - // when & then - assertThatThrownBy(() -> applyStudyService.applyStudy(studyId, memberId, request)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ALREADY_APPLIED); - - verify(studyMemberRepository, never()).save(any()); - } - - @Test - @DisplayName("APPLIED, AWAITING_SELF_APPROVAL, APPROVED ์ƒํƒœ๊ฐ€ ์žˆ์œผ๋ฉด ์ค‘๋ณต ์‹ ์ฒญ์œผ๋กœ ๊ฐ„์ฃผํ•œ๋‹ค") - void should_check_active_application_statuses() { - // given - Study study = study(); - ApplyStudyRequest request = create(); - List activeStatuses = List.of( - StudyMemberStatus.APPLIED, - StudyMemberStatus.AWAITING_SELF_APPROVAL, - StudyMemberStatus.APPROVED - ); - - given(studyRepository.getStudyById(studyId)).willReturn(study); - given(studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn( - studyId, memberId, activeStatuses)).willReturn(true); + verify(eventPublisher).publishEvent(eventCaptor.capture()); - // when & then - assertThatThrownBy(() -> applyStudyService.applyStudy(studyId, memberId, request)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ALREADY_APPLIED); + StudyApplicationProcessedEvent event = eventCaptor.getValue(); + assertThat(event.decision()).isEqualTo("REJECT"); + assertThat(event.isApproved()).isFalse(); } } } From f0cd91d069241501e6dac01b87d7f8a55c6e60f2 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 16:08:21 +0900 Subject: [PATCH 049/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/src/test/java/kr/spot/study/domain/StudyTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java b/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java index f2a33bcf..1aa322b7 100644 --- a/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java +++ b/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java @@ -100,7 +100,7 @@ void should_throw_exception_when_max_members_is_less_than_one() { class ProcessApplication { @Test - @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์‹ ์ฒญ์„ ์Šน์ธํ•˜๋ฉด AWAITING_SELF_APPROVAL ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") + @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์‹ ์ฒญ์„ ์Šน์ธํ•˜๋ฉด APPROVE ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") void should_change_status_to_awaiting_self_approval_when_leader_approves() { // given Study study = study(); @@ -111,7 +111,7 @@ void should_change_status_to_awaiting_self_approval_when_leader_approves() { // then assertThat(application.getStudyMemberStatus()) - .isEqualTo(StudyMemberStatus.AWAITING_SELF_APPROVAL); + .isEqualTo(StudyMemberStatus.APPROVED); } @Test From 5b0888f47947d9abb40166d96b4b885afe4b29fb Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 21:56:00 +0900 Subject: [PATCH 050/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?-=20=EC=B6=9C=EC=84=9D=20=EC=B2=B4=ED=81=AC=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C,=20QR=20=EC=BD=94=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20-=20=EC=B6=9C=EC=84=9D=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=ED=8F=AC=ED=95=A8)=20-=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=ED=98=84=ED=99=A9=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 6 ++ modules/study/build.gradle | 4 + .../command/AttendanceCommandService.java | 88 +++++++++++++++++++ .../event/AttendanceQrCodeUploadListener.java | 55 ++++++++++++ .../event/AttendanceStartedEvent.java | 12 +++ .../query/GetAttendanceService.java | 29 ++++++ .../kr/spot/schedule/domain/Attendance.java | 44 ++++++++++ .../kr/spot/schedule/domain/Schedule.java | 47 +++++++++- .../domain/enums/AttendanceStatus.java | 7 ++ .../spot/schedule/domain/vo/MemberInfo.java | 26 ++++++ .../jpa/AttendanceRepository.java | 12 +++ .../qr/ByteArrayMultipartFile.java | 62 +++++++++++++ .../infrastructure/qr/QrCodeGenerator.java | 46 ++++++++++ .../command/AttendanceCommandController.java | 55 ++++++++++++ .../query/AttendanceQueryController.java | 48 ++++++++++ .../query/dto/GetAttendanceInfoResponse.java | 11 +++ .../query/dto/GetAttendanceListResponse.java | 39 ++++++++ 17 files changed, 589 insertions(+), 2 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index e7a8a4a3..c08036b7 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -66,6 +66,12 @@ public enum ErrorStatus implements BaseErrorCode { // ์ผ์ • ๊ด€๋ จ _SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "์ผ์ •์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), _SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "ํ•ด๋‹น ์Šคํ„ฐ๋””์— ์†ํ•˜๋Š” ์ผ์ •์ด ์•„๋‹™๋‹ˆ๋‹ค."), + _SCHEDULE_QR_CODE_ALREADY_ASSIGNED(400, "SCHEDULE4000", "์ด๋ฏธ ์ถœ์„ QR ์ฝ”๋“œ๊ฐ€ ํ• ๋‹น๋œ ์ผ์ •์ž…๋‹ˆ๋‹ค."), + + // ์ถœ์„ ๊ด€๋ จ + _ATTENDANCE_NOT_IN_SCHEDULE_TIME(400, "ATTENDANCE4000", "์ผ์ • ์‹œ๊ฐ„ ์™ธ์—๋Š” ์ถœ์„์ฒดํฌ๋ฅผ ์ง„ํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + _ATTENDANCE_NOT_STARTED(400, "ATTENDANCE4001", "์ถœ์„์ฒดํฌ๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."), + _ATTENDANCE_ALREADY_CHECKED(400, "ATTENDANCE4002", "์ด๋ฏธ ์ถœ์„์ฒดํฌ๋ฅผ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค."), // ํˆฌ๋‘ ๊ด€๋ จ _TODO_NOT_FOUND(404, "TODO404", "ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), diff --git a/modules/study/build.gradle b/modules/study/build.gradle index 2eaa92b6..d613c9fe 100644 --- a/modules/study/build.gradle +++ b/modules/study/build.gradle @@ -32,6 +32,10 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // QR Code + implementation "com.google.zxing:core:3.5.2" + implementation "com.google.zxing:javase:3.5.2" } def querydslDir = "$buildDir/generated/querydsl" diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java new file mode 100644 index 00000000..1d98af34 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -0,0 +1,88 @@ +package kr.spot.schedule.application.command; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.schedule.application.event.AttendanceStartedEvent; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AttendanceCommandService { + + private final IdGenerator idGenerator; + private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; + private final StudyAccessValidator studyAccessValidator; + private final GetMemberInfoPort getMemberInfoPort; + private final ApplicationEventPublisher eventPublisher; + + @Value("${app.base-url}") + private String baseUrl; + + public void startAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyLeader(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.startAttendance(studyId); + + String qrContent = generateQrContent(studyId, scheduleId); + eventPublisher.publishEvent(AttendanceStartedEvent.of(studyId, scheduleId, qrContent)); + } + + public void stopAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyLeader(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.stopAttendance(studyId); + } + + public void checkAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.validateAttendanceCheckable(); + + if (attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(scheduleId, memberId)) { + throw new GeneralException(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); + } + + MemberInfo memberInfo = getMemberInfo(memberId); + Attendance attendance = Attendance.of(idGenerator.nextId(), scheduleId, memberInfo); + attendance.markAttendance(AttendanceStatus.PRESENT); + attendanceRepository.save(attendance); + } + + private MemberInfo getMemberInfo(long memberId) { + Map memberInfoMap = getMemberInfoPort.getMemberInfo( + List.of(memberId)); + MemberInfoResponse info = memberInfoMap.get(memberId); + if (info == null) { + throw new GeneralException(ErrorStatus._MEMBER_NOT_FOUND); + } + return MemberInfo.of(memberId, info.name(), info.profileImageUrl()); + } + + private String generateQrContent(long studyId, long scheduleId) { + String token = UUID.randomUUID().toString(); + return String.format("%s/api/studies/%d/schedules/%d/attendance/check?token=%s", + baseUrl, studyId, scheduleId, token); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java new file mode 100644 index 00000000..3bc6750f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java @@ -0,0 +1,55 @@ +package kr.spot.schedule.application.event; + +import kr.spot.ports.FileStoragePort; +import kr.spot.ports.dto.UploadResult; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.infrastructure.qr.ByteArrayMultipartFile; +import kr.spot.schedule.infrastructure.qr.QrCodeGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AttendanceQrCodeUploadListener { + + private static final String FILE_DIR = "attendance/qr/"; + private static final String CONTENT_TYPE = "image/png"; + + private final QrCodeGenerator qrCodeGenerator; + private final FileStoragePort fileStoragePort; + private final ScheduleRepository scheduleRepository; + + @Async("imageUploadExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleAttendanceStarted(AttendanceStartedEvent event) { + try { + byte[] qrCodeImage = qrCodeGenerator.generate(event.qrContent()); + String filename = generateFilename(event.studyId(), event.scheduleId()); + MultipartFile multipartFile = new ByteArrayMultipartFile(qrCodeImage, filename, CONTENT_TYPE); + + UploadResult result = fileStoragePort.upload(multipartFile, FILE_DIR); + + Schedule schedule = scheduleRepository.getById(event.scheduleId()); + schedule.updateQrCodeImageUrl(result.url()); + + log.info("Successfully uploaded QR code image for schedule: {}", event.scheduleId()); + } catch (Exception e) { + log.error("Failed to upload QR code image for schedule: {}", event.scheduleId(), e); + } + } + + private String generateFilename(long studyId, long scheduleId) { + return String.format("study_%d_schedule_%d_%d.png", studyId, scheduleId, + System.currentTimeMillis()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java new file mode 100644 index 00000000..e1a70fb8 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java @@ -0,0 +1,12 @@ +package kr.spot.schedule.application.event; + +public record AttendanceStartedEvent( + long studyId, + long scheduleId, + String qrContent +) { + + public static AttendanceStartedEvent of(long studyId, long scheduleId, String qrContent) { + return new AttendanceStartedEvent(studyId, scheduleId, qrContent); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java new file mode 100644 index 00000000..1fed46d8 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java @@ -0,0 +1,29 @@ +package kr.spot.schedule.application.query; + +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetAttendanceService { + + private final ScheduleRepository scheduleRepository; + private final StudyAccessValidator studyAccessValidator; + + public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long scheduleId, + long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + return GetAttendanceInfoResponse.of( + schedule.isAttendanceActive(), + schedule.getAttendanceQrCodeImageUrl() + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java new file mode 100644 index 00000000..baa4a486 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java @@ -0,0 +1,44 @@ +package kr.spot.schedule.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import kr.spot.domain.BaseEntity; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE attendance SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Attendance extends BaseEntity { + + @Id + private Long id; + + private Long scheduleId; + + private MemberInfo memberInfo; + + private AttendanceStatus attendanceStatus; + + private LocalDateTime attendedAt; + + public static Attendance of(Long id, Long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, + LocalDateTime.now()); + } + + public void markAttendance(AttendanceStatus status) { + this.attendanceStatus = status; + this.attendedAt = LocalDateTime.now(); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java index 85a7ccc6..fda2d535 100644 --- a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java @@ -36,11 +36,14 @@ public class Schedule extends BaseEntity { private LocalDateTime endAt; + private boolean attendanceActive; + + private String attendanceQrCodeImageUrl; + public static Schedule of(Long id, Long studyId, String title, String locationMemo, LocalDateTime startAt, LocalDateTime endAt ) { - return new Schedule(id, studyId, title, locationMemo, startAt, endAt - ); + return new Schedule(id, studyId, title, locationMemo, startAt, endAt, false, null); } public void delete(long studyId) { @@ -55,9 +58,49 @@ public boolean isOngoing(LocalDateTime now) { return !now.isBefore(startAt) && !now.isAfter(endAt); } + public void startAttendance(long studyId) { + validateIsValidAccess(studyId); + validateIsOngoing(); + validateIsNotAttendanceActive(); + this.attendanceActive = true; + } + + public void stopAttendance(long studyId) { + validateIsValidAccess(studyId); + this.attendanceActive = false; + this.attendanceQrCodeImageUrl = null; + } + + public void updateQrCodeImageUrl(String url) { + this.attendanceQrCodeImageUrl = url; + } + + public void validateAttendanceCheckable() { + validateIsAttendanceActive(); + validateIsOngoing(); + } + private void validateIsValidAccess(long studyId) { if (studyId != this.studyId) { throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED); } } + + private void validateIsOngoing() { + if (!isOngoing(LocalDateTime.now())) { + throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } + + private void validateIsAttendanceActive() { + if (!this.attendanceActive) { + throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + } + + private void validateIsNotAttendanceActive() { + if (this.attendanceActive) { + throw new GeneralException(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java new file mode 100644 index 00000000..b17fd2cd --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java @@ -0,0 +1,7 @@ +package kr.spot.schedule.domain.enums; + +public enum AttendanceStatus { + PRESENT, // ์ถœ์„ + ABSENT, // ๊ฒฐ์„ + UNDECIDED // ๋ฏธ๊ฒฐ +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java new file mode 100644 index 00000000..128adf2b --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java @@ -0,0 +1,26 @@ +package kr.spot.schedule.domain.vo; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberInfo { + + private Long memberId; + + private String memberName; + + private String memberProfileImageUrl; + + public static MemberInfo of(Long memberId, String memberName, String memberProfileImageUrl) { + return new MemberInfo(memberId, memberName, memberProfileImageUrl); + } +} \ No newline at end of file diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java new file mode 100644 index 00000000..4cb399d2 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java @@ -0,0 +1,12 @@ +package kr.spot.schedule.infrastructure.jpa; + +import java.util.Optional; +import kr.spot.schedule.domain.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRepository extends JpaRepository { + + Optional findByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + boolean existsByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java new file mode 100644 index 00000000..49eba323 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java @@ -0,0 +1,62 @@ +package kr.spot.schedule.infrastructure.qr; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import org.springframework.web.multipart.MultipartFile; + +public class ByteArrayMultipartFile implements MultipartFile { + + private final byte[] content; + private final String name; + private final String originalFilename; + private final String contentType; + + public ByteArrayMultipartFile(byte[] content, String filename, String contentType) { + this.content = content; + this.name = filename; + this.originalFilename = filename; + this.contentType = contentType; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() throws IOException { + return content; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + throw new UnsupportedOperationException("transferTo is not supported"); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java new file mode 100644 index 00000000..7806b789 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java @@ -0,0 +1,46 @@ +package kr.spot.schedule.infrastructure.qr; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.stereotype.Component; + +@Component +public class QrCodeGenerator { + + private static final int DEFAULT_WIDTH = 300; + private static final int DEFAULT_HEIGHT = 300; + private static final String IMAGE_FORMAT = "PNG"; + + public byte[] generate(String content) { + return generate(content, DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + public byte[] generate(String content, int width, int height) { + try { + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + Map hints = Map.of( + EncodeHintType.CHARACTER_SET, "UTF-8", + EncodeHintType.MARGIN, 1 + ); + + BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, width, height, + hints); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(bitMatrix, IMAGE_FORMAT, outputStream); + + return outputStream.toByteArray(); + } catch (WriterException | IOException e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java new file mode 100644 index 00000000..f62be07d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -0,0 +1,55 @@ +package kr.spot.schedule.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.command.AttendanceCommandService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ • - ์ถœ์„์ฒดํฌ") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceCommandController { + + private final AttendanceCommandService attendanceCommandService; + + @Operation(summary = "์ถœ์„์ฒดํฌ ์‹œ์ž‘", description = "์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. QR ์ฝ”๋“œ ์ด๋ฏธ์ง€๋Š” ๋น„๋™๊ธฐ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.") + @PostMapping + public ResponseEntity> startAttendanceCheck( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.startAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "์ถœ์„์ฒดํฌ ์ข…๋ฃŒ", description = "์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.") + @DeleteMapping + public ResponseEntity> stopAttendanceCheck( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.stopAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "์ถœ์„์ฒดํฌ ์ฒ˜๋ฆฌ", description = "QR ์ฝ”๋“œ๋ฅผ ํ†ตํ•ด ์ถœ์„์ฒดํฌ๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.") + @PostMapping("/check") + public ResponseEntity> checkAttendance( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.checkAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java new file mode 100644 index 00000000..656090c3 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -0,0 +1,48 @@ +package kr.spot.schedule.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.query.GetAttendanceService; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ • - ์ถœ์„์ฒดํฌ") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceQueryController { + + private final GetAttendanceService getAttendanceService; + + @Operation(summary = "์ถœ์„ ๋ชฉ๋ก ์กฐํšŒ", description = "ํ•ด๋‹น ์ผ์ •์˜ ์ถœ์„ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping + public ResponseEntity> getAttendanceList( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } + + @Operation(summary = "์ถœ์„์ฒดํฌ QR ์ฝ”๋“œ ์กฐํšŒ", description = "์ถœ์„์ฒดํฌ์šฉ QR ์ฝ”๋“œ ์ด๋ฏธ์ง€ URL์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/qr") + public ResponseEntity> getAttendanceQrCode( + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + GetAttendanceInfoResponse response = getAttendanceService.getAttendanceQrCode(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java new file mode 100644 index 00000000..d4cb9c76 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java @@ -0,0 +1,11 @@ +package kr.spot.schedule.presentation.query.dto; + +public record GetAttendanceInfoResponse( + boolean attendanceActive, + String qrCodeImageUrl +) { + + public static GetAttendanceInfoResponse of(boolean attendanceActive, String qrCodeImageUrl) { + return new GetAttendanceInfoResponse(attendanceActive, qrCodeImageUrl); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java new file mode 100644 index 00000000..262e03ca --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java @@ -0,0 +1,39 @@ +package kr.spot.schedule.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record GetAttendanceListResponse( + List attendances, + int totalCount +) { + + public static GetAttendanceListResponse from(List attendances, + int totalCount) { + return new GetAttendanceListResponse(attendances, totalCount); + } + + public record AttendanceInfoResponse( + MemberInfoResponse member, + String attendanceStatus, + LocalDateTime attendedAt + ) { + + public static AttendanceInfoResponse from(MemberInfoResponse member, String attendanceStatus, + LocalDateTime attendedAt) { + return new AttendanceInfoResponse(member, attendanceStatus, attendedAt); + } + } + + public record MemberInfoResponse( + Long memberId, + String memberName, + String memberProfileImageUrl + ) { + + public static MemberInfoResponse from(Long memberId, String memberName, + String memberProfileImageUrl) { + return new MemberInfoResponse(memberId, memberName, memberProfileImageUrl); + } + } +} From a3246989c5d90a3ffaf18122f0d18cb1a1d5eddf Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:20:24 +0900 Subject: [PATCH 051/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/AttendanceCommandServiceTest.java | 276 ++++++++++++++++++ .../query/GetAttendanceServiceTest.java | 117 ++++++++ .../schedule/common/AttendanceFixture.java | 73 +++++ .../spot/schedule/domain/AttendanceTest.java | 82 ++++++ .../domain/ScheduleAttendanceTest.java | 218 ++++++++++++++ 5 files changed, 766 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java diff --git a/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java new file mode 100644 index 00000000..7c7a3f8d --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java @@ -0,0 +1,276 @@ +package kr.spot.schedule.application.command; + +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_NAME; +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_PROFILE_URL; +import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AttendanceCommandServiceTest { + + @Mock + IdGenerator idGenerator; + @Mock + ScheduleRepository scheduleRepository; + @Mock + AttendanceRepository attendanceRepository; + @Mock + StudyAccessValidator studyAccessValidator; + @Mock + GetMemberInfoPort getMemberInfoPort; + @Mock + ApplicationEventPublisher eventPublisher; + + AttendanceCommandService service; + + @BeforeEach + void setUp() { + service = new AttendanceCommandService( + idGenerator, + scheduleRepository, + attendanceRepository, + studyAccessValidator, + getMemberInfoPort, + eventPublisher + ); + ReflectionTestUtils.setField(service, "baseUrl", "https://spot.com"); + } + + @Nested + @DisplayName("์ถœ์„์ฒดํฌ ์‹œ์ž‘") + class StartAttendance { + + @Test + @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•˜๋ฉด ์ผ์ •์ด ์ถœ์„์ฒดํฌ ํ™œ์„ฑ ์ƒํƒœ๊ฐ€ ๋œ๋‹ค") + void should_activateAttendance_when_leaderStartsAttendance() { + // given + Schedule schedule = ongoingSchedule(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(schedule.isAttendanceActive()).isTrue(); + verify(eventPublisher).publishEvent(any(Object.class)); + } + + @Test + @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์•„๋‹ˆ๋ฉด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_notStudyLeader() { + // given + doThrow(new GeneralException(ErrorStatus._ONLY_LEADER_CAN_ACCESS)) + .when(studyAccessValidator).validateStudyLeader(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ONLY_LEADER_CAN_ACCESS); + } + + @Test + @DisplayName("์ด๋ฏธ ์ถœ์„์ฒดํฌ๊ฐ€ ์ง„ํ–‰ ์ค‘์ด๋ฉด ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void should_throwException_when_attendanceAlreadyActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + + @Test + @DisplayName("์ผ์ • ์‹œ๊ฐ„์ด ์•„๋‹ˆ๋ฉด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_scheduleNotOngoing() { + // given + Schedule schedule = pastSchedule(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } + + @Nested + @DisplayName("์ถœ์„์ฒดํฌ ์ข…๋ฃŒ") + class StopAttendance { + + @Test + @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์ข…๋ฃŒํ•˜๋ฉด ๋น„ํ™œ์„ฑ ์ƒํƒœ๊ฐ€ ๋œ๋‹ค") + void should_deactivateAttendance_when_leaderStopsAttendance() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + service.stopAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(schedule.isAttendanceActive()).isFalse(); + assertThat(schedule.getAttendanceQrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์•„๋‹ˆ๋ฉด ์ถœ์„์ฒดํฌ๋ฅผ ์ข…๋ฃŒํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_notStudyLeader() { + // given + doThrow(new GeneralException(ErrorStatus._ONLY_LEADER_CAN_ACCESS)) + .when(studyAccessValidator).validateStudyLeader(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.stopAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ONLY_LEADER_CAN_ACCESS); + } + } + + @Nested + @DisplayName("์ถœ์„์ฒดํฌ ์ฒ˜๋ฆฌ") + class CheckAttendance { + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์ถœ์„์ฒดํฌ๋ฅผ ํ•˜๋ฉด ์ถœ์„ ๊ธฐ๋ก์ด ์ €์žฅ๋œ๋‹ค") + void should_saveAttendance_when_memberChecksIn() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + MemberInfoResponse memberInfo = new MemberInfoResponse(MEMBER_NAME, MEMBER_PROFILE_URL); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(false); + when(getMemberInfoPort.getMemberInfo(any())).thenReturn(Map.of(MEMBER_ID, memberInfo)); + when(idGenerator.nextId()).thenReturn(200L); + + // when + service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(Attendance.class); + verify(attendanceRepository).save(captor.capture()); + + Attendance saved = captor.getValue(); + assertThat(saved.getScheduleId()).isEqualTo(SCHEDULE_ID); + assertThat(saved.getMemberInfo().getMemberId()).isEqualTo(MEMBER_ID); + assertThat(saved.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด ์ถœ์„์ฒดํฌ๋ฅผ ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("์ถœ์„์ฒดํฌ๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š์•˜์œผ๋ฉด ์ถœ์„ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_attendanceNotStarted() { + // given + Schedule schedule = ongoingSchedule(); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + + @Test + @DisplayName("์ด๋ฏธ ์ถœ์„์ฒดํฌ๋ฅผ ํ–ˆ์œผ๋ฉด ์ค‘๋ณต ์ถœ์„ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_alreadyCheckedIn() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(true); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); + } + + @Test + @DisplayName("์ผ์ • ์‹œ๊ฐ„์ด ์•„๋‹ˆ๋ฉด ์ถœ์„์ฒดํฌ๋ฅผ ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_scheduleTimeExpired() { + // given + Schedule schedule = pastSchedule(); + // ๊ณผ๊ฑฐ ์ผ์ •์ด์ง€๋งŒ ๊ฐ•์ œ๋กœ ์ถœ์„์ฒดํฌ ํ™œ์„ฑํ™” ์ƒํƒœ๋กœ ์„ค์ • (ํ…Œ์ŠคํŠธ์šฉ) + ReflectionTestUtils.setField(schedule, "attendanceActive", true); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java new file mode 100644 index 00000000..043ad95a --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java @@ -0,0 +1,117 @@ +package kr.spot.schedule.application.query; + +import static kr.spot.schedule.common.AttendanceFixture.MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; +import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class GetAttendanceServiceTest { + + @Mock + ScheduleRepository scheduleRepository; + @Mock + StudyAccessValidator studyAccessValidator; + + GetAttendanceService service; + + @BeforeEach + void setUp() { + service = new GetAttendanceService(scheduleRepository, studyAccessValidator); + } + + @Nested + @DisplayName("์ถœ์„์ฒดํฌ QR ์ฝ”๋“œ ์กฐํšŒ") + class GetAttendanceQrCode { + + @Test + @DisplayName("์ถœ์„์ฒดํฌ๊ฐ€ ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ QR ์ฝ”๋“œ URL์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_returnQrCodeUrl_when_attendanceActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + ReflectionTestUtils.setField(schedule, "attendanceQrCodeImageUrl", QR_CODE_URL); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + GetAttendanceInfoResponse response = service.getAttendanceQrCode( + STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.attendanceActive()).isTrue(); + assertThat(response.qrCodeImageUrl()).isEqualTo(QR_CODE_URL); + } + + @Test + @DisplayName("์ถœ์„์ฒดํฌ๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ isActive๊ฐ€ false์ด๋‹ค") + void should_returnInactive_when_attendanceNotActive() { + // given + Schedule schedule = ongoingSchedule(); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + + // when + GetAttendanceInfoResponse response = service.getAttendanceQrCode( + STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.attendanceActive()).isFalse(); + assertThat(response.qrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด QR ์ฝ”๋“œ๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy( + () -> service.getAttendanceQrCode(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ผ์ •์€ ์กฐํšŒํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_scheduleNotFound() { + // given + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(scheduleRepository.getById(SCHEDULE_ID)) + .thenThrow(new GeneralException(ErrorStatus._SCHEDULE_NOT_FOUND)); + + // when & then + assertThatThrownBy(() -> service.getAttendanceQrCode(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_NOT_FOUND); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java b/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java new file mode 100644 index 00000000..030b6335 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/common/AttendanceFixture.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.common; + +import java.time.LocalDateTime; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.vo.MemberInfo; + +public class AttendanceFixture { + + public static final long STUDY_ID = 1L; + public static final long SCHEDULE_ID = 100L; + public static final long ATTENDANCE_ID = 200L; + public static final long MEMBER_ID = 42L; + public static final long OTHER_MEMBER_ID = 99L; + + public static final String MEMBER_NAME = "ํ…Œ์Šคํ„ฐ"; + public static final String MEMBER_PROFILE_URL = "https://example.com/profile.jpg"; + public static final String QR_CODE_URL = "https://example.com/qr.png"; + + public static MemberInfo memberInfo() { + return MemberInfo.of(MEMBER_ID, MEMBER_NAME, MEMBER_PROFILE_URL); + } + + public static MemberInfo memberInfo(long memberId) { + return MemberInfo.of(memberId, MEMBER_NAME, MEMBER_PROFILE_URL); + } + + public static Attendance attendance() { + return Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + } + + public static Schedule ongoingSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "ํ…Œ์ŠคํŠธ ์ผ์ •", + "ํ…Œ์ŠคํŠธ ์žฅ์†Œ", + now.minusHours(1), + now.plusHours(1) + ); + } + + public static Schedule ongoingScheduleWithAttendance() { + Schedule schedule = ongoingSchedule(); + schedule.startAttendance(STUDY_ID); + return schedule; + } + + public static Schedule pastSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "์ง€๋‚œ ์ผ์ •", + "ํ…Œ์ŠคํŠธ ์žฅ์†Œ", + now.minusHours(3), + now.minusHours(1) + ); + } + + public static Schedule futureSchedule() { + LocalDateTime now = LocalDateTime.now(); + return Schedule.of( + SCHEDULE_ID, + STUDY_ID, + "๋ฏธ๋ž˜ ์ผ์ •", + "ํ…Œ์ŠคํŠธ ์žฅ์†Œ", + now.plusHours(1), + now.plusHours(3) + ); + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java new file mode 100644 index 00000000..2fdaef1b --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java @@ -0,0 +1,82 @@ +package kr.spot.schedule.domain; + +import static kr.spot.schedule.common.AttendanceFixture.ATTENDANCE_ID; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.memberInfo; +import static org.assertj.core.api.Assertions.assertThat; + +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AttendanceTest { + + @Nested + @DisplayName("์ถœ์„ ๊ธฐ๋ก ์ƒ์„ฑ") + class CreateAttendance { + + @Test + @DisplayName("์ถœ์„ ๊ธฐ๋ก ์ƒ์„ฑ ์‹œ ์ดˆ๊ธฐ ์ƒํƒœ๋Š” UNDECIDED์ด๋‹ค") + void should_createWithUndecidedStatus() { + // given + MemberInfo member = memberInfo(); + + // when + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, member); + + // then + assertThat(attendance.getId()).isEqualTo(ATTENDANCE_ID); + assertThat(attendance.getScheduleId()).isEqualTo(SCHEDULE_ID); + assertThat(attendance.getMemberInfo()).isEqualTo(member); + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); + assertThat(attendance.getAttendedAt()).isNotNull(); + } + } + + @Nested + @DisplayName("์ถœ์„ ์ƒํƒœ ๋ณ€๊ฒฝ") + class MarkAttendance { + + @Test + @DisplayName("์ถœ์„์œผ๋กœ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_markAsPresent() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + + // when + attendance.markAttendance(AttendanceStatus.PRESENT); + + // then + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + } + + @Test + @DisplayName("๊ฒฐ์„์œผ๋กœ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_markAsAbsent() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + + // when + attendance.markAttendance(AttendanceStatus.ABSENT); + + // then + assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.ABSENT); + } + + @Test + @DisplayName("์ถœ์„ ์ƒํƒœ ๋ณ€๊ฒฝ ์‹œ ์ถœ์„ ์‹œ๊ฐ„์ด ๊ฐฑ์‹ ๋œ๋‹ค") + void should_updateAttendedAt_when_markAttendance() { + // given + Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); + var originalTime = attendance.getAttendedAt(); + + // when + attendance.markAttendance(AttendanceStatus.PRESENT); + + // then + assertThat(attendance.getAttendedAt()).isAfterOrEqualTo(originalTime); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java new file mode 100644 index 00000000..b93220a5 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleAttendanceTest.java @@ -0,0 +1,218 @@ +package kr.spot.schedule.domain; + +import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; +import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; +import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.futureSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; +import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; +import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ScheduleAttendanceTest { + + @Nested + @DisplayName("์ถœ์„์ฒดํฌ ์‹œ์ž‘") + class StartAttendance { + + @Test + @DisplayName("์ง„ํ–‰ ์ค‘์ธ ์ผ์ •์—์„œ ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_startAttendance_when_scheduleIsOngoing() { + // given + Schedule schedule = ongoingSchedule(); + + // when + schedule.startAttendance(STUDY_ID); + + // then + assertThat(schedule.isAttendanceActive()).isTrue(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ์ผ์ •์—์„œ๋Š” ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_differentStudyId() { + // given + Schedule schedule = ongoingSchedule(); + long wrongStudyId = 999L; + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(wrongStudyId)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + + @Test + @DisplayName("์ผ์ • ์‹œ๊ฐ„ ์™ธ์—๋Š” ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_scheduleNotOngoing() { + // given + Schedule schedule = pastSchedule(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + + @Test + @DisplayName("๋ฏธ๋ž˜ ์ผ์ •์—์„œ๋Š” ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_scheduleInFuture() { + // given + Schedule schedule = futureSchedule(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + + @Test + @DisplayName("์ด๋ฏธ ์ถœ์„์ฒดํฌ๊ฐ€ ์ง„ํ–‰ ์ค‘์ด๋ฉด ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void should_throwException_when_attendanceAlreadyActive() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when & then + assertThatThrownBy(() -> schedule.startAttendance(STUDY_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + } + + @Nested + @DisplayName("์ถœ์„์ฒดํฌ ์ข…๋ฃŒ") + class StopAttendance { + + @Test + @DisplayName("์ถœ์„์ฒดํฌ๋ฅผ ์ข…๋ฃŒํ•˜๋ฉด ๋น„ํ™œ์„ฑ ์ƒํƒœ๊ฐ€ ๋œ๋‹ค") + void should_deactivateAttendance_when_stopped() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when + schedule.stopAttendance(STUDY_ID); + + // then + assertThat(schedule.isAttendanceActive()).isFalse(); + } + + @Test + @DisplayName("์ถœ์„์ฒดํฌ ์ข…๋ฃŒ ์‹œ QR ์ฝ”๋“œ URL์ด ์ดˆ๊ธฐํ™”๋œ๋‹ค") + void should_clearQrCodeUrl_when_stopped() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + schedule.updateQrCodeImageUrl(QR_CODE_URL); + + // when + schedule.stopAttendance(STUDY_ID); + + // then + assertThat(schedule.getAttendanceQrCodeImageUrl()).isNull(); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ์Šคํ„ฐ๋””์˜ ์ผ์ •์€ ์ถœ์„์ฒดํฌ๋ฅผ ์ข…๋ฃŒํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_differentStudyId() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + long wrongStudyId = 999L; + + // when & then + assertThatThrownBy(() -> schedule.stopAttendance(wrongStudyId)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + } + + @Nested + @DisplayName("์ถœ์„์ฒดํฌ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๊ฒ€์ฆ") + class ValidateAttendanceCheckable { + + @Test + @DisplayName("์ถœ์„์ฒดํฌ๊ฐ€ ํ™œ์„ฑํ™”๋˜๊ณ  ์ง„ํ–‰ ์ค‘์ธ ์ผ์ •์ด๋ฉด ๊ฒ€์ฆ์— ์„ฑ๊ณตํ•œ๋‹ค") + void should_passValidation_when_attendanceActiveAndOngoing() { + // given + Schedule schedule = ongoingScheduleWithAttendance(); + + // when & then (no exception thrown) + schedule.validateAttendanceCheckable(); + } + + @Test + @DisplayName("์ถœ์„์ฒดํฌ๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์œผ๋ฉด ๊ฒ€์ฆ์— ์‹คํŒจํ•œ๋‹ค") + void should_failValidation_when_attendanceNotActive() { + // given + Schedule schedule = ongoingSchedule(); + + // when & then + assertThatThrownBy(schedule::validateAttendanceCheckable) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + } + + @Nested + @DisplayName("QR ์ฝ”๋“œ URL ์—…๋ฐ์ดํŠธ") + class UpdateQrCodeImageUrl { + + @Test + @DisplayName("QR ์ฝ”๋“œ URL์„ ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ๋‹ค") + void should_updateQrCodeUrl() { + // given + Schedule schedule = ongoingSchedule(); + + // when + schedule.updateQrCodeImageUrl(QR_CODE_URL); + + // then + assertThat(schedule.getAttendanceQrCodeImageUrl()).isEqualTo(QR_CODE_URL); + } + } + + @Nested + @DisplayName("์ผ์ • ์ง„ํ–‰ ์ค‘ ํ™•์ธ") + class IsOngoing { + + @Test + @DisplayName("ํ˜„์žฌ ์‹œ๊ฐ„์ด ์ผ์ • ์‹œ๊ฐ„ ๋‚ด์— ์žˆ์œผ๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_returnTrue_when_currentTimeWithinSchedule() { + // given + Schedule schedule = ongoingSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isTrue(); + } + + @Test + @DisplayName("์ผ์ • ์‹œ๊ฐ„์ด ์ง€๋‚ฌ์œผ๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_returnFalse_when_scheduleEnded() { + // given + Schedule schedule = pastSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isFalse(); + } + + @Test + @DisplayName("์ผ์ • ์‹œ๊ฐ„ ์ „์ด๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_returnFalse_when_scheduleNotStarted() { + // given + Schedule schedule = futureSchedule(); + + // when & then + assertThat(schedule.isOngoing(java.time.LocalDateTime.now())).isFalse(); + } + } +} From 62cde207026ddf2708352bc887148dbdb25a2d96 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:43:50 +0900 Subject: [PATCH 052/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20-=20QR=EC=97=90=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EB=84=A3=EB=8A=94=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20=ED=86=A0=ED=81=B0=EC=97=90=EB=8A=94=20studyId?= =?UTF-8?q?=EC=99=80=20scheduleId=EB=A5=BC=20=EC=95=94=ED=98=B8=ED=99=94?= =?UTF-8?q?=20=ED=95=98=EC=97=AC=20=EC=A0=80=EC=9E=A5=20-=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=ED=86=A0=ED=81=B0=EC=9D=84=20=ED=99=9C=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=B6=9C=EC=84=9D=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 1 + .../command/AttendanceCommandService.java | 85 +++++++++++++------ .../query/GetAttendanceService.java | 28 ++++++ .../kr/spot/schedule/domain/Attendance.java | 12 ++- .../crypto/AttendanceTokenEncryptor.java | 73 ++++++++++++++++ .../jpa/AttendanceRepository.java | 3 + .../command/AttendanceCommandController.java | 4 +- .../query/AttendanceQueryController.java | 4 +- .../associations/StudyMemberRepository.java | 3 + 9 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index c08036b7..058033b3 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -72,6 +72,7 @@ public enum ErrorStatus implements BaseErrorCode { _ATTENDANCE_NOT_IN_SCHEDULE_TIME(400, "ATTENDANCE4000", "์ผ์ • ์‹œ๊ฐ„ ์™ธ์—๋Š” ์ถœ์„์ฒดํฌ๋ฅผ ์ง„ํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), _ATTENDANCE_NOT_STARTED(400, "ATTENDANCE4001", "์ถœ์„์ฒดํฌ๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."), _ATTENDANCE_ALREADY_CHECKED(400, "ATTENDANCE4002", "์ด๋ฏธ ์ถœ์„์ฒดํฌ๋ฅผ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค."), + _INVALID_ATTENDANCE_TOKEN(400, "ATTENDANCE4003", "์œ ํšจํ•˜์ง€ ์•Š์€ ์ถœ์„ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), // ํˆฌ๋‘ ๊ด€๋ จ _TODO_NOT_FOUND(404, "TODO404", "ํˆฌ๋‘๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java index 1d98af34..d007276d 100644 --- a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -1,8 +1,8 @@ package kr.spot.schedule.application.command; +import java.time.Instant; import java.util.List; import java.util.Map; -import java.util.UUID; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; @@ -13,11 +13,14 @@ import kr.spot.schedule.domain.Schedule; import kr.spot.schedule.domain.enums.AttendanceStatus; import kr.spot.schedule.domain.vo.MemberInfo; +import kr.spot.schedule.infrastructure.crypto.AttendanceTokenEncryptor; import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,15 +30,17 @@ @RequiredArgsConstructor public class AttendanceCommandService { + private static final List ACTIVE_MEMBER_STATUSES = + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED); + private final IdGenerator idGenerator; private final ScheduleRepository scheduleRepository; private final AttendanceRepository attendanceRepository; + private final StudyMemberRepository studyMemberRepository; private final StudyAccessValidator studyAccessValidator; private final GetMemberInfoPort getMemberInfoPort; private final ApplicationEventPublisher eventPublisher; - - @Value("${app.base-url}") - private String baseUrl; + private final AttendanceTokenEncryptor tokenEncryptor; public void startAttendance(long studyId, long scheduleId, long memberId) { studyAccessValidator.validateStudyLeader(studyId, memberId); @@ -43,7 +48,9 @@ public void startAttendance(long studyId, long scheduleId, long memberId) { Schedule schedule = scheduleRepository.getById(scheduleId); schedule.startAttendance(studyId); - String qrContent = generateQrContent(studyId, scheduleId); + createAttendancesForAllMembers(studyId, scheduleId); + + String qrContent = generateEncryptedToken(studyId, scheduleId); eventPublisher.publishEvent(AttendanceStartedEvent.of(studyId, scheduleId, qrContent)); } @@ -54,35 +61,61 @@ public void stopAttendance(long studyId, long scheduleId, long memberId) { schedule.stopAttendance(studyId); } - public void checkAttendance(long studyId, long scheduleId, long memberId) { - studyAccessValidator.validateStudyMember(studyId, memberId); + public void checkAttendance(String encryptedToken, long memberId) { + AttendanceTokenData tokenData = decryptToken(encryptedToken); - Schedule schedule = scheduleRepository.getById(scheduleId); + studyAccessValidator.validateStudyMember(tokenData.studyId(), memberId); + + Schedule schedule = scheduleRepository.getById(tokenData.scheduleId()); schedule.validateAttendanceCheckable(); - if (attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(scheduleId, memberId)) { - throw new GeneralException(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); - } + Attendance attendance = attendanceRepository + .findByScheduleIdAndMemberInfoMemberId(tokenData.scheduleId(), memberId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); - MemberInfo memberInfo = getMemberInfo(memberId); - Attendance attendance = Attendance.of(idGenerator.nextId(), scheduleId, memberInfo); attendance.markAttendance(AttendanceStatus.PRESENT); - attendanceRepository.save(attendance); } - private MemberInfo getMemberInfo(long memberId) { - Map memberInfoMap = getMemberInfoPort.getMemberInfo( - List.of(memberId)); - MemberInfoResponse info = memberInfoMap.get(memberId); - if (info == null) { - throw new GeneralException(ErrorStatus._MEMBER_NOT_FOUND); + private void createAttendancesForAllMembers(long studyId, long scheduleId) { + List activeMembers = studyMemberRepository + .findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_MEMBER_STATUSES); + + List memberIds = activeMembers.stream() + .map(StudyMember::getMemberId) + .toList(); + + Map memberInfoMap = getMemberInfoPort.getMemberInfo(memberIds); + + List attendances = activeMembers.stream() + .map(studyMember -> { + MemberInfoResponse info = memberInfoMap.get(studyMember.getMemberId()); + MemberInfo memberInfo = MemberInfo.of( + studyMember.getMemberId(), + info.name(), + info.profileImageUrl() + ); + return Attendance.createPending(idGenerator.nextId(), scheduleId, memberInfo); + }) + .toList(); + + attendanceRepository.saveAll(attendances); + } + + private String generateEncryptedToken(long studyId, long scheduleId) { + String plainText = String.format("%d:%d:%d", studyId, scheduleId, Instant.now().toEpochMilli()); + return tokenEncryptor.encrypt(plainText); + } + + private AttendanceTokenData decryptToken(String encryptedToken) { + String plainText = tokenEncryptor.decrypt(encryptedToken); + String[] parts = plainText.split(":"); + if (parts.length != 3) { + throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN); } - return MemberInfo.of(memberId, info.name(), info.profileImageUrl()); + return new AttendanceTokenData(Long.parseLong(parts[0]), Long.parseLong(parts[1])); } - private String generateQrContent(long studyId, long scheduleId) { - String token = UUID.randomUUID().toString(); - return String.format("%s/api/studies/%d/schedules/%d/attendance/check?token=%s", - baseUrl, studyId, scheduleId, token); + private record AttendanceTokenData(long studyId, long scheduleId) { + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java index 1fed46d8..e944aaf3 100644 --- a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java @@ -1,8 +1,14 @@ package kr.spot.schedule.application.query; +import java.util.List; +import kr.spot.schedule.domain.Attendance; import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.AttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.MemberInfoResponse; import kr.spot.study.application.validator.StudyAccessValidator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,6 +20,7 @@ public class GetAttendanceService { private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; private final StudyAccessValidator studyAccessValidator; public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long scheduleId, @@ -26,4 +33,25 @@ public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long schedule schedule.getAttendanceQrCodeImageUrl() ); } + + public GetAttendanceListResponse getAttendanceList(long studyId, long scheduleId, + long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + List attendances = attendanceRepository.findAllByScheduleId(scheduleId); + + List attendanceInfos = attendances.stream() + .map(attendance -> AttendanceInfoResponse.from( + MemberInfoResponse.from( + attendance.getMemberInfo().getMemberId(), + attendance.getMemberInfo().getMemberName(), + attendance.getMemberInfo().getMemberProfileImageUrl() + ), + attendance.getAttendanceStatus().name(), + attendance.getAttendedAt() + )) + .toList(); + + return GetAttendanceListResponse.from(attendanceInfos, attendanceInfos.size()); + } } diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java index baa4a486..7952019f 100644 --- a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java @@ -1,6 +1,8 @@ package kr.spot.schedule.domain; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import java.time.LocalDateTime; import kr.spot.domain.BaseEntity; @@ -28,13 +30,17 @@ public class Attendance extends BaseEntity { private MemberInfo memberInfo; + @Enumerated(EnumType.STRING) private AttendanceStatus attendanceStatus; private LocalDateTime attendedAt; - public static Attendance of(Long id, Long scheduleId, MemberInfo memberInfo) { - return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, - LocalDateTime.now()); + public static Attendance of(long id, long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null); + } + + public static Attendance createPending(long id, long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null); } public void markAttendance(AttendanceStatus status) { diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java new file mode 100644 index 00000000..ed387a7d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.infrastructure.crypto; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AttendanceTokenEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int GCM_IV_LENGTH = 12; + + private final SecretKeySpec secretKey; + + public AttendanceTokenEncryptor( + @Value("${app.attendance.encryption-key}") String encryptionKey) { + byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) { + throw new IllegalArgumentException("AES key must be 16, 24, or 32 bytes"); + } + this.secretKey = new SecretKeySpec(keyBytes, "AES"); + } + + public String encrypt(String plainText) { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + byte[] iv = generateIv(); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); + + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + encryptedBytes.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(combined); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } + + public String decrypt(String encryptedText) { + try { + byte[] combined = Base64.getUrlDecoder().decode(encryptedText); + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encryptedBytes = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(combined, GCM_IV_LENGTH, encryptedBytes, 0, encryptedBytes.length); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); + + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + } + + private byte[] generateIv() { + byte[] iv = new byte[GCM_IV_LENGTH]; + new java.security.SecureRandom().nextBytes(iv); + return iv; + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java index 4cb399d2..188b44e5 100644 --- a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java @@ -1,5 +1,6 @@ package kr.spot.schedule.infrastructure.jpa; +import java.util.List; import java.util.Optional; import kr.spot.schedule.domain.Attendance; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +10,6 @@ public interface AttendanceRepository extends JpaRepository { Optional findByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); boolean existsByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + List findAllByScheduleId(Long scheduleId); } diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java index f62be07d..65177caf 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "์Šคํ„ฐ๋”” ์ผ์ • - ์ถœ์„์ฒดํฌ") @@ -48,8 +49,9 @@ public ResponseEntity> stopAttendanceCheck( public ResponseEntity> checkAttendance( @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "์•”ํ˜ธํ™”๋œ ์ถœ์„ ํ† ํฐ", required = true) @RequestParam String token, @CurrentMember @Parameter(hidden = true) Long memberId) { - attendanceCommandService.checkAttendance(studyId, scheduleId, memberId); + attendanceCommandService.checkAttendance(token, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } } diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java index 656090c3..1620a3d5 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -31,7 +31,9 @@ public ResponseEntity> getAttendanceList( @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId ) { - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + GetAttendanceListResponse response = getAttendanceService.getAttendanceList(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } @Operation(summary = "์ถœ์„์ฒดํฌ QR ์ฝ”๋“œ ์กฐํšŒ", description = "์ถœ์„์ฒดํฌ์šฉ QR ์ฝ”๋“œ ์ด๋ฏธ์ง€ URL์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java index 76649a6f..15f826ec 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -17,4 +17,7 @@ default StudyMember getStudyMemberById(Long studyMemberId) { boolean existsByStudyIdAndMemberIdAndStudyMemberStatusIn(Long studyId, Long memberId, List studyMemberStatuses); + List findAllByStudyIdAndStudyMemberStatusIn(Long studyId, + List studyMemberStatuses); + } From 86ef197a618ff4e223cf1e162cf908835d3d1b0f Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:48:14 +0900 Subject: [PATCH 053/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EC=9E=A5=EC=9D=98=20=EA=B2=BD=EC=9A=B0,=20=EC=B6=9C=EC=84=9D?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91=20=EC=8B=9C=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/command/AttendanceCommandService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java index d007276d..ef6658ba 100644 --- a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -94,7 +94,12 @@ private void createAttendancesForAllMembers(long studyId, long scheduleId) { info.name(), info.profileImageUrl() ); - return Attendance.createPending(idGenerator.nextId(), scheduleId, memberInfo); + Attendance attendance = Attendance.createPending(idGenerator.nextId(), scheduleId, + memberInfo); + if (studyMember.getStudyMemberStatus() == StudyMemberStatus.OWNER) { + attendance.markAttendance(AttendanceStatus.PRESENT); + } + return attendance; }) .toList(); From e8c8ab724e9d5f54063af5500c46b59dae9e1b9c Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:48:35 +0900 Subject: [PATCH 054/126] =?UTF-8?q?[Feature]=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/AttendanceCommandServiceTest.java | 120 +++++++++++++----- .../query/GetAttendanceServiceTest.java | 67 +++++++++- 2 files changed, 156 insertions(+), 31 deletions(-) diff --git a/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java index 7c7a3f8d..5d18f3e8 100644 --- a/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java +++ b/modules/study/src/test/java/kr/spot/schedule/application/command/AttendanceCommandServiceTest.java @@ -6,19 +6,22 @@ import static kr.spot.schedule.common.AttendanceFixture.OTHER_MEMBER_ID; import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.attendance; import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; import static kr.spot.schedule.common.AttendanceFixture.pastSchedule; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; import java.util.Map; +import java.util.Optional; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; @@ -27,9 +30,13 @@ import kr.spot.schedule.domain.Attendance; import kr.spot.schedule.domain.Schedule; import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.infrastructure.crypto.AttendanceTokenEncryptor; import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -44,6 +51,9 @@ @ExtendWith(MockitoExtension.class) class AttendanceCommandServiceTest { + private static final String ENCRYPTED_TOKEN = "encrypted_token"; + private static final String DECRYPTED_TOKEN = STUDY_ID + ":" + SCHEDULE_ID + ":1234567890"; + @Mock IdGenerator idGenerator; @Mock @@ -51,11 +61,15 @@ class AttendanceCommandServiceTest { @Mock AttendanceRepository attendanceRepository; @Mock + StudyMemberRepository studyMemberRepository; + @Mock StudyAccessValidator studyAccessValidator; @Mock GetMemberInfoPort getMemberInfoPort; @Mock ApplicationEventPublisher eventPublisher; + @Mock + AttendanceTokenEncryptor tokenEncryptor; AttendanceCommandService service; @@ -65,11 +79,12 @@ void setUp() { idGenerator, scheduleRepository, attendanceRepository, + studyMemberRepository, studyAccessValidator, getMemberInfoPort, - eventPublisher + eventPublisher, + tokenEncryptor ); - ReflectionTestUtils.setField(service, "baseUrl", "https://spot.com"); } @Nested @@ -77,13 +92,23 @@ void setUp() { class StartAttendance { @Test - @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•˜๋ฉด ์ผ์ •์ด ์ถœ์„์ฒดํฌ ํ™œ์„ฑ ์ƒํƒœ๊ฐ€ ๋œ๋‹ค") - void should_activateAttendance_when_leaderStartsAttendance() { + @DisplayName("์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•˜๋ฉด ์ผ์ •์ด ์ถœ์„์ฒดํฌ ํ™œ์„ฑ ์ƒํƒœ๊ฐ€ ๋˜๊ณ  ๋ชจ๋“  ๋ฉค๋ฒ„์˜ ์ถœ์„ ๊ธฐ๋ก์ด ์ƒ์„ฑ๋œ๋‹ค") + void should_activateAttendanceAndCreateRecords_when_leaderStartsAttendance() { // given Schedule schedule = ongoingSchedule(); + StudyMember owner = StudyMember.create(1L, STUDY_ID, MEMBER_ID); + StudyMember member = createApprovedMember(2L, STUDY_ID, OTHER_MEMBER_ID); + MemberInfoResponse ownerInfo = new MemberInfoResponse(MEMBER_NAME, MEMBER_PROFILE_URL); + MemberInfoResponse memberInfo = new MemberInfoResponse("๋ฉค๋ฒ„", MEMBER_PROFILE_URL); doNothing().when(studyAccessValidator).validateStudyLeader(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); + when(studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn(any(), anyList())) + .thenReturn(List.of(owner, member)); + when(getMemberInfoPort.getMemberInfo(anyList())) + .thenReturn(Map.of(MEMBER_ID, ownerInfo, OTHER_MEMBER_ID, memberInfo)); + when(idGenerator.nextId()).thenReturn(100L, 101L); + when(tokenEncryptor.encrypt(any())).thenReturn(ENCRYPTED_TOKEN); // when service.startAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); @@ -91,6 +116,24 @@ void should_activateAttendance_when_leaderStartsAttendance() { // then assertThat(schedule.isAttendanceActive()).isTrue(); verify(eventPublisher).publishEvent(any(Object.class)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(attendanceRepository).saveAll(captor.capture()); + + List savedAttendances = captor.getValue(); + assertThat(savedAttendances).hasSize(2); + + Attendance ownerAttendance = savedAttendances.stream() + .filter(a -> a.getMemberInfo().getMemberId().equals(MEMBER_ID)) + .findFirst() + .orElseThrow(); + assertThat(ownerAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + + Attendance memberAttendance = savedAttendances.stream() + .filter(a -> a.getMemberInfo().getMemberId().equals(OTHER_MEMBER_ID)) + .findFirst() + .orElseThrow(); + assertThat(memberAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); } @Test @@ -181,41 +224,36 @@ void should_throwException_when_notStudyLeader() { class CheckAttendance { @Test - @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์ถœ์„์ฒดํฌ๋ฅผ ํ•˜๋ฉด ์ถœ์„ ๊ธฐ๋ก์ด ์ €์žฅ๋œ๋‹ค") - void should_saveAttendance_when_memberChecksIn() { + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ํ† ํฐ์œผ๋กœ ์ถœ์„์ฒดํฌ๋ฅผ ํ•˜๋ฉด ์ถœ์„ ์ƒํƒœ๊ฐ€ PRESENT๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค") + void should_markAttendancePresent_when_memberChecksInWithToken() { // given Schedule schedule = ongoingScheduleWithAttendance(); - MemberInfoResponse memberInfo = new MemberInfoResponse(MEMBER_NAME, MEMBER_PROFILE_URL); + Attendance existingAttendance = attendance(); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); - when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) - .thenReturn(false); - when(getMemberInfoPort.getMemberInfo(any())).thenReturn(Map.of(MEMBER_ID, memberInfo)); - when(idGenerator.nextId()).thenReturn(200L); + when(attendanceRepository.findByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(Optional.of(existingAttendance)); // when - service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID); // then - ArgumentCaptor captor = ArgumentCaptor.forClass(Attendance.class); - verify(attendanceRepository).save(captor.capture()); - - Attendance saved = captor.getValue(); - assertThat(saved.getScheduleId()).isEqualTo(SCHEDULE_ID); - assertThat(saved.getMemberInfo().getMemberId()).isEqualTo(MEMBER_ID); - assertThat(saved.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(existingAttendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.PRESENT); + assertThat(existingAttendance.getAttendedAt()).isNotNull(); } @Test @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด ์ถœ์„์ฒดํฌ๋ฅผ ํ•  ์ˆ˜ ์—†๋‹ค") void should_throwException_when_notStudyMember() { // given + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, OTHER_MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); @@ -227,32 +265,48 @@ void should_throwException_when_attendanceNotStarted() { // given Schedule schedule = ongoingSchedule(); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) .isEqualTo(ErrorStatus._ATTENDANCE_NOT_STARTED); } @Test - @DisplayName("์ด๋ฏธ ์ถœ์„์ฒดํฌ๋ฅผ ํ–ˆ์œผ๋ฉด ์ค‘๋ณต ์ถœ์„ํ•  ์ˆ˜ ์—†๋‹ค") - void should_throwException_when_alreadyCheckedIn() { + @DisplayName("์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ด๋ฉด ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void should_throwException_when_invalidToken() { + // given + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)) + .thenThrow(new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN)); + + // when & then + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + + @Test + @DisplayName("์ถœ์„ ๊ธฐ๋ก์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void should_throwException_when_attendanceRecordNotFound() { // given Schedule schedule = ongoingScheduleWithAttendance(); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); - when(attendanceRepository.existsByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) - .thenReturn(true); + when(attendanceRepository.findByScheduleIdAndMemberInfoMemberId(SCHEDULE_ID, MEMBER_ID)) + .thenReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) - .isEqualTo(ErrorStatus._ATTENDANCE_ALREADY_CHECKED); + .isEqualTo(ErrorStatus._STUDY_MEMBER_NOT_FOUND); } @Test @@ -260,17 +314,23 @@ void should_throwException_when_alreadyCheckedIn() { void should_throwException_when_scheduleTimeExpired() { // given Schedule schedule = pastSchedule(); - // ๊ณผ๊ฑฐ ์ผ์ •์ด์ง€๋งŒ ๊ฐ•์ œ๋กœ ์ถœ์„์ฒดํฌ ํ™œ์„ฑํ™” ์ƒํƒœ๋กœ ์„ค์ • (ํ…Œ์ŠคํŠธ์šฉ) ReflectionTestUtils.setField(schedule, "attendanceActive", true); + when(tokenEncryptor.decrypt(ENCRYPTED_TOKEN)).thenReturn(DECRYPTED_TOKEN); doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); when(scheduleRepository.getById(SCHEDULE_ID)).thenReturn(schedule); // when & then - assertThatThrownBy(() -> service.checkAttendance(STUDY_ID, SCHEDULE_ID, MEMBER_ID)) + assertThatThrownBy(() -> service.checkAttendance(ENCRYPTED_TOKEN, MEMBER_ID)) .isInstanceOf(GeneralException.class) .extracting(e -> ((GeneralException) e).getStatus()) .isEqualTo(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); } } + + private StudyMember createApprovedMember(long id, long studyId, long memberId) { + StudyMember member = StudyMember.apply(id, studyId, memberId, "ํ…Œ์ŠคํŠธ"); + ReflectionTestUtils.setField(member, "studyMemberStatus", StudyMemberStatus.APPROVED); + return member; + } } diff --git a/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java index 043ad95a..29ab87f6 100644 --- a/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java +++ b/modules/study/src/test/java/kr/spot/schedule/application/query/GetAttendanceServiceTest.java @@ -5,6 +5,8 @@ import static kr.spot.schedule.common.AttendanceFixture.QR_CODE_URL; import static kr.spot.schedule.common.AttendanceFixture.SCHEDULE_ID; import static kr.spot.schedule.common.AttendanceFixture.STUDY_ID; +import static kr.spot.schedule.common.AttendanceFixture.attendance; +import static kr.spot.schedule.common.AttendanceFixture.memberInfo; import static kr.spot.schedule.common.AttendanceFixture.ongoingSchedule; import static kr.spot.schedule.common.AttendanceFixture.ongoingScheduleWithAttendance; import static org.assertj.core.api.Assertions.assertThat; @@ -13,11 +15,16 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.when; +import java.util.List; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Attendance; import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; import kr.spot.study.application.validator.StudyAccessValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -34,13 +41,16 @@ class GetAttendanceServiceTest { @Mock ScheduleRepository scheduleRepository; @Mock + AttendanceRepository attendanceRepository; + @Mock StudyAccessValidator studyAccessValidator; GetAttendanceService service; @BeforeEach void setUp() { - service = new GetAttendanceService(scheduleRepository, studyAccessValidator); + service = new GetAttendanceService(scheduleRepository, attendanceRepository, + studyAccessValidator); } @Nested @@ -114,4 +124,59 @@ void should_throwException_when_scheduleNotFound() { .isEqualTo(ErrorStatus._SCHEDULE_NOT_FOUND); } } + + @Nested + @DisplayName("์ถœ์„ ๋ชฉ๋ก ์กฐํšŒ") + class GetAttendanceList { + + @Test + @DisplayName("์ผ์ •์˜ ์ถœ์„ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") + void should_returnAttendanceList_when_requested() { + // given + Attendance attendance1 = attendance(); + Attendance attendance2 = Attendance.createPending(201L, SCHEDULE_ID, memberInfo(OTHER_MEMBER_ID)); + attendance2.markAttendance(AttendanceStatus.PRESENT); + + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(attendanceRepository.findAllByScheduleId(SCHEDULE_ID)) + .thenReturn(List.of(attendance1, attendance2)); + + // when + GetAttendanceListResponse response = service.getAttendanceList(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.attendances()).hasSize(2); + } + + @Test + @DisplayName("์ถœ์„ ๊ธฐ๋ก์ด ์—†์œผ๋ฉด ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_returnEmptyList_when_noAttendanceRecords() { + // given + doNothing().when(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID); + when(attendanceRepository.findAllByScheduleId(SCHEDULE_ID)).thenReturn(List.of()); + + // when + GetAttendanceListResponse response = service.getAttendanceList(STUDY_ID, SCHEDULE_ID, MEMBER_ID); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.attendances()).isEmpty(); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด ์ถœ์„ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ์ˆ˜ ์—†๋‹ค") + void should_throwException_when_notStudyMember() { + // given + doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED)) + .when(studyAccessValidator).validateStudyMember(STUDY_ID, OTHER_MEMBER_ID); + + // when & then + assertThatThrownBy( + () -> service.getAttendanceList(STUDY_ID, SCHEDULE_ID, OTHER_MEMBER_ID)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getStatus()) + .isEqualTo(ErrorStatus._STUDY_ACCESS_DENIED); + } + } } From 0dbbf8073d364214b5763115200ee1e3cb2e6a72 Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 22:58:23 +0900 Subject: [PATCH 055/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EB=AA=85=EC=84=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/AttendanceCommandController.java | 130 +++++++++++++++-- .../query/AttendanceQueryController.java | 131 +++++++++++++++++- 2 files changed, 243 insertions(+), 18 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java index 65177caf..7cd8fedf 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -2,6 +2,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; @@ -16,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ • - ์ถœ์„์ฒดํฌ") +@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ • - ์ถœ์„์ฒดํฌ", description = "์Šคํ„ฐ๋”” ์ผ์ • ์ถœ์„์ฒดํฌ ๊ด€๋ จ API") @RestController @RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") @RequiredArgsConstructor @@ -24,32 +28,136 @@ public class AttendanceCommandController { private final AttendanceCommandService attendanceCommandService; - @Operation(summary = "์ถœ์„์ฒดํฌ ์‹œ์ž‘", description = "์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. QR ์ฝ”๋“œ ์ด๋ฏธ์ง€๋Š” ๋น„๋™๊ธฐ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "์ถœ์„์ฒดํฌ ์‹œ์ž‘", + description = """ + ์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. + + **๋™์ž‘:** + - ๋ชจ๋“  ์Šคํ„ฐ๋”” ๋ฉค๋ฒ„(OWNER, APPROVED)์— ๋Œ€ํ•ด ์ถœ์„ ๊ธฐ๋ก ์ƒ์„ฑ + - ์Šคํ„ฐ๋””์žฅ(OWNER)์€ ์ž๋™์œผ๋กœ ์ถœ์„(PRESENT) ์ฒ˜๋ฆฌ + - ์ผ๋ฐ˜ ๋ฉค๋ฒ„๋Š” ๋ฏธ์ •(UNDECIDED) ์ƒํƒœ๋กœ ์ƒ์„ฑ + - QR ์ฝ”๋“œ ์ด๋ฏธ์ง€๋Š” ๋น„๋™๊ธฐ๋กœ ์ƒ์„ฑ๋˜๋ฉฐ, `/qr` ์—”๋“œํฌ์ธํŠธ์—์„œ ์กฐํšŒ ๊ฐ€๋Šฅ + + **๊ถŒํ•œ:** ์Šคํ„ฐ๋””์žฅ๋งŒ ๊ฐ€๋Šฅ + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "์ถœ์„์ฒดํฌ ์‹œ์ž‘ ์„ฑ๊ณต" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "์ผ์ • ์‹œ๊ฐ„์ด ์•„๋‹˜ (ATTENDANCE4000) / ์ด๋ฏธ ์ถœ์„์ฒดํฌ ์ง„ํ–‰ ์ค‘ (SCHEDULE4000)", + content = @Content(examples = { + @ExampleObject(name = "์ผ์ • ์‹œ๊ฐ„ ์™ธ", value = """ + {"isSuccess": false, "code": "ATTENDANCE4000", "message": "์ผ์ • ์‹œ๊ฐ„ ์™ธ์—๋Š” ์ถœ์„์ฒดํฌ๋ฅผ ์ง„ํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."} + """), + @ExampleObject(name = "์ด๋ฏธ ์ง„ํ–‰ ์ค‘", value = """ + {"isSuccess": false, "code": "SCHEDULE4000", "message": "์ด๋ฏธ ์ถœ์„ QR ์ฝ”๋“œ๊ฐ€ ํ• ๋‹น๋œ ์ผ์ •์ž…๋‹ˆ๋‹ค."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "์Šคํ„ฐ๋””์žฅ์ด ์•„๋‹˜ (STUDY403)", + content = @Content(examples = @ExampleObject(value = """ + {"isSuccess": false, "code": "STUDY403", "message": "์Šคํ„ฐ๋””์žฅ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."} + """)) + ) + }) @PostMapping public ResponseEntity> startAttendanceCheck( - @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId) { attendanceCommandService.startAttendance(studyId, scheduleId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } - @Operation(summary = "์ถœ์„์ฒดํฌ ์ข…๋ฃŒ", description = "์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "์ถœ์„์ฒดํฌ ์ข…๋ฃŒ", + description = """ + ์Šคํ„ฐ๋””์žฅ์ด ์ถœ์„์ฒดํฌ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค. + + **๋™์ž‘:** + - ์ถœ์„์ฒดํฌ ๋น„ํ™œ์„ฑํ™” + - QR ์ฝ”๋“œ ์ด๋ฏธ์ง€ URL ์‚ญ์ œ + + **๊ถŒํ•œ:** ์Šคํ„ฐ๋””์žฅ๋งŒ ๊ฐ€๋Šฅ + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "์ถœ์„์ฒดํฌ ์ข…๋ฃŒ ์„ฑ๊ณต" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "์Šคํ„ฐ๋””์žฅ์ด ์•„๋‹˜ (STUDY403)" + ) + }) @DeleteMapping public ResponseEntity> stopAttendanceCheck( - @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId) { attendanceCommandService.stopAttendance(studyId, scheduleId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } - @Operation(summary = "์ถœ์„์ฒดํฌ ์ฒ˜๋ฆฌ", description = "QR ์ฝ”๋“œ๋ฅผ ํ†ตํ•ด ์ถœ์„์ฒดํฌ๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "์ถœ์„์ฒดํฌ ์ฒ˜๋ฆฌ (QR ์Šค์บ”)", + description = """ + QR ์ฝ”๋“œ๋ฅผ ์Šค์บ”ํ•˜์—ฌ ์ถœ์„์ฒดํฌ๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + + **๋™์ž‘:** + - ์•”ํ˜ธํ™”๋œ ํ† ํฐ์„ ๋ณตํ˜ธํ™”ํ•˜์—ฌ studyId, scheduleId ์ถ”์ถœ + - ํ•ด๋‹น ๋ฉค๋ฒ„์˜ ์ถœ์„ ์ƒํƒœ๋ฅผ PRESENT๋กœ ๋ณ€๊ฒฝ + + **QR ์ฝ”๋“œ ํ† ํฐ:** + - QR ์ฝ”๋“œ์—๋Š” ์•”ํ˜ธํ™”๋œ ํ† ํฐ ๋ฌธ์ž์—ด๋งŒ ๋‹ด๊ฒจ ์žˆ์Œ + - ์•ฑ์—์„œ QR ์Šค์บ” ํ›„ ํ•ด๋‹น ํ† ํฐ์„ ์ด API์˜ token ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌ + + **๊ถŒํ•œ:** ์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๋งŒ ๊ฐ€๋Šฅ + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "์ถœ์„ ์ฒ˜๋ฆฌ ์„ฑ๊ณต" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ (ATTENDANCE4003) / ์ถœ์„์ฒดํฌ ๋ฏธ์‹œ์ž‘ (ATTENDANCE4001) / ์ผ์ • ์‹œ๊ฐ„ ์™ธ (ATTENDANCE4000)", + content = @Content(examples = { + @ExampleObject(name = "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ", value = """ + {"isSuccess": false, "code": "ATTENDANCE4003", "message": "์œ ํšจํ•˜์ง€ ์•Š์€ ์ถœ์„ ํ† ํฐ์ž…๋‹ˆ๋‹ค."} + """), + @ExampleObject(name = "์ถœ์„์ฒดํฌ ๋ฏธ์‹œ์ž‘", value = """ + {"isSuccess": false, "code": "ATTENDANCE4001", "message": "์ถœ์„์ฒดํฌ๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹˜ (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "์ถœ์„ ๊ธฐ๋ก ์—†์Œ (STUDYMEMBER404)" + ) + }) @PostMapping("/check") public ResponseEntity> checkAttendance( - @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, - @Parameter(description = "์•”ํ˜ธํ™”๋œ ์ถœ์„ ํ† ํฐ", required = true) @RequestParam String token, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true, example = "100") @PathVariable Long scheduleId, + @Parameter( + description = "QR ์ฝ”๋“œ์—์„œ ์ฝ์€ ์•”ํ˜ธํ™”๋œ ์ถœ์„ ํ† ํฐ", + required = true, + example = "dK8x2mP9qL3nR7vT..." + ) @RequestParam String token, @CurrentMember @Parameter(hidden = true) Long memberId) { attendanceCommandService.checkAttendance(token, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java index 1620a3d5..cfe6bf63 100644 --- a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -2,6 +2,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; @@ -16,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ • - ์ถœ์„์ฒดํฌ") +@Tag(name = "์Šคํ„ฐ๋”” ์ผ์ • - ์ถœ์„์ฒดํฌ", description = "์Šคํ„ฐ๋”” ์ผ์ • ์ถœ์„์ฒดํฌ ๊ด€๋ จ API") @RestController @RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") @RequiredArgsConstructor @@ -24,11 +28,67 @@ public class AttendanceQueryController { private final GetAttendanceService getAttendanceService; - @Operation(summary = "์ถœ์„ ๋ชฉ๋ก ์กฐํšŒ", description = "ํ•ด๋‹น ์ผ์ •์˜ ์ถœ์„ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "์ถœ์„ ๋ชฉ๋ก ์กฐํšŒ", + description = """ + ํ•ด๋‹น ์ผ์ •์˜ ์ถœ์„ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + **์ถœ์„ ์ƒํƒœ (attendanceStatus):** + - `PRESENT`: ์ถœ์„ + - `ABSENT`: ๊ฒฐ์„ + - `UNDECIDED`: ๋ฏธ์ • (์•„์ง ์ถœ์„ ์•ˆ ํ•จ) + + **๊ถŒํ•œ:** ์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๋งŒ ๊ฐ€๋Šฅ + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "์ถœ์„ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceListResponse.class), + examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendances": [ + { + "member": { + "memberId": 1, + "memberName": "ํ™๊ธธ๋™", + "memberProfileImageUrl": "https://example.com/profile1.jpg" + }, + "attendanceStatus": "PRESENT", + "attendedAt": "2024-01-15T14:30:00" + }, + { + "member": { + "memberId": 2, + "memberName": "๊น€์ฒ ์ˆ˜", + "memberProfileImageUrl": "https://example.com/profile2.jpg" + }, + "attendanceStatus": "UNDECIDED", + "attendedAt": null + } + ], + "totalCount": 2 + } + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹˜ (STUDY4032)" + ) + }) @GetMapping public ResponseEntity> getAttendanceList( - @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId ) { GetAttendanceListResponse response = getAttendanceService.getAttendanceList(studyId, @@ -36,11 +96,68 @@ public ResponseEntity> getAttendanceList( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } - @Operation(summary = "์ถœ์„์ฒดํฌ QR ์ฝ”๋“œ ์กฐํšŒ", description = "์ถœ์„์ฒดํฌ์šฉ QR ์ฝ”๋“œ ์ด๋ฏธ์ง€ URL์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @Operation( + summary = "์ถœ์„์ฒดํฌ QR ์ฝ”๋“œ ์กฐํšŒ", + description = """ + ์ถœ์„์ฒดํฌ์šฉ QR ์ฝ”๋“œ ์ด๋ฏธ์ง€ URL์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + **์‘๋‹ต ํ•„๋“œ:** + - `attendanceActive`: ์ถœ์„์ฒดํฌ ์ง„ํ–‰ ์ค‘ ์—ฌ๋ถ€ + - `qrCodeImageUrl`: QR ์ฝ”๋“œ ์ด๋ฏธ์ง€ URL (์ถœ์„์ฒดํฌ ๋ฏธ์‹œ์ž‘ ์‹œ null) + + **QR ์ฝ”๋“œ ๋‚ด์šฉ:** + - QR ์ฝ”๋“œ์—๋Š” ์•”ํ˜ธํ™”๋œ ํ† ํฐ ๋ฌธ์ž์—ด๋งŒ ๋‹ด๊ฒจ ์žˆ์Œ + - ๋ฉค๋ฒ„๊ฐ€ QR ์Šค์บ” ํ›„ ํ† ํฐ์„ `/check` API์— ์ „๋‹ฌํ•˜์—ฌ ์ถœ์„ ์ฒ˜๋ฆฌ + + **๊ถŒํ•œ:** ์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๋งŒ ๊ฐ€๋Šฅ + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "QR ์ฝ”๋“œ ์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceInfoResponse.class), + examples = { + @ExampleObject(name = "์ถœ์„์ฒดํฌ ์ง„ํ–‰ ์ค‘", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": true, + "qrCodeImageUrl": "https://s3.amazonaws.com/.../qr-code.png" + } + } + """), + @ExampleObject(name = "์ถœ์„์ฒดํฌ ๋ฏธ์‹œ์ž‘", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": false, + "qrCodeImageUrl": null + } + } + """) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "์Šคํ„ฐ๋”” ๋ฉค๋ฒ„๊ฐ€ ์•„๋‹˜ (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "์ผ์ •์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ (SCHEDULE404)" + ) + }) @GetMapping("/qr") public ResponseEntity> getAttendanceQrCode( - @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true) @PathVariable Long studyId, - @Parameter(description = "์ผ์ • ID", required = true) @PathVariable Long scheduleId, + @Parameter(description = "์Šคํ„ฐ๋”” ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "์ผ์ • ID", required = true, example = "100") @PathVariable Long scheduleId, @CurrentMember @Parameter(hidden = true) Long memberId ) { GetAttendanceInfoResponse response = getAttendanceService.getAttendanceQrCode(studyId, From 8cfe8283c831f41048063437e85ce80a180ac34b Mon Sep 17 00:00:00 2001 From: msk226 Date: Tue, 30 Dec 2025 23:05:52 +0900 Subject: [PATCH 056/126] =?UTF-8?q?[Feature]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/kr/spot/schedule/domain/AttendanceTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java index 2fdaef1b..d8258350 100644 --- a/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java +++ b/modules/study/src/test/java/kr/spot/schedule/domain/AttendanceTest.java @@ -5,6 +5,7 @@ import static kr.spot.schedule.common.AttendanceFixture.memberInfo; import static org.assertj.core.api.Assertions.assertThat; +import java.time.LocalDateTime; import kr.spot.schedule.domain.enums.AttendanceStatus; import kr.spot.schedule.domain.vo.MemberInfo; import org.junit.jupiter.api.DisplayName; @@ -31,7 +32,6 @@ void should_createWithUndecidedStatus() { assertThat(attendance.getScheduleId()).isEqualTo(SCHEDULE_ID); assertThat(attendance.getMemberInfo()).isEqualTo(member); assertThat(attendance.getAttendanceStatus()).isEqualTo(AttendanceStatus.UNDECIDED); - assertThat(attendance.getAttendedAt()).isNotNull(); } } @@ -70,13 +70,13 @@ void should_markAsAbsent() { void should_updateAttendedAt_when_markAttendance() { // given Attendance attendance = Attendance.of(ATTENDANCE_ID, SCHEDULE_ID, memberInfo()); - var originalTime = attendance.getAttendedAt(); + LocalDateTime testPoint = LocalDateTime.now(); // when attendance.markAttendance(AttendanceStatus.PRESENT); // then - assertThat(attendance.getAttendedAt()).isAfterOrEqualTo(originalTime); + assertThat(attendance.getAttendedAt()).isAfterOrEqualTo(testPoint); } } } From e0fa100d3129ba4cf380efbb859fe50e8e227ed5 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 14:52:24 +0900 Subject: [PATCH 057/126] =?UTF-8?q?[Feature]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20-=20=EB=82=B4=EA=B0=80?= =?UTF-8?q?=20=EC=84=A0=ED=98=B8=ED=95=98=EB=8A=94=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC,=20=EC=A7=80=EC=97=AD=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20-=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetMemberInfoService.java | 56 +++++++++++++++++++ .../query/MemberQueryController.java | 25 +++++++++ .../dto/response/GetMemberInfoResponse.java | 52 +++++++++++++++++ .../GetMemberPreferCategoryResponse.java | 14 +++++ .../GetMemberPreferRegionResponse.java | 14 +++++ .../ports/StudyParticipationInfoPort.java | 8 +++ .../ports/dto/StudyParticipationInfo.java | 21 +++++++ .../GetStudyParticipationInfoService.java | 45 +++++++++++++++ .../infrastructure/jpa/StudyRepository.java | 3 + .../associations/StudyMemberRepository.java | 5 ++ 10 files changed, 243 insertions(+) create mode 100644 modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberInfoResponse.java create mode 100644 modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferCategoryResponse.java create mode 100644 modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferRegionResponse.java create mode 100644 modules/study-api/src/main/java/kr/spot/ports/StudyParticipationInfoPort.java create mode 100644 modules/study-api/src/main/java/kr/spot/ports/dto/StudyParticipationInfo.java create mode 100644 modules/study/src/main/java/kr/spot/study/application/ports/GetStudyParticipationInfoService.java diff --git a/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java b/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java index 80430329..97569b75 100644 --- a/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java +++ b/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java @@ -1,8 +1,19 @@ package kr.spot.application.query; +import java.util.List; import kr.spot.domain.Member; +import kr.spot.domain.association.PreferredCategory; +import kr.spot.domain.association.PreferredRegion; import kr.spot.infrastructure.jpa.MemberRepository; +import kr.spot.infrastructure.jpa.PreferredCategoryRepository; +import kr.spot.infrastructure.jpa.PreferredRegionRepository; +import kr.spot.ports.StudyParticipationInfoPort; +import kr.spot.ports.dto.StudyParticipationInfo; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse.StudyParticipationInfoResponse; import kr.spot.presentation.query.dto.response.GetMemberNameResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferCategoryResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferRegionResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,10 +23,55 @@ @RequiredArgsConstructor public class GetMemberInfoService { + private final StudyParticipationInfoPort studyParticipationInfoPort; private final MemberRepository memberRepository; + private final PreferredCategoryRepository preferredCategoryRepository; + private final PreferredRegionRepository preferredRegionRepository; public GetMemberNameResponse getMemberName(Long memberId) { Member member = memberRepository.getMemberById(memberId); return GetMemberNameResponse.from(member.getName()); } + + public GetMemberInfoResponse getMemberInfo(Long memberId) { + Member member = memberRepository.getMemberById(memberId); + + StudyParticipationInfo studyParticipationInfo = studyParticipationInfoPort.getStudyParticipationInfo( + memberId); + + return GetMemberInfoResponse.from( + member.getId(), + member.getName(), + member.getProfileImageUrl(), + member.getLoginType(), + member.getEmail().getValue(), + StudyParticipationInfoResponse.from( + studyParticipationInfo.participatingStudyCount(), + studyParticipationInfo.recruitingStudyCount(), + studyParticipationInfo.appliedStudyCount() + ) + ); + } + + public GetMemberPreferCategoryResponse getMemberPreferCategories(Long memberId) { + List preferredCategories = preferredCategoryRepository.findAllByMemberId( + memberId); + + List categoryCodes = preferredCategories.stream() + .map(PreferredCategory::getCategory) + .toList(); + + return GetMemberPreferCategoryResponse.from(categoryCodes, categoryCodes.size()); + } + + public GetMemberPreferRegionResponse getMemberPreferRegions(Long memberId) { + List preferredRegions = preferredRegionRepository.findAllByMemberId( + memberId); + + List regionCodes = preferredRegions.stream() + .map(PreferredRegion::getRegionCode) + .toList(); + + return GetMemberPreferRegionResponse.from(regionCodes, regionCodes.size()); + } } diff --git a/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java b/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java index 1d70a4d1..6b8266e5 100644 --- a/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java +++ b/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java @@ -10,7 +10,10 @@ import kr.spot.annotations.CurrentMember; import kr.spot.application.query.GetMemberInfoService; import kr.spot.code.status.SuccessStatus; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse; import kr.spot.presentation.query.dto.response.GetMemberNameResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferCategoryResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferRegionResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -37,4 +40,26 @@ public ResponseEntity> getMemberName( GetMemberNameResponse response = getMemberInfoService.getMemberName(memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } + + @GetMapping("/info") + public ResponseEntity> getMemberInfo( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetMemberInfoResponse response = getMemberInfoService.getMemberInfo(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @GetMapping("/prefer-categories") + public ResponseEntity> getMemberPreferCategories( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetMemberPreferCategoryResponse response = getMemberInfoService.getMemberPreferCategories( + memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @GetMapping("/prefer-regions") + public ResponseEntity> getMemberPreferRegions( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetMemberPreferRegionResponse response = getMemberInfoService.getMemberPreferRegions(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } } diff --git a/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberInfoResponse.java b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberInfoResponse.java new file mode 100644 index 00000000..4be0fe98 --- /dev/null +++ b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberInfoResponse.java @@ -0,0 +1,52 @@ +package kr.spot.presentation.query.dto.response; + +import kr.spot.domain.enums.LoginType; + +public record GetMemberInfoResponse( + long memberId, + String nickname, + String profileImageUrl, + LoginType loginType, + String email, + StudyParticipationInfoResponse studyParticipationInfo +) { + + public static GetMemberInfoResponse from( + long memberId, + String nickname, + String profileImageUrl, + LoginType loginType, + String email, + StudyParticipationInfoResponse studyParticipationInfo + ) { + return new GetMemberInfoResponse( + memberId, + nickname, + profileImageUrl, + loginType, + email, + studyParticipationInfo + ); + } + + public record StudyParticipationInfoResponse( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount + ) { + + public static StudyParticipationInfoResponse from( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount + ) { + return new StudyParticipationInfoResponse( + participatingStudyCount, + recruitingStudyCount, + appliedStudyCount + ); + } + + } + +} diff --git a/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferCategoryResponse.java b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferCategoryResponse.java new file mode 100644 index 00000000..dd3716df --- /dev/null +++ b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferCategoryResponse.java @@ -0,0 +1,14 @@ +package kr.spot.presentation.query.dto.response; + +import java.util.List; + +public record GetMemberPreferCategoryResponse( + List categories, + long totalCount +) { + + public static GetMemberPreferCategoryResponse from(List categories, + long totalCount) { + return new GetMemberPreferCategoryResponse(categories, totalCount); + } +} diff --git a/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferRegionResponse.java b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferRegionResponse.java new file mode 100644 index 00000000..9fd1ecfa --- /dev/null +++ b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferRegionResponse.java @@ -0,0 +1,14 @@ +package kr.spot.presentation.query.dto.response; + +import java.util.List; + +public record GetMemberPreferRegionResponse( + List regionCodes, + long totalCount +) { + + public static GetMemberPreferRegionResponse from(List regionCodes, + long totalCount) { + return new GetMemberPreferRegionResponse(regionCodes, totalCount); + } +} diff --git a/modules/study-api/src/main/java/kr/spot/ports/StudyParticipationInfoPort.java b/modules/study-api/src/main/java/kr/spot/ports/StudyParticipationInfoPort.java new file mode 100644 index 00000000..e0e6673e --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/StudyParticipationInfoPort.java @@ -0,0 +1,8 @@ +package kr.spot.ports; + +import kr.spot.ports.dto.StudyParticipationInfo; + +public interface StudyParticipationInfoPort { + + StudyParticipationInfo getStudyParticipationInfo(long memberId); +} diff --git a/modules/study-api/src/main/java/kr/spot/ports/dto/StudyParticipationInfo.java b/modules/study-api/src/main/java/kr/spot/ports/dto/StudyParticipationInfo.java new file mode 100644 index 00000000..f810fcf1 --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/dto/StudyParticipationInfo.java @@ -0,0 +1,21 @@ +package kr.spot.ports.dto; + +public record StudyParticipationInfo( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount +) { + + public static StudyParticipationInfo from( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount + ) { + return new StudyParticipationInfo( + participatingStudyCount, + recruitingStudyCount, + appliedStudyCount + ); + } + +} \ No newline at end of file diff --git a/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyParticipationInfoService.java b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyParticipationInfoService.java new file mode 100644 index 00000000..2d7fc13e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyParticipationInfoService.java @@ -0,0 +1,45 @@ +package kr.spot.study.application.ports; + +import java.util.List; +import kr.spot.ports.StudyParticipationInfoPort; +import kr.spot.ports.dto.StudyParticipationInfo; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetStudyParticipationInfoService implements StudyParticipationInfoPort { + + private final StudyMemberRepository studyMemberRepository; + private final StudyRepository studyRepository; + + @Override + public StudyParticipationInfo getStudyParticipationInfo(long memberId) { + long participatingStudyCount = studyMemberRepository.countByMemberIdAndStudyMemberStatusIn( + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + ); + + long recruitingStudyCount = studyRepository.countByLeaderIdAndRecruitingStatus( + memberId, + RecruitingStatus.RECRUITING + ); + + long appliedStudyCount = studyMemberRepository.countByMemberIdAndStudyMemberStatus( + memberId, + StudyMemberStatus.APPLIED + ); + + return StudyParticipationInfo.from( + participatingStudyCount, + recruitingStudyCount, + appliedStudyCount + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java index ec9bbb0f..8e449bf4 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java @@ -3,6 +3,7 @@ import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.RecruitingStatus; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyRepository extends JpaRepository { @@ -11,4 +12,6 @@ default Study getStudyById(Long studyId) { return findById(studyId) .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_NOT_FOUND)); } + + long countByLeaderIdAndRecruitingStatus(long leaderId, RecruitingStatus recruitingStatus); } diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java index 15f826ec..df1985f0 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -20,4 +20,9 @@ boolean existsByStudyIdAndMemberIdAndStudyMemberStatusIn(Long studyId, Long memb List findAllByStudyIdAndStudyMemberStatusIn(Long studyId, List studyMemberStatuses); + long countByMemberIdAndStudyMemberStatusIn(long memberId, + List studyMemberStatuses); + + long countByMemberIdAndStudyMemberStatus(long memberId, StudyMemberStatus studyMemberStatus); + } From 80fe54ec2d8fe28f96cc45719ffbc49a8efec87b Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 14:52:33 +0900 Subject: [PATCH 058/126] =?UTF-8?q?[Feature]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetMemberInfoServiceTest.java | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 modules/member/src/test/java/kr/spot/application/query/GetMemberInfoServiceTest.java diff --git a/modules/member/src/test/java/kr/spot/application/query/GetMemberInfoServiceTest.java b/modules/member/src/test/java/kr/spot/application/query/GetMemberInfoServiceTest.java new file mode 100644 index 00000000..7ccd1ab6 --- /dev/null +++ b/modules/member/src/test/java/kr/spot/application/query/GetMemberInfoServiceTest.java @@ -0,0 +1,185 @@ +package kr.spot.application.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.spot.common.fixture.MemberFixture; +import kr.spot.domain.Member; +import kr.spot.domain.association.PreferredCategory; +import kr.spot.domain.association.PreferredRegion; +import kr.spot.domain.enums.LoginType; +import kr.spot.infrastructure.jpa.MemberRepository; +import kr.spot.infrastructure.jpa.PreferredCategoryRepository; +import kr.spot.infrastructure.jpa.PreferredRegionRepository; +import kr.spot.ports.StudyParticipationInfoPort; +import kr.spot.ports.dto.StudyParticipationInfo; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse; +import kr.spot.presentation.query.dto.response.GetMemberNameResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferCategoryResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferRegionResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +@ExtendWith(MockitoExtension.class) +class GetMemberInfoServiceTest { + + @Mock + MemberRepository memberRepository; + + @Mock + PreferredCategoryRepository preferredCategoryRepository; + + @Mock + PreferredRegionRepository preferredRegionRepository; + + @Mock + StudyParticipationInfoPort studyParticipationInfoPort; + + @InjectMocks + GetMemberInfoService sut; + + @Nested + @DisplayName("getMemberName ๋ฉ”์„œ๋“œ๋Š”") + class GetMemberNameTest { + + @Test + @DisplayName("ํšŒ์› ID๋กœ ํšŒ์› ์ด๋ฆ„์„ ์กฐํšŒํ•œ๋‹ค") + void should_return_member_name_when_member_exists() { + // given + long memberId = MemberFixture.ID; + Member member = MemberFixture.member(); + + when(memberRepository.getMemberById(memberId)).thenReturn(member); + + // when + GetMemberNameResponse response = sut.getMemberName(memberId); + + // then + assertThat(response.name()).isEqualTo(MemberFixture.NAME); + } + } + + @Nested + @DisplayName("getMemberInfo ๋ฉ”์„œ๋“œ๋Š”") + class GetMemberInfoTest { + + @Test + @DisplayName("ํšŒ์› ID๋กœ ํšŒ์› ์ •๋ณด์™€ ์Šคํ„ฐ๋”” ์ฐธ์—ฌ ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค") + void should_return_member_info_with_study_participation_info() { + // given + long memberId = MemberFixture.ID; + Member member = MemberFixture.member(); + StudyParticipationInfo studyParticipationInfo = StudyParticipationInfo.from(3L, 1L, 2L); + + when(memberRepository.getMemberById(memberId)).thenReturn(member); + when(studyParticipationInfoPort.getStudyParticipationInfo(memberId)) + .thenReturn(studyParticipationInfo); + + // when + GetMemberInfoResponse response = sut.getMemberInfo(memberId); + + // then + assertThat(response.memberId()).isEqualTo(MemberFixture.ID); + assertThat(response.nickname()).isEqualTo(MemberFixture.NAME); + assertThat(response.profileImageUrl()).isEqualTo(MemberFixture.PROFILE_IMAGE); + assertThat(response.loginType()).isEqualTo(LoginType.KAKAO); + assertThat(response.email()).isEqualTo(MemberFixture.EMAIL); + assertThat(response.studyParticipationInfo().participatingStudyCount()).isEqualTo(3L); + assertThat(response.studyParticipationInfo().recruitingStudyCount()).isEqualTo(1L); + assertThat(response.studyParticipationInfo().appliedStudyCount()).isEqualTo(2L); + } + } + + @Nested + @DisplayName("getMemberPreferCategories ๋ฉ”์„œ๋“œ๋Š”") + class GetMemberPreferCategoriesTest { + + @Test + @DisplayName("ํšŒ์› ID๋กœ ์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") + void should_return_preferred_categories_when_member_has_categories() { + // given + long memberId = MemberFixture.ID; + List preferredCategories = List.of( + PreferredCategory.of(1L, memberId, "PROGRAMMING"), + PreferredCategory.of(2L, memberId, "DESIGN"), + PreferredCategory.of(3L, memberId, "LANGUAGE") + ); + + when(preferredCategoryRepository.findAllByMemberId(memberId)) + .thenReturn(preferredCategories); + + // when + GetMemberPreferCategoryResponse response = sut.getMemberPreferCategories(memberId); + + // then + assertThat(response.categories()).containsExactly("PROGRAMMING", "DESIGN", "LANGUAGE"); + assertThat(response.totalCount()).isEqualTo(3); + } + + @Test + @DisplayName("์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_member_has_no_categories() { + // given + long memberId = MemberFixture.ID; + + when(preferredCategoryRepository.findAllByMemberId(memberId)) + .thenReturn(List.of()); + + // when + GetMemberPreferCategoryResponse response = sut.getMemberPreferCategories(memberId); + + // then + assertThat(response.categories()).isEmpty(); + assertThat(response.totalCount()).isZero(); + } + } + + @Nested + @DisplayName("getMemberPreferRegions ๋ฉ”์„œ๋“œ๋Š”") + class GetMemberPreferRegionsTest { + + @Test + @DisplayName("ํšŒ์› ID๋กœ ์„ ํ˜ธ ์ง€์—ญ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") + void should_return_preferred_regions_when_member_has_regions() { + // given + long memberId = MemberFixture.ID; + List preferredRegions = List.of( + PreferredRegion.of(1L, memberId, "SEOUL"), + PreferredRegion.of(2L, memberId, "BUSAN") + ); + + when(preferredRegionRepository.findAllByMemberId(memberId)) + .thenReturn(preferredRegions); + + // when + GetMemberPreferRegionResponse response = sut.getMemberPreferRegions(memberId); + + // then + assertThat(response.regionCodes()).containsExactly("SEOUL", "BUSAN"); + assertThat(response.totalCount()).isEqualTo(2); + } + + @Test + @DisplayName("์„ ํ˜ธ ์ง€์—ญ์ด ์—†์œผ๋ฉด ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_member_has_no_regions() { + // given + long memberId = MemberFixture.ID; + + when(preferredRegionRepository.findAllByMemberId(memberId)) + .thenReturn(List.of()); + + // when + GetMemberPreferRegionResponse response = sut.getMemberPreferRegions(memberId); + + // then + assertThat(response.regionCodes()).isEmpty(); + assertThat(response.totalCount()).isZero(); + } + } +} From 0befc23c73a0c0a4e4467c405a8fd17dd21d917e Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 14:54:23 +0900 Subject: [PATCH 059/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=ED=8F=AC=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GetStudyParticipationInfoServiceTest.java | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/study/application/ports/GetStudyParticipationInfoServiceTest.java diff --git a/modules/study/src/test/java/kr/spot/study/application/ports/GetStudyParticipationInfoServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/ports/GetStudyParticipationInfoServiceTest.java new file mode 100644 index 00000000..73f33670 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/study/application/ports/GetStudyParticipationInfoServiceTest.java @@ -0,0 +1,183 @@ +package kr.spot.study.application.ports; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; +import kr.spot.ports.dto.StudyParticipationInfo; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetStudyParticipationInfoService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class GetStudyParticipationInfoServiceTest { + + @Mock + StudyMemberRepository studyMemberRepository; + + @Mock + StudyRepository studyRepository; + + @InjectMocks + GetStudyParticipationInfoService sut; + + @Nested + @DisplayName("getStudyParticipationInfo ๋ฉ”์„œ๋“œ๋Š”") + class GetStudyParticipationInfoTest { + + private final long memberId = 1L; + + @Test + @DisplayName("ํšŒ์›์˜ ์Šคํ„ฐ๋”” ์ฐธ์—ฌ ์ •๋ณด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์กฐํšŒํ•œ๋‹ค") + void should_return_study_participation_info() { + // given + long expectedParticipatingCount = 3L; + long expectedRecruitingCount = 1L; + long expectedAppliedCount = 2L; + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatusIn( + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + )).willReturn(expectedParticipatingCount); + + given(studyRepository.countByLeaderIdAndRecruitingStatus( + memberId, + RecruitingStatus.RECRUITING + )).willReturn(expectedRecruitingCount); + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatus( + memberId, + StudyMemberStatus.APPLIED + )).willReturn(expectedAppliedCount); + + // when + StudyParticipationInfo result = sut.getStudyParticipationInfo(memberId); + + // then + assertThat(result.participatingStudyCount()).isEqualTo(expectedParticipatingCount); + assertThat(result.recruitingStudyCount()).isEqualTo(expectedRecruitingCount); + assertThat(result.appliedStudyCount()).isEqualTo(expectedAppliedCount); + } + + @Test + @DisplayName("์ฐธ์—ฌ ์ค‘์ธ ์Šคํ„ฐ๋””๊ฐ€ ์—†์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_zero_when_no_participating_studies() { + // given + given(studyMemberRepository.countByMemberIdAndStudyMemberStatusIn( + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + )).willReturn(0L); + + given(studyRepository.countByLeaderIdAndRecruitingStatus( + memberId, + RecruitingStatus.RECRUITING + )).willReturn(0L); + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatus( + memberId, + StudyMemberStatus.APPLIED + )).willReturn(0L); + + // when + StudyParticipationInfo result = sut.getStudyParticipationInfo(memberId); + + // then + assertThat(result.participatingStudyCount()).isZero(); + assertThat(result.recruitingStudyCount()).isZero(); + assertThat(result.appliedStudyCount()).isZero(); + } + + @Test + @DisplayName("OWNER์™€ APPROVED ์ƒํƒœ์˜ ์Šคํ„ฐ๋””๋งŒ ์ฐธ์—ฌ ์ค‘์œผ๋กœ ์นด์šดํŠธํ•œ๋‹ค") + void should_count_only_owner_and_approved_as_participating() { + // given + long participatingCount = 5L; + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatusIn( + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + )).willReturn(participatingCount); + + given(studyRepository.countByLeaderIdAndRecruitingStatus( + memberId, + RecruitingStatus.RECRUITING + )).willReturn(0L); + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatus( + memberId, + StudyMemberStatus.APPLIED + )).willReturn(0L); + + // when + StudyParticipationInfo result = sut.getStudyParticipationInfo(memberId); + + // then + assertThat(result.participatingStudyCount()).isEqualTo(participatingCount); + } + + @Test + @DisplayName("RECRUITING ์ƒํƒœ์˜ ์Šคํ„ฐ๋””๋งŒ ๋ชจ์ง‘ ์ค‘์œผ๋กœ ์นด์šดํŠธํ•œ๋‹ค") + void should_count_only_recruiting_status_as_recruiting() { + // given + long recruitingCount = 2L; + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatusIn( + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + )).willReturn(0L); + + given(studyRepository.countByLeaderIdAndRecruitingStatus( + memberId, + RecruitingStatus.RECRUITING + )).willReturn(recruitingCount); + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatus( + memberId, + StudyMemberStatus.APPLIED + )).willReturn(0L); + + // when + StudyParticipationInfo result = sut.getStudyParticipationInfo(memberId); + + // then + assertThat(result.recruitingStudyCount()).isEqualTo(recruitingCount); + } + + @Test + @DisplayName("APPLIED ์ƒํƒœ์˜ ์Šคํ„ฐ๋””๋งŒ ์‹ ์ฒญ ์ค‘์œผ๋กœ ์นด์šดํŠธํ•œ๋‹ค") + void should_count_only_applied_status_as_applied() { + // given + long appliedCount = 4L; + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatusIn( + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + )).willReturn(0L); + + given(studyRepository.countByLeaderIdAndRecruitingStatus( + memberId, + RecruitingStatus.RECRUITING + )).willReturn(0L); + + given(studyMemberRepository.countByMemberIdAndStudyMemberStatus( + memberId, + StudyMemberStatus.APPLIED + )).willReturn(appliedCount); + + // when + StudyParticipationInfo result = sut.getStudyParticipationInfo(memberId); + + // then + assertThat(result.appliedStudyCount()).isEqualTo(appliedCount); + } + } +} From c27e64db8da3b9e8645181040b45b97ec606de07 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 14:57:41 +0900 Subject: [PATCH 060/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EB=AA=85=EC=84=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/presentation/query/MemberQueryController.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java b/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java index 6b8266e5..055ad190 100644 --- a/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java +++ b/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java @@ -41,6 +41,7 @@ public ResponseEntity> getMemberName( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } + @Operation(summary = "[๋งˆ์ดํŽ˜์ด์ง€] ํšŒ์› ์ •๋ณด ์กฐํšŒ", description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ํšŒ์›์˜ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") @GetMapping("/info") public ResponseEntity> getMemberInfo( @CurrentMember @Parameter(hidden = true) Long memberId) { @@ -48,6 +49,7 @@ public ResponseEntity> getMemberInfo( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } + @Operation(summary = "ํšŒ์› ์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ", description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ํšŒ์›์˜ ์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") @GetMapping("/prefer-categories") public ResponseEntity> getMemberPreferCategories( @CurrentMember @Parameter(hidden = true) Long memberId) { @@ -56,6 +58,7 @@ public ResponseEntity> getMemberPre return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } + @Operation(summary = "ํšŒ์› ์„ ํ˜ธ ์ง€์—ญ ์กฐํšŒ", description = "ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ํšŒ์›์˜ ์„ ํ˜ธ ์ง€์—ญ์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") @GetMapping("/prefer-regions") public ResponseEntity> getMemberPreferRegions( @CurrentMember @Parameter(hidden = true) Long memberId) { From af1eb8b2da227df3fc892bc0ae71ffb591fa915b Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 14:58:42 +0900 Subject: [PATCH 061/126] =?UTF-8?q?[Feature]=20=EB=88=84=EB=9D=BD=EB=90=9C?= =?UTF-8?q?=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AA=85=EC=84=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/command/ReviewCommandController.java | 5 +++++ .../review/presentation/query/ReviewQueryController.java | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java index 2ddbbd33..b314b716 100644 --- a/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java +++ b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java @@ -1,5 +1,6 @@ package kr.spot.review.presentation.command; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; @@ -29,6 +30,7 @@ public class ReviewCommandController { private final ManageReviewService manageReviewService; private final ManageReviewReactionService manageReviewReactionService; + @Operation(summary = "์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก ์ž‘์„ฑ", description = "ํŠน์ • ์Šคํ„ฐ๋””์— ๋Œ€ํ•œ ํšŒ๊ณ ๋ก์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.") @PostMapping public ResponseEntity> createReview( @PathVariable Long studyId, @@ -40,6 +42,7 @@ public ResponseEntity> createReview( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); } + @Operation(summary = "์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก ์‚ญ์ œ", description = "ํŠน์ • ์Šคํ„ฐ๋””์— ๋Œ€ํ•œ ํšŒ๊ณ ๋ก์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.") @DeleteMapping("/{reviewId}") public ResponseEntity> deleteReview( @PathVariable Long studyId, @@ -50,6 +53,7 @@ public ResponseEntity> deleteReview( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); } + @Operation(summary = "์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก ๋ฐ˜์‘ ์ถ”๊ฐ€", description = "ํŠน์ • ์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก์— ๋Œ€ํ•œ ๋ฐ˜์‘์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.") @PostMapping("/{reviewId}/reactions") public ResponseEntity> addReaction( @PathVariable Long studyId, @@ -61,6 +65,7 @@ public ResponseEntity> addReaction( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); } + @Operation(summary = "์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก ๋ฐ˜์‘ ์ œ๊ฑฐ", description = "ํŠน์ • ์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก์— ๋Œ€ํ•œ ๋ฐ˜์‘์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.") @DeleteMapping("/{reviewId}/reactions") public ResponseEntity> removeReaction( @PathVariable Long studyId, diff --git a/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java b/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java index b544186f..acdf0e74 100644 --- a/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java +++ b/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java @@ -1,5 +1,6 @@ package kr.spot.review.presentation.query; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Max; @@ -25,6 +26,8 @@ public class ReviewQueryController { private final GetReviewService getReviewService; + + @Operation(summary = "์Šคํ„ฐ๋”” ํšŒ๊ณ ๋ก ๋ชฉ๋ก ์กฐํšŒ", description = "ํŠน์ • ์Šคํ„ฐ๋””์˜ ํšŒ๊ณ ๋ก ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง€๋„ค์ด์…˜์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.") @GetMapping public ResponseEntity> getReviewList( @PathVariable Long studyId, @@ -32,7 +35,8 @@ public ResponseEntity> getReviewList( @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size ) { - GetReviewListResponse response = getReviewService.getReviewList(studyId, memberId, cursor, size); + GetReviewListResponse response = getReviewService.getReviewList(studyId, memberId, cursor, + size); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } } From 7943458859536b79b9dbddbfd72c7c1dd5c2869e Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:31:39 +0900 Subject: [PATCH 062/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84=20-=20=EC=A1=B0=ED=9A=8C=EC=88=98?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EC=A6=9D=EA=B0=80=20=EB=B0=A9=EC=A7=80?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=EB=8F=84=20=EC=84=A4=EC=A0=95=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetStudyDetailService.java | 124 ++++++++++++++++++ .../associations/StudyCategoryRepository.java | 2 + .../query/StudyQueryController.java | 29 +++- .../dto/response/GetStudyInfoResponse.java | 52 ++++++++ .../dto/response/GetStudyMembersResponse.java | 40 ++++++ 5 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 modules/study/src/main/java/kr/spot/study/application/query/GetStudyDetailService.java create mode 100644 modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyInfoResponse.java create mode 100644 modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyMembersResponse.java diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetStudyDetailService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyDetailService.java new file mode 100644 index 00000000..513027cc --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyDetailService.java @@ -0,0 +1,124 @@ +package kr.spot.study.application.query; + +import java.util.List; +import java.util.Map; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyCategory; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.associations.StudyStats; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyCategoryRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import kr.spot.study.presentation.query.dto.response.GetStudyInfoResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyInfoResponse.Statistics; +import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse.MemberResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetStudyDetailService { + + private static final List ACTIVE_MEMBER_STATUSES = + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED); + + private final StudyRepository studyRepository; + private final StudyCategoryRepository studyCategoryRepository; + private final StudyStatsRepository studyStatsRepository; + private final StudyMemberRepository studyMemberRepository; + private final StudyViewCountService studyViewCountService; + private final GetMemberInfoPort getMemberInfoPort; + + public GetStudyInfoResponse getStudyInfo(long studyId, long viewerId) { + Study study = findStudy(studyId); + StudyStats stats = findStudyStats(studyId); + List categories = findCategories(studyId); + Statistics statistics = buildStatistics(study, stats, studyId, viewerId); + + return toStudyInfoResponse(study, categories, statistics); + } + + public GetStudyMembersResponse getStudyMembers(long studyId) { + List studyMembers = findActiveStudyMembers(studyId); + Map memberInfoMap = fetchMemberInfos(studyMembers); + List members = toMemberResponses(studyMembers, memberInfoMap); + + return GetStudyMembersResponse.of(members, members.size()); + } + + private Study findStudy(long studyId) { + return studyRepository.getStudyById(studyId); + } + + private StudyStats findStudyStats(long studyId) { + return studyStatsRepository.getByStudyId(studyId); + } + + private List findCategories(long studyId) { + return studyCategoryRepository.findAllByStudyId(studyId).stream() + .map(StudyCategory::getCategory) + .toList(); + } + + private Statistics buildStatistics(Study study, StudyStats stats, long studyId, long viewerId) { + long displayViewCount = studyViewCountService.calculateDisplayViewCount(stats, studyId, + viewerId); + return Statistics.of( + study.getMaxMembers(), + study.getCurrentMembers(), + stats.getLikeCount(), + displayViewCount + ); + } + + private GetStudyInfoResponse toStudyInfoResponse(Study study, List categories, + Statistics statistics) { + return GetStudyInfoResponse.of( + study.getName(), + study.getDescription(), + study.getImageUrl(), + categories, + statistics + ); + } + + private List findActiveStudyMembers(long studyId) { + return studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn( + studyId, ACTIVE_MEMBER_STATUSES); + } + + private Map fetchMemberInfos(List studyMembers) { + List memberIds = studyMembers.stream() + .map(StudyMember::getMemberId) + .toList(); + return getMemberInfoPort.getMemberInfo(memberIds); + } + + private List toMemberResponses(List studyMembers, + Map memberInfoMap) { + return studyMembers.stream() + .map(studyMember -> toMemberResponse(studyMember, memberInfoMap)) + .toList(); + } + + private MemberResponse toMemberResponse(StudyMember studyMember, + Map memberInfoMap) { + MemberInfoResponse memberInfo = memberInfoMap.get(studyMember.getMemberId()); + boolean isOwner = studyMember.getStudyMemberStatus() == StudyMemberStatus.OWNER; + + return MemberResponse.of( + studyMember.getMemberId(), + memberInfo.name(), + memberInfo.profileImageUrl(), + isOwner + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java index 32040953..564ecbda 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java @@ -1,8 +1,10 @@ package kr.spot.study.infrastructure.jpa.associations; +import java.util.List; import kr.spot.study.domain.associations.StudyCategory; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyCategoryRepository extends JpaRepository { + List findAllByStudyId(long studyId); } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java index be518511..a3b8f786 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java @@ -10,11 +10,14 @@ import kr.spot.annotations.CurrentMember; import kr.spot.code.status.SuccessStatus; import kr.spot.study.application.query.GetMyStudyInfoService; +import kr.spot.study.application.query.GetStudyDetailService; import kr.spot.study.domain.enums.Category; import kr.spot.study.domain.enums.FeeCategory; import kr.spot.study.domain.enums.RecruitingStatus; import kr.spot.study.domain.enums.SortBy; import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.presentation.query.dto.response.GetStudyInfoResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse; import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -30,6 +33,28 @@ public class StudyQueryController { private final GetMyStudyInfoService getMyStudyInfoService; + private final GetStudyDetailService getStudyDetailService; + + @Operation(summary = "์Šคํ„ฐ๋”” ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ", + description = "์Šคํ„ฐ๋””์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/info") + public ResponseEntity> getStudyInfo( + @RequestParam Long studyId, + @CurrentMember @Parameter(hidden = true) Long viewerId + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, + getStudyDetailService.getStudyInfo(studyId, viewerId))); + } + + @Operation(summary = "์Šคํ„ฐ๋”” ๋ฉค๋ฒ„ ์กฐํšŒ", + description = "์Šคํ„ฐ๋””์˜ ๋ฉค๋ฒ„๋“ค์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/members") + public ResponseEntity> getStudyMembers( + @RequestParam Long studyId + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, + getStudyDetailService.getStudyMembers(studyId))); + } @Operation(summary = "๋งˆ์ดํŽ˜์ด์ง€ ์Šคํ„ฐ๋”” ์กฐํšŒ", description = "๋งˆ์ดํŽ˜์ด์ง€์— ํ•„์š”ํ•œ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.\n " @@ -55,8 +80,8 @@ public ResponseEntity> getHotStudies( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); } - @Operation(summary = "์ถ”์ฒœํ•˜๋Š” ์Šคํ„ฐ๋”” ์กฐํšŒ - ๊ตฌํ˜„ ์ค‘", - description = "์ถ”์ฒœํ•˜๋Š” ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") + @Operation(summary = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋”” ์กฐํšŒ - ๊ตฌํ˜„ ์ค‘", + description = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") @GetMapping("/recommended") public ResponseEntity> getRecommendedStudies( ) { diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyInfoResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyInfoResponse.java new file mode 100644 index 00000000..f7ab0e14 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyInfoResponse.java @@ -0,0 +1,52 @@ +package kr.spot.study.presentation.query.dto.response; + +import java.util.List; +import kr.spot.study.domain.enums.Category; + +public record GetStudyInfoResponse( + String title, + String description, + String thumbnailUrl, + List categories, + Statistics statistics +) { + + public static GetStudyInfoResponse of( + String title, + String description, + String thumbnailUrl, + List categories, + Statistics statistics + ) { + return new GetStudyInfoResponse( + title, + description, + thumbnailUrl, + categories, + statistics + ); + } + + public record Statistics( + int totalMembers, + int currentMembers, + long likeCount, + long hitCount + ) { + + public static Statistics of( + int totalMembers, + int currentMembers, + long likeCount, + long hitCount + ) { + return new Statistics( + totalMembers, + currentMembers, + likeCount, + hitCount + ); + } + } + +} diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyMembersResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyMembersResponse.java new file mode 100644 index 00000000..7f860371 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyMembersResponse.java @@ -0,0 +1,40 @@ +package kr.spot.study.presentation.query.dto.response; + +import java.util.List; + +public record GetStudyMembersResponse( + List members, + long totalMembers +) { + + public static GetStudyMembersResponse of( + List members, + long totalMembers + ) { + return new GetStudyMembersResponse(members, totalMembers); + } + + public record MemberResponse( + Long memberId, + String nickname, + String profileImageUrl, + boolean isOwner + ) { + + public static MemberResponse of( + Long memberId, + String nickname, + String profileImageUrl, + boolean isOwner + ) { + return new MemberResponse( + memberId, + nickname, + profileImageUrl, + isOwner + ); + } + + } + +} From afad9764f79219a8aa0bfe74666e6f182558375e Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:31:53 +0900 Subject: [PATCH 063/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/StudyViewCountService.java | 36 ++++ .../batch/StudyViewFlushJob.java | 157 ++++++++++++++++++ .../batch/StudyViewFlusher.java | 22 +++ .../associations/StudyStatsRepository.java | 21 +++ 4 files changed, 236 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/study/application/query/StudyViewCountService.java create mode 100644 modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlushJob.java create mode 100644 modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlusher.java diff --git a/modules/study/src/main/java/kr/spot/study/application/query/StudyViewCountService.java b/modules/study/src/main/java/kr/spot/study/application/query/StudyViewCountService.java new file mode 100644 index 00000000..2c556274 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/query/StudyViewCountService.java @@ -0,0 +1,36 @@ +package kr.spot.study.application.query; + +import kr.spot.study.domain.associations.StudyStats; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudyViewCountService { + + private final ViewCounter viewCounter; + private final ViewAbuseGuard viewAbuseGuard; + + public long calculateDisplayViewCount(StudyStats stats, long studyId, long viewerId) { + long baseViewCount = stats.getViewCount(); + long viewDelta = getViewDeltaFromCounter(studyId, viewerId); + return baseViewCount + viewDelta; + } + + private long getViewDeltaFromCounter(long studyId, long viewerId) { + try { + if (viewAbuseGuard.shouldCount(ViewableType.STUDY, studyId, viewerId)) { + return viewCounter.incrementAndGet(ViewableType.STUDY, studyId); + } + return viewCounter.currentDelta(ViewableType.STUDY, studyId); + } catch (Exception e) { + log.warn("Redis view counter access failed for studyId: {}", studyId, e); + return 0L; + } + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlushJob.java b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlushJob.java new file mode 100644 index 00000000..5f8614e5 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlushJob.java @@ -0,0 +1,157 @@ +package kr.spot.study.infrastructure.batch; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyViewFlushJob { + + private static final String KEY_PREFIX = "view:study:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + private static final String LOCK_KEY = "lock:study-view-flush"; + + private final StringRedisTemplate redis; + private final StudyViewFlusher studyViewFlusher; + + @Scheduled(cron = "0 * * * * *") + public void flush() { + if (!tryAcquireLock()) { + log.debug("๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์—์„œ ์‹คํ–‰ ์ค‘"); + return; + } + try { + executeBatch(); + } finally { + releaseLock(); + } + } + + private void executeBatch() { + log.info("StudyViewFlushJob ์‹œ์ž‘"); + long startTime = System.currentTimeMillis(); + + BatchResult result = processAllKeys(); + + long duration = System.currentTimeMillis() - startTime; + log.info("StudyViewFlushJob ์™„๋ฃŒ: ์„ฑ๊ณต {}๊ฑด, ์‹คํŒจ {}๊ฑด, {}ms", + result.successCount, result.failureCount, duration); + } + + private BatchResult processAllKeys() { + BatchResult result = new BatchResult(); + ScanOptions options = ScanOptions.scanOptions() + .match(KEY_PATTERN) + .count(500) + .build(); + + redis.execute((RedisCallback) connection -> { + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + byte[] keyBytes = cursor.next(); + processKey(keyBytes, result); + } + } catch (Exception e) { + log.error("SCAN ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜", e); + } + return null; + }); + + return result; + } + + private void processKey(byte[] keyBytes, BatchResult result) { + String key = new String(keyBytes, StandardCharsets.UTF_8); + long delta = 0; + + try { + String valueStr = redis.opsForValue().getAndDelete(key); + if (valueStr == null) { + return; + } + + delta = parseLong(valueStr); + if (delta <= 0) { + log.warn("์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฐ’ ๋ฌด์‹œ: key={}, value={}", key, valueStr); + return; + } + + Long studyId = extractStudyIdFromKey(key); + if (studyId == null) { + log.error("studyId ์ถ”์ถœ ์‹คํŒจ, ๊ฐ’ ์†์‹ค: key={}, delta={}", key, delta); + return; + } + + studyViewFlusher.updateViewCount(studyId, delta); + result.recordSuccess(); + log.debug("์ฒ˜๋ฆฌ ์™„๋ฃŒ: studyId={}, delta={}", studyId, delta); + + } catch (Exception e) { + result.recordFailure(); + log.error("์ฒ˜๋ฆฌ ์‹คํŒจ: key={}", key, e); + if (delta > 0) { + restoreValue(key, delta); + } + } + } + + private void restoreValue(String key, long delta) { + try { + redis.opsForValue().increment(key, delta); + log.warn("DB ๋ฐ˜์˜ ์‹คํŒจ, Redis์— ๊ฐ’ ๋ณต๊ตฌ ์™„๋ฃŒ: key={}, delta={}", key, delta); + } catch (Exception e) { + log.error("๋ณต๊ตฌ ์‹คํŒจ, ๋ฐ์ดํ„ฐ ์†์‹ค: key={}, delta={}", key, delta, e); + } + } + + private boolean tryAcquireLock() { + Boolean acquired = redis.opsForValue() + .setIfAbsent(LOCK_KEY, "1", 55, TimeUnit.SECONDS); + return Boolean.TRUE.equals(acquired); + } + + private void releaseLock() { + redis.delete(LOCK_KEY); + } + + private long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + log.warn("Long ํŒŒ์‹ฑ ์‹คํŒจ: {}", value); + return 0L; + } + } + + private Long extractStudyIdFromKey(String key) { + try { + return Long.parseLong(key.substring(KEY_PREFIX.length())); + } catch (Exception e) { + log.error("studyId ์ถ”์ถœ ์‹คํŒจ: {}", key, e); + return null; + } + } + + private static class BatchResult { + + int successCount = 0; + int failureCount = 0; + + void recordSuccess() { + successCount++; + } + + void recordFailure() { + failureCount++; + } + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlusher.java b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlusher.java new file mode 100644 index 00000000..d103cd45 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlusher.java @@ -0,0 +1,22 @@ +package kr.spot.study.infrastructure.batch; + +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyViewFlusher { + + private final StudyStatsRepository studyStatsRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateViewCount(long studyId, long delta) { + studyStatsRepository.increaseViewBy(studyId, delta); + log.debug("์Šคํ„ฐ๋”” ์กฐํšŒ์ˆ˜ DB ์—…๋ฐ์ดํŠธ: studyId={}, delta={}", studyId, delta); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java index 42d3e873..47292094 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java @@ -1,8 +1,29 @@ package kr.spot.study.infrastructure.jpa.associations; +import java.util.Optional; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; import kr.spot.study.domain.associations.StudyStats; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface StudyStatsRepository extends JpaRepository { + Optional findByStudyId(long studyId); + + default StudyStats getByStudyId(long studyId) { + return findByStudyId(studyId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_NOT_FOUND)); + } + + @Modifying + @Query(""" + update StudyStats s + set s.viewCount = s.viewCount + :delta, + s.updatedAt = CURRENT_TIMESTAMP + where s.studyId = :studyId and s.status = 'ACTIVE' + """) + int increaseViewBy(@Param("studyId") long studyId, @Param("delta") long delta); } From 55448d442115e388975d2cd52fbdead39728b66d Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:37:54 +0900 Subject: [PATCH 064/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C,=20=EC=9E=91=EC=84=B1=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=EB=8A=94=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/mapper/PostResponseMapper.java | 14 ++------------ .../presentation/query/dto/PostListResponse.java | 14 +------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java index d00e12de..01d3a390 100644 --- a/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java +++ b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java @@ -8,7 +8,6 @@ import kr.spot.post.presentation.query.dto.PostDetailResponse; import kr.spot.post.presentation.query.dto.PostDetailResponse.CommentResponse; import kr.spot.post.presentation.query.dto.PostListResponse.PostItem; -import kr.spot.post.presentation.query.dto.PostListResponse.WriterInfoResponse; import kr.spot.post.presentation.query.dto.PostStatsResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -34,7 +33,6 @@ public static PostDetailResponse toDetailResponse( .isPinned(post.isPinned()) .isOwner(post.isOwnedBy(viewerId)) .isLiked(isLiked) - .writer(toDetailWriterInfoResponse(post.getWriterInfo())) .stats(toStatsResponse(stats, displayViewCount)) .createdAt(post.getCreatedAt()) .comments(toCommentResponses(comments, viewerId)) @@ -58,7 +56,6 @@ private static PostItem toPublicPostItem(Post post, PostStats stats, int maxCont post.isPinned(), isLiked, toStatsResponse(stats), - toWriterInfoResponse(post.getWriterInfo()), post.getCreatedAt() ); } @@ -71,7 +68,6 @@ private static PostItem toMaskedPostItem(Post post, PostStats stats, boolean isL post.isPinned(), isLiked, toStatsResponse(stats), - toWriterInfoResponse(post.getWriterInfo()), post.getCreatedAt() ); } @@ -93,15 +89,9 @@ private static CommentResponse toCommentResponse(Comment comment, long viewerId) ); } - private static WriterInfoResponse toWriterInfoResponse(WriterInfo writerInfo) { - return WriterInfoResponse.of( - writerInfo.getWriterId(), - writerInfo.getWriterName(), - writerInfo.getWriterProfileImageUrl() - ); - } - private static PostDetailResponse.WriterInfoResponse toDetailWriterInfoResponse(WriterInfo writerInfo) { + private static PostDetailResponse.WriterInfoResponse toDetailWriterInfoResponse( + WriterInfo writerInfo) { return PostDetailResponse.WriterInfoResponse.of( writerInfo.getWriterId(), writerInfo.getWriterName(), diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java index c84eff4e..a180d7fd 100644 --- a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java @@ -18,7 +18,6 @@ public record PostItem( boolean isPinned, boolean isLiked, PostStatsResponse stats, - WriterInfoResponse writer, LocalDateTime createdAt ) { @@ -29,21 +28,10 @@ public static PostItem of( boolean isPinned, boolean isLiked, PostStatsResponse stats, - WriterInfoResponse writer, LocalDateTime createdAt ) { - return new PostItem(postId, title, content, isPinned, isLiked, stats, writer, createdAt); + return new PostItem(postId, title, content, isPinned, isLiked, stats, createdAt); } } - public record WriterInfoResponse( - Long writerId, - String nickname, - String profileImageUrl - ) { - - public static WriterInfoResponse of(Long writerId, String nickname, String profileImageUrl) { - return new WriterInfoResponse(writerId, nickname, profileImageUrl); - } - } } From 032f6fbbbd49ffc4efa75edacef7bb9afa9b145a Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:39:55 +0900 Subject: [PATCH 065/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetStudyDetailServiceTest.java | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/study/application/query/GetStudyDetailServiceTest.java diff --git a/modules/study/src/test/java/kr/spot/study/application/query/GetStudyDetailServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/GetStudyDetailServiceTest.java new file mode 100644 index 00000000..eb84da11 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/study/application/query/GetStudyDetailServiceTest.java @@ -0,0 +1,224 @@ +package kr.spot.study.application.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; +import java.util.Map; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyCategory; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.associations.StudyStats; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.domain.vo.Fee; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyCategoryRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import kr.spot.study.presentation.query.dto.response.GetStudyInfoResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse.MemberResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetStudyDetailService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class GetStudyDetailServiceTest { + + @Mock + StudyRepository studyRepository; + + @Mock + StudyCategoryRepository studyCategoryRepository; + + @Mock + StudyStatsRepository studyStatsRepository; + + @Mock + StudyMemberRepository studyMemberRepository; + + @Mock + StudyViewCountService studyViewCountService; + + @Mock + GetMemberInfoPort getMemberInfoPort; + + @InjectMocks + GetStudyDetailService sut; + + @Nested + @DisplayName("getStudyInfo ๋ฉ”์„œ๋“œ๋Š”") + class GetStudyInfoTest { + + private final long studyId = 1L; + private final long viewerId = 100L; + + @Test + @DisplayName("์Šคํ„ฐ๋”” ์ƒ์„ธ ์ •๋ณด๋ฅผ ์ •์ƒ์ ์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_study_info_successfully() { + // given + Study study = createStudy(studyId, "์•Œ๊ณ ๋ฆฌ์ฆ˜ ์Šคํ„ฐ๋””", 10, 5); + StudyStats stats = createStudyStats(studyId, 100L, 50L); + List categories = List.of( + StudyCategory.of(1L, studyId, Category.LANGUAGE), + StudyCategory.of(2L, studyId, Category.CERTIFICATION) + ); + long displayViewCount = 150L; + + given(studyRepository.getStudyById(studyId)).willReturn(study); + given(studyStatsRepository.getByStudyId(studyId)).willReturn(stats); + given(studyCategoryRepository.findAllByStudyId(studyId)).willReturn(categories); + given(studyViewCountService.calculateDisplayViewCount(stats, studyId, viewerId)) + .willReturn(displayViewCount); + + // when + GetStudyInfoResponse result = sut.getStudyInfo(studyId, viewerId); + + // then + assertThat(result.title()).isEqualTo("์•Œ๊ณ ๋ฆฌ์ฆ˜ ์Šคํ„ฐ๋””"); + assertThat(result.description()).isEqualTo("์Šคํ„ฐ๋”” ์„ค๋ช…"); + assertThat(result.categories()).containsExactly(Category.LANGUAGE, Category.CERTIFICATION); + assertThat(result.statistics().totalMembers()).isEqualTo(10); + assertThat(result.statistics().currentMembers()).isEqualTo(5); + assertThat(result.statistics().likeCount()).isEqualTo(50L); + assertThat(result.statistics().hitCount()).isEqualTo(displayViewCount); + } + + @Test + @DisplayName("์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†๋Š” ์Šคํ„ฐ๋””๋„ ์ •์ƒ์ ์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_study_info_with_empty_categories() { + // given + Study study = createStudy(studyId, "์Šคํ„ฐ๋””", 5, 1); + StudyStats stats = createStudyStats(studyId, 10L, 5L); + long displayViewCount = 10L; + + given(studyRepository.getStudyById(studyId)).willReturn(study); + given(studyStatsRepository.getByStudyId(studyId)).willReturn(stats); + given(studyCategoryRepository.findAllByStudyId(studyId)).willReturn(List.of()); + given(studyViewCountService.calculateDisplayViewCount(stats, studyId, viewerId)) + .willReturn(displayViewCount); + + // when + GetStudyInfoResponse result = sut.getStudyInfo(studyId, viewerId); + + // then + assertThat(result.categories()).isEmpty(); + assertThat(result.statistics().hitCount()).isEqualTo(displayViewCount); + } + + private Study createStudy(long id, String name, int maxMembers, int currentMembers) { + Study study = Study.of(id, 1L, name, maxMembers, Fee.of(false, 0), "์Šคํ„ฐ๋”” ์„ค๋ช…"); + ReflectionTestUtils.setField(study, "currentMembers", currentMembers); + return study; + } + + private StudyStats createStudyStats(long studyId, long viewCount, long likeCount) { + StudyStats stats = StudyStats.of(studyId); + ReflectionTestUtils.setField(stats, "viewCount", viewCount); + ReflectionTestUtils.setField(stats, "likeCount", likeCount); + return stats; + } + } + + @Nested + @DisplayName("getStudyMembers ๋ฉ”์„œ๋“œ๋Š”") + class GetStudyMembersTest { + + private final long studyId = 1L; + private final List ACTIVE_STATUSES = + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED); + + @Test + @DisplayName("ํ™œ์„ฑ ๋ฉค๋ฒ„ ๋ชฉ๋ก์„ ์ •์ƒ์ ์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_active_members_successfully() { + // given + long ownerId = 10L; + long memberId = 20L; + StudyMember owner = StudyMember.create(1L, studyId, ownerId); + StudyMember member = createApprovedMember(2L, studyId, memberId); + + MemberInfoResponse ownerInfo = MemberInfoResponse.of("์Šคํ„ฐ๋””์žฅ", "https://image.url/owner.jpg"); + MemberInfoResponse memberInfo = MemberInfoResponse.of("๋ฉค๋ฒ„1", "https://image.url/member.jpg"); + + given(studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_STATUSES)) + .willReturn(List.of(owner, member)); + given(getMemberInfoPort.getMemberInfo(List.of(ownerId, memberId))) + .willReturn(Map.of(ownerId, ownerInfo, memberId, memberInfo)); + + // when + GetStudyMembersResponse result = sut.getStudyMembers(studyId); + + // then + assertThat(result.totalMembers()).isEqualTo(2); + assertThat(result.members()).hasSize(2); + + MemberResponse ownerResponse = result.members().stream() + .filter(MemberResponse::isOwner) + .findFirst() + .orElseThrow(); + assertThat(ownerResponse.memberId()).isEqualTo(ownerId); + assertThat(ownerResponse.nickname()).isEqualTo("์Šคํ„ฐ๋””์žฅ"); + + MemberResponse memberResponse = result.members().stream() + .filter(m -> !m.isOwner()) + .findFirst() + .orElseThrow(); + assertThat(memberResponse.memberId()).isEqualTo(memberId); + assertThat(memberResponse.nickname()).isEqualTo("๋ฉค๋ฒ„1"); + } + + @Test + @DisplayName("ํ™œ์„ฑ ๋ฉค๋ฒ„๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_list_when_no_active_members() { + // given + given(studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_STATUSES)) + .willReturn(List.of()); + given(getMemberInfoPort.getMemberInfo(List.of())) + .willReturn(Map.of()); + + // when + GetStudyMembersResponse result = sut.getStudyMembers(studyId); + + // then + assertThat(result.totalMembers()).isZero(); + assertThat(result.members()).isEmpty(); + } + + @Test + @DisplayName("์Šคํ„ฐ๋””์žฅ๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ์—๋„ ์ •์ƒ์ ์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_only_owner_when_no_other_members() { + // given + long ownerId = 10L; + StudyMember owner = StudyMember.create(1L, studyId, ownerId); + MemberInfoResponse ownerInfo = MemberInfoResponse.of("์Šคํ„ฐ๋””์žฅ", "https://image.url/owner.jpg"); + + given(studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_STATUSES)) + .willReturn(List.of(owner)); + given(getMemberInfoPort.getMemberInfo(List.of(ownerId))) + .willReturn(Map.of(ownerId, ownerInfo)); + + // when + GetStudyMembersResponse result = sut.getStudyMembers(studyId); + + // then + assertThat(result.totalMembers()).isEqualTo(1); + assertThat(result.members()).hasSize(1); + assertThat(result.members().get(0).isOwner()).isTrue(); + } + + private StudyMember createApprovedMember(long id, long studyId, long memberId) { + StudyMember member = StudyMember.apply(id, studyId, memberId, "๊ฐ€์ž… ์‹ ์ฒญ"); + ReflectionTestUtils.setField(member, "studyMemberStatus", StudyMemberStatus.APPROVED); + return member; + } + } +} From 0e941edd1759bcdae99ef083b4a0592ec49e0695 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:40:06 +0900 Subject: [PATCH 066/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EC=88=98=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20=EB=B0=B0=EC=B9=98=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/StudyViewCountServiceTest.java | 97 +++++++++++++++++++ .../batch/StudyViewFlushJobTest.java | 94 ++++++++++++++++++ .../batch/StudyViewFlusherTest.java | 38 ++++++++ 3 files changed, 229 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/study/application/query/StudyViewCountServiceTest.java create mode 100644 modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlushJobTest.java create mode 100644 modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlusherTest.java diff --git a/modules/study/src/test/java/kr/spot/study/application/query/StudyViewCountServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/StudyViewCountServiceTest.java new file mode 100644 index 00000000..557d9091 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/study/application/query/StudyViewCountServiceTest.java @@ -0,0 +1,97 @@ +package kr.spot.study.application.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import kr.spot.study.domain.associations.StudyStats; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StudyViewCountService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class StudyViewCountServiceTest { + + @Mock + ViewCounter viewCounter; + + @Mock + ViewAbuseGuard viewAbuseGuard; + + @InjectMocks + StudyViewCountService sut; + + @Nested + @DisplayName("calculateDisplayViewCount ๋ฉ”์„œ๋“œ๋Š”") + class CalculateDisplayViewCountTest { + + private final long studyId = 1L; + private final long viewerId = 100L; + + @Test + @DisplayName("์ƒˆ๋กœ์šด ์กฐํšŒ์ผ ๊ฒฝ์šฐ ์กฐํšŒ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๊ณ  ํ•ฉ์‚ฐํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_increment_and_return_total_when_new_view() { + // given + StudyStats stats = StudyStats.of(studyId); + long expectedDelta = 5L; + + given(viewAbuseGuard.shouldCount(ViewableType.STUDY, studyId, viewerId)) + .willReturn(true); + given(viewCounter.incrementAndGet(ViewableType.STUDY, studyId)) + .willReturn(expectedDelta); + + // when + long result = sut.calculateDisplayViewCount(stats, studyId, viewerId); + + // then + assertThat(result).isEqualTo(expectedDelta); + verify(viewCounter).incrementAndGet(ViewableType.STUDY, studyId); + } + + @Test + @DisplayName("์ค‘๋ณต ์กฐํšŒ์ผ ๊ฒฝ์šฐ ์ฆ๊ฐ€ ์—†์ด ํ˜„์žฌ ๋ธํƒ€๋งŒ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_current_delta_when_duplicate_view() { + // given + StudyStats stats = StudyStats.of(studyId); + long currentDelta = 10L; + + given(viewAbuseGuard.shouldCount(ViewableType.STUDY, studyId, viewerId)) + .willReturn(false); + given(viewCounter.currentDelta(ViewableType.STUDY, studyId)) + .willReturn(currentDelta); + + // when + long result = sut.calculateDisplayViewCount(stats, studyId, viewerId); + + // then + assertThat(result).isEqualTo(currentDelta); + verify(viewCounter, never()).incrementAndGet(ViewableType.STUDY, studyId); + verify(viewCounter).currentDelta(ViewableType.STUDY, studyId); + } + + @Test + @DisplayName("Redis ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ๊ธฐ๋ณธ ์กฐํšŒ์ˆ˜๋งŒ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_base_count_when_redis_error() { + // given + StudyStats stats = StudyStats.of(studyId); + + given(viewAbuseGuard.shouldCount(ViewableType.STUDY, studyId, viewerId)) + .willThrow(new RuntimeException("Redis connection failed")); + + // when + long result = sut.calculateDisplayViewCount(stats, studyId, viewerId); + + // then + assertThat(result).isEqualTo(stats.getViewCount()); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlushJobTest.java b/modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlushJobTest.java new file mode 100644 index 00000000..e928df40 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlushJobTest.java @@ -0,0 +1,94 @@ +package kr.spot.study.infrastructure.batch; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +class StudyViewFlushJobTest { + + private static final String LOCK_KEY = "lock:study-view-flush"; + + @Mock + StringRedisTemplate redis; + + @Mock + ValueOperations valueOps; + + @Mock + StudyViewFlusher studyViewFlusher; + + StudyViewFlushJob job; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(redis.opsForValue()).thenReturn(valueOps); + job = new StudyViewFlushJob(redis, studyViewFlusher); + } + + @Nested + @DisplayName("๋ถ„์‚ฐ ๋ฝ ํ…Œ์ŠคํŠธ") + class DistributedLockTest { + + @Test + @DisplayName("๋ฝ ํš๋“ ์‹คํŒจ ์‹œ ๋ฐฐ์น˜๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_execute_when_lock_not_acquired() { + // given + when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS))) + .thenReturn(false); + + // when + job.flush(); + + // then + verify(redis, never()).execute(any(RedisCallback.class)); + } + + @Test + @DisplayName("๋ฝ ํš๋“ ์„ฑ๊ณต ์‹œ ๋ฐฐ์น˜๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ๋ฝ์„ ํ•ด์ œํ•œ๋‹ค") + void should_execute_and_release_lock_when_acquired() { + // given + when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS))) + .thenReturn(true); + + // when + job.flush(); + + // then + verify(redis).execute(any(RedisCallback.class)); + verify(redis).delete(LOCK_KEY); + } + + @Test + @DisplayName("๋ฐฐ์น˜ ์‹คํ–‰ ์ค‘ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ๋ฝ์„ ํ•ด์ œํ•œ๋‹ค") + void should_release_lock_even_when_exception_occurs() { + // given + when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS))) + .thenReturn(true); + when(redis.execute(any(RedisCallback.class))) + .thenThrow(new RuntimeException("Redis error")); + + // when + try { + job.flush(); + } catch (Exception ignored) { + } + + // then + verify(redis).delete(LOCK_KEY); + } + } +} diff --git a/modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlusherTest.java b/modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlusherTest.java new file mode 100644 index 00000000..48cdcb5f --- /dev/null +++ b/modules/study/src/test/java/kr/spot/study/infrastructure/batch/StudyViewFlusherTest.java @@ -0,0 +1,38 @@ +package kr.spot.study.infrastructure.batch; + +import static org.mockito.Mockito.verify; + +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class StudyViewFlusherTest { + + @Mock + StudyStatsRepository studyStatsRepository; + + StudyViewFlusher flusher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + flusher = new StudyViewFlusher(studyStatsRepository); + } + + @Test + @DisplayName("์Šคํ„ฐ๋”” ์กฐํšŒ์ˆ˜ ๋ธํƒ€๋ฅผ DB์— ๋ฐ˜์˜ํ•œ๋‹ค") + void should_update_view_count_in_db() { + // given + long studyId = 123L; + long delta = 50L; + + // when + flusher.updateViewCount(studyId, delta); + + // then + verify(studyStatsRepository).increaseViewBy(studyId, delta); + } +} From 1850505498195748539516cafcb0ddcbd257e651 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:53:00 +0900 Subject: [PATCH 067/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A7=81=20API=EC=99=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/StudyCommandController.java | 20 +++- .../query/StudyQueryController.java | 97 ------------------- 2 files changed, 19 insertions(+), 98 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java index 41df1348..be35d1b8 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java @@ -13,12 +13,15 @@ import kr.spot.study.presentation.command.dto.request.CreateStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +@Tag(name = "์Šคํ„ฐ๋””") @RestController @RequestMapping("/api/studies") @RequiredArgsConstructor @@ -26,7 +29,6 @@ public class StudyCommandController { private final CreateStudyService createStudyService; - @Tag(name = "์Šคํ„ฐ๋””") @Operation(summary = "์Šคํ„ฐ๋”” ์ƒ์„ฑ", description = "์ƒˆ๋กœ์šด ์Šคํ„ฐ๋””๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์š”์ฒญ ์ •๋ณด๋Š” `multipart/form-data` ํ˜•์‹์œผ๋กœ ๋ณด๋‚ด์•ผ ํ•˜๋ฉฐ, ์ด๋ฏธ์ง€ ํŒŒ์ผ์€ ์„ ํƒ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค." + "request๋Š” application/json ํ˜•์‹์œผ๋กœ ๋ณด๋‚ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") @@ -49,4 +51,20 @@ public ResponseEntity> createStudy(@RequestPart CreateStudyReq createStudyService.createStudy(request, memberId, imageFile); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); } + + @PostMapping("/{studyId}/like") + public ResponseEntity> likeStudy( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @DeleteMapping("/{studyId}/unlike") + public ResponseEntity> unlikeStudy( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java index a3b8f786..17eb7edd 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java @@ -3,22 +3,12 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.List; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; import kr.spot.code.status.SuccessStatus; -import kr.spot.study.application.query.GetMyStudyInfoService; import kr.spot.study.application.query.GetStudyDetailService; -import kr.spot.study.domain.enums.Category; -import kr.spot.study.domain.enums.FeeCategory; -import kr.spot.study.domain.enums.RecruitingStatus; -import kr.spot.study.domain.enums.SortBy; -import kr.spot.study.domain.enums.StudyMemberStatus; import kr.spot.study.presentation.query.dto.response.GetStudyInfoResponse; import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse; -import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -32,7 +22,6 @@ @RequiredArgsConstructor public class StudyQueryController { - private final GetMyStudyInfoService getMyStudyInfoService; private final GetStudyDetailService getStudyDetailService; @Operation(summary = "์Šคํ„ฐ๋”” ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ", @@ -56,91 +45,5 @@ public ResponseEntity> getStudyMembers( getStudyDetailService.getStudyMembers(studyId))); } - @Operation(summary = "๋งˆ์ดํŽ˜์ด์ง€ ์Šคํ„ฐ๋”” ์กฐํšŒ", - description = "๋งˆ์ดํŽ˜์ด์ง€์— ํ•„์š”ํ•œ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.\n " - + "\n ๋ชจ์ง‘ ์ค‘์ธ ์Šคํ„ฐ๋”” : status = OWNER (๊ธฐ๋ณธ๊ฐ’) \n" - + "\n ์ฐธ์—ฌ ์ค‘์ธ ์Šคํ„ฐ๋”” : status = APPROVED \n " - + "\n ๋Œ€๊ธฐ ์ค‘์ธ ์Šคํ„ฐ๋”” : status = APPLIED") - @GetMapping("/me") - public ResponseEntity> getMyStudies( - @CurrentMember @Parameter(hidden = true) Long viewerId, - @RequestParam(required = false, defaultValue = "OWNER") StudyMemberStatus status, - @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size - ) { - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, - getMyStudyInfoService.getMyStudyOverview(viewerId, status, cursor, size))); - } - - @Operation(summary = "์ง€๊ธˆ ๊ฐ€์žฅ ์ธ๊ธฐ์žˆ๋Š” ์Šคํ„ฐ๋”” ์กฐํšŒ - ๊ตฌํ˜„ ์ค‘", - description = "์ง€๊ธˆ ๊ฐ€์žฅ ์ธ๊ธฐ์žˆ๋Š” ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") - @GetMapping("/hot") - public ResponseEntity> getHotStudies( - ) { - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); - } - - @Operation(summary = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋”” ์กฐํšŒ - ๊ตฌํ˜„ ์ค‘", - description = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") - @GetMapping("/recommended") - public ResponseEntity> getRecommendedStudies( - ) { - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); - } - // ํ•„ํ„ฐ๋ง O - @Operation(summary = "๋‚ด ๊ด€์‹ฌ ์ง€์—ญ ์Šคํ„ฐ๋”” ์กฐํšŒ", - description = "๋‚ด ๊ด€์‹ฌ ์ง€์—ญ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ง€์—ญ ์ฝ”๋“œ๋ฅผ ๋ฆฌ์ŠคํŠธ ํ˜•ํƒœ๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”." - + "์•„๋ฌด ๊ฐ’๋„ ์ž…๋ ฅํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ์—๋Š” ํšŒ์›์˜ ์ „์ฒด ๊ด€์‹ฌ ์ง€์—ญ ์Šคํ„ฐ๋””๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") - @GetMapping("/by-region") - public ResponseEntity> getRegionStudies( - @CurrentMember @Parameter(hidden = true) Long viewerId, - @RequestParam(required = false) RecruitingStatus recruitingStatus, - @RequestParam(required = false) FeeCategory feeCategory, - @RequestParam(required = false) List categories, - @RequestParam(required = false) SortBy sortBy, - @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size, - @RequestParam(required = false) List regionCodes - ) { - return ResponseEntity.ok( - ApiResponse.onSuccess(SuccessStatus._OK, getMyStudyInfoService.getMyPreferredRegionStudies( - viewerId, - recruitingStatus, - feeCategory, - categories, - sortBy, - cursor, - size, - regionCodes - ))); - } - - @Operation(summary = "๋‚ด ๊ด€์‹ฌ์‚ฌ ์Šคํ„ฐ๋”” ์กฐํšŒ", - description = "๋‚ด ๊ด€์‹ฌ์‚ฌ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๊ด€์‹ฌ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”." - + "์•„๋ฌด ๊ฐ’๋„ ์ž…๋ ฅํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ์—๋Š” ํšŒ์›์˜ ์ „์ฒด ๊ด€์‹ฌ ์นดํ…Œ๊ณ ๋ฆฌ ์Šคํ„ฐ๋””๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") - @GetMapping("/by-category") - public ResponseEntity> getMyPreferredCategoriesStudies( - @CurrentMember @Parameter(hidden = true) Long viewerId, - @RequestParam(required = false) Category category, - @RequestParam(required = false) RecruitingStatus recruitingStatus, - @RequestParam(required = false) FeeCategory feeCategory, - @RequestParam(required = false) SortBy sortBy, - @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size - ) { - return ResponseEntity.ok( - ApiResponse.onSuccess(SuccessStatus._OK, - getMyStudyInfoService.getMyPreferredCategoryStudies( - viewerId, - category, - recruitingStatus, - feeCategory, - sortBy, - cursor, - size - ))); - } - // TODO ๋ชจ์ง‘์ค‘ ์Šคํ„ฐ๋”” - // TODO ์ „์ฒด ์Šคํ„ฐ๋”” ์กฐํšŒ } From 8758330d311faf16921ace09f6a522a684dd33d2 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:53:07 +0900 Subject: [PATCH 068/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/domain/associations/StudyLike.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/study/domain/associations/StudyLike.java diff --git a/modules/study/src/main/java/kr/spot/study/domain/associations/StudyLike.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyLike.java new file mode 100644 index 00000000..b2a246c3 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyLike.java @@ -0,0 +1,36 @@ +package kr.spot.study.domain.associations; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.spot.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE study_like SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "study_like", + uniqueConstraints = @UniqueConstraint(name = "uk_study_member", columnNames = {"study_id", + "member_id"})) +public class StudyLike extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + private Long memberId; + + public static StudyLike of(Long id, Long studyId, Long memberId) { + return new StudyLike(id, studyId, memberId); + } +} From 864485a7dc7053aa6d2cf72af4f1f297fa187d0b Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 15:53:33 +0900 Subject: [PATCH 069/126] =?UTF-8?q?[Feature]=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=ED=95=9C=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/StudySearchController.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java new file mode 100644 index 00000000..65393306 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java @@ -0,0 +1,135 @@ +package kr.spot.study.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.List; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.study.application.query.GetMyStudyInfoService; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.SortBy; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "์Šคํ„ฐ๋”” ์กฐํšŒ") +@RestController +@RequestMapping("/api/studies") +@RequiredArgsConstructor +public class StudySearchController { + + private final GetMyStudyInfoService getMyStudyInfoService; + + @Operation(summary = "๋งˆ์ดํŽ˜์ด์ง€ ์Šคํ„ฐ๋”” ์กฐํšŒ", + description = "๋งˆ์ดํŽ˜์ด์ง€์— ํ•„์š”ํ•œ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.\n " + + "\n ๋ชจ์ง‘ ์ค‘์ธ ์Šคํ„ฐ๋”” : status = OWNER (๊ธฐ๋ณธ๊ฐ’) \n" + + "\n ์ฐธ์—ฌ ์ค‘์ธ ์Šคํ„ฐ๋”” : status = APPROVED \n " + + "\n ๋Œ€๊ธฐ ์ค‘์ธ ์Šคํ„ฐ๋”” : status = APPLIED") + @GetMapping("/me") + public ResponseEntity> getMyStudies( + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false, defaultValue = "OWNER") StudyMemberStatus status, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, + getMyStudyInfoService.getMyStudyOverview(viewerId, status, cursor, size))); + } + + @Operation(summary = "์ง€๊ธˆ ๊ฐ€์žฅ ์ธ๊ธฐ์žˆ๋Š” ์Šคํ„ฐ๋”” ์กฐํšŒ - ๊ตฌํ˜„ ์ค‘", + description = "์ง€๊ธˆ ๊ฐ€์žฅ ์ธ๊ธฐ์žˆ๋Š” ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") + @GetMapping("/hot") + public ResponseEntity> getHotStudies( + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } + + @Operation(summary = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋”” ์กฐํšŒ - ๊ตฌํ˜„ ์ค‘", + description = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") + @GetMapping("/recommended") + public ResponseEntity> getRecommendedStudies( + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } + + // ํ•„ํ„ฐ๋ง O + @Operation(summary = "๋‚ด ๊ด€์‹ฌ ์ง€์—ญ ์Šคํ„ฐ๋”” ์กฐํšŒ", + description = "๋‚ด ๊ด€์‹ฌ ์ง€์—ญ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ง€์—ญ ์ฝ”๋“œ๋ฅผ ๋ฆฌ์ŠคํŠธ ํ˜•ํƒœ๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”." + + "์•„๋ฌด ๊ฐ’๋„ ์ž…๋ ฅํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ์—๋Š” ํšŒ์›์˜ ์ „์ฒด ๊ด€์‹ฌ ์ง€์—ญ ์Šคํ„ฐ๋””๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/by-region") + public ResponseEntity> getRegionStudies( + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false) RecruitingStatus recruitingStatus, + @RequestParam(required = false) FeeCategory feeCategory, + @RequestParam(required = false) List categories, + @RequestParam(required = false) SortBy sortBy, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size, + @RequestParam(required = false) List regionCodes + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, getMyStudyInfoService.getMyPreferredRegionStudies( + viewerId, + recruitingStatus, + feeCategory, + categories, + sortBy, + cursor, + size, + regionCodes + ))); + } + + @Operation(summary = "๋‚ด ๊ด€์‹ฌ์‚ฌ ์Šคํ„ฐ๋”” ์กฐํšŒ", + description = "๋‚ด ๊ด€์‹ฌ์‚ฌ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๊ด€์‹ฌ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”." + + "์•„๋ฌด ๊ฐ’๋„ ์ž…๋ ฅํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ์—๋Š” ํšŒ์›์˜ ์ „์ฒด ๊ด€์‹ฌ ์นดํ…Œ๊ณ ๋ฆฌ ์Šคํ„ฐ๋””๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping("/by-category") + public ResponseEntity> getMyPreferredCategoriesStudies( + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false) Category category, + @RequestParam(required = false) RecruitingStatus recruitingStatus, + @RequestParam(required = false) FeeCategory feeCategory, + @RequestParam(required = false) SortBy sortBy, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, + getMyStudyInfoService.getMyPreferredCategoryStudies( + viewerId, + category, + recruitingStatus, + feeCategory, + sortBy, + cursor, + size + ))); + } + + @Operation(summary = "๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์Šคํ„ฐ๋”” ์กฐํšŒ", + description = "๋‚ด๊ฐ€ ์ข‹์•„์š”ํ•œ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") + @GetMapping("/liked") + public ResponseEntity> getLikedStudies( + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, null)); + } + + // TODO ๋ชจ์ง‘์ค‘ ์Šคํ„ฐ๋”” + // TODO ์ „์ฒด ์Šคํ„ฐ๋”” ์กฐํšŒ + +} From 4f77e62a57f97864d16a4d47c7e8f5fbc850e05c Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 16:02:13 +0900 Subject: [PATCH 070/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=B0=8F=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/command/StudyLikeService.java | 36 +++++++++++++++++++ .../jpa/associations/StudyLikeRepository.java | 29 +++++++++++++++ .../associations/StudyStatsRepository.java | 8 +++++ .../command/StudyCommandController.java | 8 ++++- 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 modules/study/src/main/java/kr/spot/study/application/command/StudyLikeService.java create mode 100644 modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java diff --git a/modules/study/src/main/java/kr/spot/study/application/command/StudyLikeService.java b/modules/study/src/main/java/kr/spot/study/application/command/StudyLikeService.java new file mode 100644 index 00000000..040e35be --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/command/StudyLikeService.java @@ -0,0 +1,36 @@ +package kr.spot.study.application.command; + +import kr.spot.IdGenerator; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class StudyLikeService { + + private final IdGenerator idGenerator; + private final StudyLikeRepository studyLikeRepository; + private final StudyStatsRepository studyStatsRepository; + + public void likeStudy(long studyId, long memberId) { + int inserted = studyLikeRepository.saveStudyLike(idGenerator.nextId(), studyId, memberId); + increaseLikeCount(studyId, inserted); + } + + private void increaseLikeCount(long studyId, int inserted) { + if (inserted == 1) { + studyStatsRepository.increaseLike(studyId); + } + } + + public void unlikeStudy(long studyId, long memberId) { + int deleted = studyLikeRepository.hardDelete(studyId, memberId); + if (deleted > 0) { + studyStatsRepository.decreaseLike(studyId); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java new file mode 100644 index 00000000..bf097e28 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java @@ -0,0 +1,29 @@ +package kr.spot.study.infrastructure.jpa.associations; + +import kr.spot.study.domain.associations.StudyLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StudyLikeRepository extends JpaRepository { + + @Modifying + @Query( + value = """ + INSERT IGNORE INTO study_like(id, study_id, member_id, status, created_at, updated_at) + VALUES (:id, :studyId, :memberId, 'ACTIVE', NOW(), NOW()) + """, nativeQuery = true) + int saveStudyLike(@Param("id") long id, + @Param("studyId") long studyId, + @Param("memberId") long memberId); + + @Modifying + @Query(value = """ + DELETE FROM study_like + WHERE study_id = :studyId + AND member_id = :memberId + """, nativeQuery = true) + int hardDelete(@Param("studyId") long studyId, + @Param("memberId") long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java index 47292094..c40490af 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java @@ -26,4 +26,12 @@ default StudyStats getByStudyId(long studyId) { where s.studyId = :studyId and s.status = 'ACTIVE' """) int increaseViewBy(@Param("studyId") long studyId, @Param("delta") long delta); + + @Modifying + @Query("update StudyStats s set s.likeCount = s.likeCount + 1 where s.studyId = :studyId") + int increaseLike(@Param("studyId") long studyId); + + @Modifying + @Query("update StudyStats s set s.likeCount = case when s.likeCount > 0 then s.likeCount - 1 else 0 end where s.studyId = :studyId") + int decreaseLike(@Param("studyId") long studyId); } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java index be35d1b8..b4b808e0 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java @@ -10,6 +10,7 @@ import kr.spot.annotations.CurrentMember; import kr.spot.code.status.SuccessStatus; import kr.spot.study.application.command.CreateStudyService; +import kr.spot.study.application.command.StudyLikeService; import kr.spot.study.presentation.command.dto.request.CreateStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -28,6 +29,7 @@ public class StudyCommandController { private final CreateStudyService createStudyService; + private final StudyLikeService studyLikeService; @Operation(summary = "์Šคํ„ฐ๋”” ์ƒ์„ฑ", description = "์ƒˆ๋กœ์šด ์Šคํ„ฐ๋””๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์š”์ฒญ ์ •๋ณด๋Š” `multipart/form-data` ํ˜•์‹์œผ๋กœ ๋ณด๋‚ด์•ผ ํ•˜๋ฉฐ, ์ด๋ฏธ์ง€ ํŒŒ์ผ์€ ์„ ํƒ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค." @@ -52,19 +54,23 @@ public ResponseEntity> createStudy(@RequestPart CreateStudyReq return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); } + @Operation(summary = "์Šคํ„ฐ๋”” ์ข‹์•„์š”", description = "์Šคํ„ฐ๋””์— ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค.") @PostMapping("/{studyId}/like") public ResponseEntity> likeStudy( @PathVariable Long studyId, @CurrentMember @Parameter(hidden = true) Long memberId ) { + studyLikeService.likeStudy(studyId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } - @DeleteMapping("/{studyId}/unlike") + @Operation(summary = "์Šคํ„ฐ๋”” ์ข‹์•„์š” ์ทจ์†Œ", description = "์Šคํ„ฐ๋”” ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค.") + @DeleteMapping("/{studyId}/like") public ResponseEntity> unlikeStudy( @PathVariable Long studyId, @CurrentMember @Parameter(hidden = true) Long memberId ) { + studyLikeService.unlikeStudy(studyId, memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } } From 250efd8053912c9e71b70fb4a093fd7336a7984e Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 16:02:24 +0900 Subject: [PATCH 071/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=B0=8F=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20API=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/StudyLikeServiceTest.java | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 modules/study/src/test/java/kr/spot/study/application/command/StudyLikeServiceTest.java diff --git a/modules/study/src/test/java/kr/spot/study/application/command/StudyLikeServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/command/StudyLikeServiceTest.java new file mode 100644 index 00000000..eb581c0d --- /dev/null +++ b/modules/study/src/test/java/kr/spot/study/application/command/StudyLikeServiceTest.java @@ -0,0 +1,108 @@ +package kr.spot.study.application.command; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import kr.spot.IdGenerator; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStatsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StudyLikeService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class StudyLikeServiceTest { + + @Mock + IdGenerator idGenerator; + + @Mock + StudyLikeRepository studyLikeRepository; + + @Mock + StudyStatsRepository studyStatsRepository; + + @InjectMocks + StudyLikeService sut; + + @Nested + @DisplayName("likeStudy ๋ฉ”์„œ๋“œ๋Š”") + class LikeStudyTest { + + private final long studyId = 1L; + private final long memberId = 100L; + private final long generatedId = 999L; + + @Test + @DisplayName("์ƒˆ๋กœ์šด ์ข‹์•„์š”์ธ ๊ฒฝ์šฐ ์ข‹์•„์š” ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚จ๋‹ค") + void should_increase_like_count_when_new_like() { + // given + given(idGenerator.nextId()).willReturn(generatedId); + given(studyLikeRepository.saveStudyLike(generatedId, studyId, memberId)).willReturn(1); + + // when + sut.likeStudy(studyId, memberId); + + // then + verify(studyLikeRepository).saveStudyLike(generatedId, studyId, memberId); + verify(studyStatsRepository).increaseLike(studyId); + } + + @Test + @DisplayName("์ค‘๋ณต ์ข‹์•„์š”์ธ ๊ฒฝ์šฐ ์ข‹์•„์š” ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค์ง€ ์•Š๋Š”๋‹ค") + void should_not_increase_like_count_when_duplicate_like() { + // given + given(idGenerator.nextId()).willReturn(generatedId); + given(studyLikeRepository.saveStudyLike(generatedId, studyId, memberId)).willReturn(0); + + // when + sut.likeStudy(studyId, memberId); + + // then + verify(studyLikeRepository).saveStudyLike(generatedId, studyId, memberId); + verify(studyStatsRepository, never()).increaseLike(studyId); + } + } + + @Nested + @DisplayName("unlikeStudy ๋ฉ”์„œ๋“œ๋Š”") + class UnlikeStudyTest { + + private final long studyId = 1L; + private final long memberId = 100L; + + @Test + @DisplayName("์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜๋ฉด ์‚ญ์ œํ•˜๊ณ  ์ข‹์•„์š” ์ˆ˜๋ฅผ ๊ฐ์†Œ์‹œํ‚จ๋‹ค") + void should_decrease_like_count_when_like_exists() { + // given + given(studyLikeRepository.hardDelete(studyId, memberId)).willReturn(1); + + // when + sut.unlikeStudy(studyId, memberId); + + // then + verify(studyLikeRepository).hardDelete(studyId, memberId); + verify(studyStatsRepository).decreaseLike(studyId); + } + + @Test + @DisplayName("์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด ์ข‹์•„์š” ์ˆ˜๋ฅผ ๊ฐ์†Œ์‹œํ‚ค์ง€ ์•Š๋Š”๋‹ค") + void should_not_decrease_like_count_when_like_not_exists() { + // given + given(studyLikeRepository.hardDelete(studyId, memberId)).willReturn(0); + + // when + sut.unlikeStudy(studyId, memberId); + + // then + verify(studyLikeRepository).hardDelete(studyId, memberId); + verify(studyStatsRepository, never()).decreaseLike(studyId); + } + } +} From 8192e611a020ccdcef336ee047fc3cbefdac82b6 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 16:02:33 +0900 Subject: [PATCH 072/126] =?UTF-8?q?[Feature]=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=ED=95=9C=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetMyStudyInfoService.java | 7 +++++ .../jpa/querydsl/StudyQueryRepository.java | 30 +++++++++++++++++++ .../query/StudySearchController.java | 3 +- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java index d5e4f127..1045f685 100644 --- a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -139,4 +139,11 @@ private List getCategories(Long viewerId, Category category) { } return preferredRegionCodes; } + + public GetStudyOverviewResponse getLikedStudies(long memberId, Long cursor, int size) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + List rows = studyQueryRepository.findLikedStudies(memberId, cursor, pageSize + 1); + long totalElements = studyQueryRepository.countLikedStudies(memberId); + return toCursorPage(rows, pageSize, totalElements); + } } diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java index 831b78e9..cda33d5b 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java @@ -7,6 +7,7 @@ import kr.spot.study.domain.QStudy; import kr.spot.study.domain.Study; import kr.spot.study.domain.associations.QStudyCategory; +import kr.spot.study.domain.associations.QStudyLike; import kr.spot.study.domain.associations.QStudyMember; import kr.spot.study.domain.associations.QStudyRegion; import kr.spot.study.domain.associations.QStudyStats; @@ -206,4 +207,33 @@ private OrderSpecifier orderBy(SortBy sortBy, QStudy study, QStudyStats study case HITS -> studyStats.viewCount.desc(); }; } + + public List findLikedStudies(long memberId, Long cursor, int limit) { + QStudy study = QStudy.study; + QStudyLike studyLike = QStudyLike.studyLike; + + return query + .select(study) + .from(studyLike) + .join(study).on(study.id.eq(studyLike.studyId)) + .where( + studyLike.memberId.eq(memberId), + ltCursor(cursor, study) + ) + .orderBy(study.id.desc()) + .limit(limit) + .fetch(); + } + + public long countLikedStudies(long memberId) { + QStudy study = QStudy.study; + QStudyLike studyLike = QStudyLike.studyLike; + + return query + .select(study.id.countDistinct()) + .from(studyLike) + .join(study).on(study.id.eq(studyLike.studyId)) + .where(studyLike.memberId.eq(memberId)) + .fetchOne(); + } } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java index 65393306..d1e5d89b 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java @@ -126,7 +126,8 @@ public ResponseEntity> getLikedStudies( @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size ) { return ResponseEntity.ok( - ApiResponse.onSuccess(SuccessStatus._OK, null)); + ApiResponse.onSuccess(SuccessStatus._OK, + getMyStudyInfoService.getLikedStudies(viewerId, cursor, size))); } // TODO ๋ชจ์ง‘์ค‘ ์Šคํ„ฐ๋”” From 0c3d26c2daad9417e9fb0b0248eb8cfa488595e1 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 16:04:36 +0900 Subject: [PATCH 073/126] =?UTF-8?q?[Feature]=20=EC=BD=94=EB=94=A9=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=88=9C=EC=84=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/query/GetMyStudyInfoService.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java index 1045f685..358c796c 100644 --- a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -109,6 +109,13 @@ public GetStudyOverviewResponse getMyPreferredCategoryStudies( return toCursorPage(rows, pageSize, totalElements); } + public GetStudyOverviewResponse getLikedStudies(long memberId, Long cursor, int size) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + List rows = studyQueryRepository.findLikedStudies(memberId, cursor, pageSize + 1); + long totalElements = studyQueryRepository.countLikedStudies(memberId); + return toCursorPage(rows, pageSize, totalElements); + } + private GetStudyOverviewResponse toCursorPage(List rows, int pageSize, Long totalElements) { boolean hasNext = rows.size() > pageSize; @@ -139,11 +146,4 @@ private List getCategories(Long viewerId, Category category) { } return preferredRegionCodes; } - - public GetStudyOverviewResponse getLikedStudies(long memberId, Long cursor, int size) { - final int pageSize = Math.min(size, MAX_PAGE_SIZE); - List rows = studyQueryRepository.findLikedStudies(memberId, cursor, pageSize + 1); - long totalElements = studyQueryRepository.countLikedStudies(memberId); - return toCursorPage(rows, pageSize, totalElements); - } } From cbd625cbe02a6fcbf2a19209af8c371cb8a777c7 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 22:42:34 +0900 Subject: [PATCH 074/126] =?UTF-8?q?[Feature]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spot/application/token/LogoutService.java | 18 ++++++++++++++++++ .../kr/spot/presentation/AuthController.java | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 modules/auth/src/main/java/kr/spot/application/token/LogoutService.java diff --git a/modules/auth/src/main/java/kr/spot/application/token/LogoutService.java b/modules/auth/src/main/java/kr/spot/application/token/LogoutService.java new file mode 100644 index 00000000..43f39dd4 --- /dev/null +++ b/modules/auth/src/main/java/kr/spot/application/token/LogoutService.java @@ -0,0 +1,18 @@ +package kr.spot.application.token; + +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LogoutService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public void logout(long memberId) { + refreshTokenRepository.deleteByMemberId(memberId); + } +} diff --git a/modules/auth/src/main/java/kr/spot/presentation/AuthController.java b/modules/auth/src/main/java/kr/spot/presentation/AuthController.java index cd4e0f16..8bd8a2f4 100644 --- a/modules/auth/src/main/java/kr/spot/presentation/AuthController.java +++ b/modules/auth/src/main/java/kr/spot/presentation/AuthController.java @@ -6,6 +6,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.application.token.LogoutService; import kr.spot.application.token.TokenReissueService; import kr.spot.code.status.SuccessStatus; import kr.spot.presentation.command.dto.TokenDTO; @@ -23,6 +25,7 @@ public class AuthController { private final TokenReissueService tokenReissueService; + private final LogoutService logoutService; @Operation(summary = "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰", description = """ ## ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ํ†ตํ•ด ์ƒˆ๋กœ์šด ์•ก์„ธ์Šค ํ† ํฐ๊ณผ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ๋ฐœ๊ธ‰๋ฐ›์Šต๋‹ˆ๋‹ค. @@ -45,4 +48,20 @@ public ResponseEntity> reissueToken( return ResponseEntity.ok( ApiResponse.onSuccess(SuccessStatus._OK, tokenReissueService.reissueToken(refreshToken))); } + + @Operation(summary = "๋กœ๊ทธ์•„์›ƒ", description = """ + ## ๋กœ๊ทธ์•„์›ƒ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + - ์„œ๋ฒ„์— ์ €์žฅ๋œ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + - ์•ก์„ธ์Šค ํ† ํฐ์€ ๋งŒ๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ์œ ํšจํ•˜๋ฏ€๋กœ, ํด๋ผ์ด์–ธํŠธ์—์„œ๋„ ํ† ํฐ์„ ์‚ญ์ œํ•ด์ฃผ์„ธ์š”. + """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + }) + @PostMapping("/logout") + public ResponseEntity> logout( + @CurrentMember long memberId + ) { + logoutService.logout(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } } From b53eab0e27f4cd9988078a00f0bfd6f94e0afbb3 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 22:43:48 +0900 Subject: [PATCH 075/126] =?UTF-8?q?[Feature]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/token/LogoutServiceTest.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 modules/auth/src/test/java/kr/spot/application/token/LogoutServiceTest.java diff --git a/modules/auth/src/test/java/kr/spot/application/token/LogoutServiceTest.java b/modules/auth/src/test/java/kr/spot/application/token/LogoutServiceTest.java new file mode 100644 index 00000000..1b320c6a --- /dev/null +++ b/modules/auth/src/test/java/kr/spot/application/token/LogoutServiceTest.java @@ -0,0 +1,36 @@ +package kr.spot.application.token; + +import static kr.spot.common.AuthFixture.MEMBER_ID; +import static org.mockito.Mockito.verify; + +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LogoutServiceTest { + + @Mock + RefreshTokenRepository refreshTokenRepository; + + LogoutService service; + + @BeforeEach + void setUp() { + service = new LogoutService(refreshTokenRepository); + } + + @Test + @DisplayName("๋กœ๊ทธ์•„์›ƒ ์‹œ ํ•ด๋‹น ํšŒ์›์˜ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteRefreshToken_when_logout() { + // when + service.logout(MEMBER_ID); + + // then + verify(refreshTokenRepository).deleteByMemberId(MEMBER_ID); + } +} From 0ab2187da5c462a1432977671453e596668585bb Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 23:17:25 +0900 Subject: [PATCH 076/126] =?UTF-8?q?[Feature]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/spot/code/status/ErrorStatus.java | 1 + .../event/RevokeTokenOnMemberWithdrawn.java | 20 +++++++++++ .../command/MemberWithdrawService.java | 33 +++++++++++++++++++ .../command/MemberCommandController.java | 20 +++++++++++ .../CleanupNotificationOnMemberWithdrawn.java | 20 +++++++++++ .../jpa/NotificationRepository.java | 1 + .../event/CleanupPostOnMemberWithdrawn.java | 27 +++++++++++++++ .../infrastructure/jpa/CommentRepository.java | 2 ++ .../jpa/PostLikeRepository.java | 4 +++ .../infrastructure/jpa/PostRepository.java | 2 ++ .../domain/events/MemberWithdrawnEvent.java | 5 +++ .../ports/HasActiveStudyAsLeaderPort.java | 6 ++++ .../CleanupStudyPostOnMemberWithdrawn.java | 29 ++++++++++++++++ .../infrastructure/jpa/CommentRepository.java | 1 + .../jpa/PostLikeRepository.java | 4 +++ .../infrastructure/jpa/PostRepository.java | 2 ++ .../event/CleanupReviewOnMemberWithdrawn.java | 26 +++++++++++++++ .../jpa/ReviewReactionRepository.java | 4 +++ .../infrastructure/jpa/ReviewRepository.java | 2 ++ .../CleanupAttendanceOnMemberWithdrawn.java | 22 +++++++++++++ .../jpa/AttendanceRepository.java | 2 ++ ...eanupStudyMembershipOnMemberWithdrawn.java | 26 +++++++++++++++ .../ports/HasActiveStudyAsLeaderService.java | 20 +++++++++++ .../infrastructure/jpa/StudyRepository.java | 2 ++ .../jpa/associations/StudyLikeRepository.java | 4 +++ .../associations/StudyMemberRepository.java | 1 + .../event/CleanupTodoOnMemberWithdrawn.java | 22 +++++++++++++ .../infrastructure/jpa/TodoRepository.java | 2 ++ 28 files changed, 310 insertions(+) create mode 100644 modules/auth/src/main/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawn.java create mode 100644 modules/member/src/main/java/kr/spot/application/command/MemberWithdrawService.java create mode 100644 modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java create mode 100644 modules/post/src/main/java/kr/spot/application/event/CleanupPostOnMemberWithdrawn.java create mode 100644 modules/shared/src/main/java/kr/spot/domain/events/MemberWithdrawnEvent.java create mode 100644 modules/study-api/src/main/java/kr/spot/ports/HasActiveStudyAsLeaderPort.java create mode 100644 modules/study/src/main/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawn.java create mode 100644 modules/study/src/main/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawn.java create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawn.java create mode 100644 modules/study/src/main/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawn.java create mode 100644 modules/study/src/main/java/kr/spot/study/application/ports/HasActiveStudyAsLeaderService.java create mode 100644 modules/study/src/main/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawn.java diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index 058033b3..668c7efc 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -32,6 +32,7 @@ public enum ErrorStatus implements BaseErrorCode { _MEMBER_UNSUPPORTED_LOGIN_TYPE(400, "MEMBER4003", "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ํƒ€์ž…์ž…๋‹ˆ๋‹ค."), _MEMBER_EMAIL_EXIST(400, "MEMBER4004", "์ด๋ฏธ ํ•ด๋‹น ๋ฐฉ์‹์œผ๋กœ ๊ฐ€์ž… ๋‚ด์—ญ์ด ์กด์žฌํ•˜๋Š” ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."), _FAIL_TO_UPDATE_NAME(400, "MEMBER4005", "์ด๋ฆ„ ๋ณ€๊ฒฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + _CANNOT_WITHDRAW_WITH_ACTIVE_STUDY(400, "MEMBER4006", "์šด์˜ ์ค‘์ธ ์Šคํ„ฐ๋””๊ฐ€ ์žˆ์–ด ํƒˆํ‡ดํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), // ์Šคํ„ฐ๋”” ๊ด€๋ จ _MAX_MEMBERS_MUST_BE_POSITIVE(400, "STUDY4000", "์ตœ๋Œ€ ์ธ์› ์ˆ˜๋Š” ์–‘์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."), diff --git a/modules/auth/src/main/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawn.java b/modules/auth/src/main/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawn.java new file mode 100644 index 00000000..75ecf4a7 --- /dev/null +++ b/modules/auth/src/main/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawn.java @@ -0,0 +1,20 @@ +package kr.spot.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class RevokeTokenOnMemberWithdrawn { + + private final RefreshTokenRepository refreshTokenRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + refreshTokenRepository.deleteByMemberId(event.memberId()); + } +} diff --git a/modules/member/src/main/java/kr/spot/application/command/MemberWithdrawService.java b/modules/member/src/main/java/kr/spot/application/command/MemberWithdrawService.java new file mode 100644 index 00000000..97b1071a --- /dev/null +++ b/modules/member/src/main/java/kr/spot/application/command/MemberWithdrawService.java @@ -0,0 +1,33 @@ +package kr.spot.application.command; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.exception.GeneralException; +import kr.spot.infrastructure.jpa.MemberRepository; +import kr.spot.ports.HasActiveStudyAsLeaderPort; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberWithdrawService { + + private final MemberRepository memberRepository; + private final ApplicationEventPublisher eventPublisher; + private final HasActiveStudyAsLeaderPort hasActiveStudyAsLeaderPort; + + @Transactional + public void withdraw(long memberId) { + validateCanWithdraw(memberId); + memberRepository.deleteById(memberId); + eventPublisher.publishEvent(new MemberWithdrawnEvent(memberId)); + } + + private void validateCanWithdraw(long memberId) { + if (hasActiveStudyAsLeaderPort.hasActiveStudyAsLeader(memberId)) { + throw new GeneralException(ErrorStatus._CANNOT_WITHDRAW_WITH_ACTIVE_STUDY); + } + } +} diff --git a/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java b/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java index 1b2d155c..4ffa7e84 100644 --- a/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java +++ b/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; +import kr.spot.application.command.MemberWithdrawService; import kr.spot.application.command.RegisterMemberInfoService; import kr.spot.application.command.RegisterPreferredCategoryService; import kr.spot.application.command.RegisterPreferredRegionService; @@ -17,6 +18,7 @@ import kr.spot.presentation.command.dto.request.UpdateMemberNameRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -31,6 +33,7 @@ public class MemberCommandController { private final RegisterPreferredCategoryService registerPreferredCategoryService; private final RegisterPreferredRegionService registerPreferredRegionService; private final RegisterMemberInfoService registerMemberInfoService; + private final MemberWithdrawService memberWithdrawService; @Operation(summary = "์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ ๋“ฑ๋ก", description = "ํšŒ์›์˜ ์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ ์ €์žฅ ํ›„ ๋‹ค์‹œ ํ˜ธ์ถœํ•˜๋ฉด ๋ฎ์–ด์”๋‹ˆ๋‹ค. (์ด์ „ ๋ฐ์ดํ„ฐ ์‚ญ์ œ)") @ApiResponses({ @@ -83,4 +86,21 @@ public ResponseEntity> updateMemberName( registerMemberInfoService.updateMemberName(memberId, request); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); } + + @Operation(summary = "ํšŒ์› ํƒˆํ‡ด", description = """ + ## ํšŒ์› ํƒˆํ‡ด๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + - ํšŒ์› ์ •๋ณด ๋ฐ ๊ด€๋ จ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ชจ๋‘ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค. + - ํด๋ผ์ด์–ธํŠธ์—์„œ๋„ ์ €์žฅ๋œ ํ† ํฐ์„ ์‚ญ์ œํ•ด์ฃผ์„ธ์š”. + """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "ํƒˆํ‡ด ์„ฑ๊ณต"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "์ธ์ฆ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @DeleteMapping("/me") + public ResponseEntity> withdraw( + @CurrentMember @Parameter(hidden = true) long memberId + ) { + memberWithdrawService.withdraw(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } } diff --git a/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java b/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java new file mode 100644 index 00000000..de746d6e --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java @@ -0,0 +1,20 @@ +package kr.spot.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CleanupNotificationOnMemberWithdrawn { + + private final NotificationRepository notificationRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + notificationRepository.deleteByTargetTargetMemberId(event.memberId()); + } +} diff --git a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java index fd7191eb..eadf8a00 100644 --- a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java +++ b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java @@ -5,4 +5,5 @@ public interface NotificationRepository extends JpaRepository { + void deleteByTargetTargetMemberId(long targetMemberId); } diff --git a/modules/post/src/main/java/kr/spot/application/event/CleanupPostOnMemberWithdrawn.java b/modules/post/src/main/java/kr/spot/application/event/CleanupPostOnMemberWithdrawn.java new file mode 100644 index 00000000..3c751960 --- /dev/null +++ b/modules/post/src/main/java/kr/spot/application/event/CleanupPostOnMemberWithdrawn.java @@ -0,0 +1,27 @@ +package kr.spot.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.CommentRepository; +import kr.spot.infrastructure.jpa.PostLikeRepository; +import kr.spot.infrastructure.jpa.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CleanupPostOnMemberWithdrawn { + + private final PostLikeRepository postLikeRepository; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + postLikeRepository.deleteAllByMemberId(memberId); + commentRepository.deleteByWriterInfoWriterId(memberId); + postRepository.deleteByWriterInfoWriterId(memberId); + } +} diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java index 3913596c..0b832fdc 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java @@ -17,4 +17,6 @@ default Comment getById(Long id) { return findById(id) .orElseThrow(() -> new GeneralException(ErrorStatus._COMMENT_NOT_FOUND)); } + + void deleteByWriterInfoWriterId(long writerId); } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java index 94236e03..c53364f1 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java @@ -26,4 +26,8 @@ int savePostLike(@Param("id") long id, """, nativeQuery = true) int hardDelete(@Param("postId") Long postId, @Param("memberId") Long memberId); + + @Modifying + @Query(value = "DELETE FROM post_like WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java index 050a127c..a29917a4 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java @@ -65,4 +65,6 @@ default void validateExists(long postId) { throw new GeneralException(ErrorStatus._POST_NOT_FOUND); } } + + void deleteByWriterInfoWriterId(long writerId); } diff --git a/modules/shared/src/main/java/kr/spot/domain/events/MemberWithdrawnEvent.java b/modules/shared/src/main/java/kr/spot/domain/events/MemberWithdrawnEvent.java new file mode 100644 index 00000000..704a8669 --- /dev/null +++ b/modules/shared/src/main/java/kr/spot/domain/events/MemberWithdrawnEvent.java @@ -0,0 +1,5 @@ +package kr.spot.domain.events; + +public record MemberWithdrawnEvent(long memberId) { + +} diff --git a/modules/study-api/src/main/java/kr/spot/ports/HasActiveStudyAsLeaderPort.java b/modules/study-api/src/main/java/kr/spot/ports/HasActiveStudyAsLeaderPort.java new file mode 100644 index 00000000..c21e0504 --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/HasActiveStudyAsLeaderPort.java @@ -0,0 +1,6 @@ +package kr.spot.ports; + +public interface HasActiveStudyAsLeaderPort { + + boolean hasActiveStudyAsLeader(long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawn.java new file mode 100644 index 00000000..c99bfe66 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawn.java @@ -0,0 +1,29 @@ +package kr.spot.post.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.post.infrastructure.jpa.CommentRepository; +import kr.spot.post.infrastructure.jpa.PostLikeRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(5) +@Component +@RequiredArgsConstructor +public class CleanupStudyPostOnMemberWithdrawn { + + private final PostLikeRepository postLikeRepository; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + postLikeRepository.deleteAllByMemberId(memberId); + commentRepository.deleteByWriterInfoWriterId(memberId); + postRepository.deleteByWriterInfoWriterId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java index d0e9a0aa..0221f94e 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java @@ -14,4 +14,5 @@ default Comment getById(long id) { .orElseThrow(() -> new GeneralException(ErrorStatus._COMMENT_NOT_FOUND)); } + void deleteByWriterInfoWriterId(long writerId); } diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java index 4bb4ee2b..36e6609d 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java @@ -28,4 +28,8 @@ int savePostLike(@Param("id") long id, """, nativeQuery = true) int hardDelete(@Param("postId") Long postId, @Param("memberId") Long memberId); + + @Modifying + @Query(value = "DELETE FROM study_post_like WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); } diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java index 61fa9cf4..5ecea141 100644 --- a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java @@ -13,4 +13,6 @@ default Post getById(long postId) { return findById(postId).orElseThrow( () -> new GeneralException(ErrorStatus._POST_NOT_FOUND)); } + + void deleteByWriterInfoWriterId(long writerId); } diff --git a/modules/study/src/main/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawn.java new file mode 100644 index 00000000..62914fe5 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawn.java @@ -0,0 +1,26 @@ +package kr.spot.review.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(4) +@Component +@RequiredArgsConstructor +public class CleanupReviewOnMemberWithdrawn { + + private final ReviewReactionRepository reviewReactionRepository; + private final ReviewRepository reviewRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + reviewReactionRepository.deleteAllByMemberId(memberId); + reviewRepository.deleteByWriterInfoWriterId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java index 53d81390..9d53b424 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java @@ -22,4 +22,8 @@ boolean existsByReviewIdAndMemberIdAndReaction( int hardDelete(@Param("reviewId") Long reviewId, @Param("memberId") Long memberId, @Param("reaction") String reaction); + + @Modifying + @Query(value = "DELETE FROM review_reaction WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); } diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java index 866fdcfa..d6daf770 100644 --- a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java @@ -17,4 +17,6 @@ default void validateExists(long reviewId) { throw new GeneralException(ErrorStatus._REVIEW_NOT_FOUND); } } + + void deleteByWriterInfoWriterId(long writerId); } diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawn.java new file mode 100644 index 00000000..9c11f2e9 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawn.java @@ -0,0 +1,22 @@ +package kr.spot.schedule.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(2) +@Component +@RequiredArgsConstructor +public class CleanupAttendanceOnMemberWithdrawn { + + private final AttendanceRepository attendanceRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + attendanceRepository.deleteByMemberInfoMemberId(event.memberId()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java index 188b44e5..20fd8151 100644 --- a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java @@ -12,4 +12,6 @@ public interface AttendanceRepository extends JpaRepository { boolean existsByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); List findAllByScheduleId(Long scheduleId); + + void deleteByMemberInfoMemberId(long memberId); } diff --git a/modules/study/src/main/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawn.java new file mode 100644 index 00000000..9fb887a1 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawn.java @@ -0,0 +1,26 @@ +package kr.spot.study.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(1) +@Component +@RequiredArgsConstructor +public class CleanupStudyMembershipOnMemberWithdrawn { + + private final StudyMemberRepository studyMemberRepository; + private final StudyLikeRepository studyLikeRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + studyMemberRepository.deleteByMemberId(memberId); + studyLikeRepository.deleteAllByMemberId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/ports/HasActiveStudyAsLeaderService.java b/modules/study/src/main/java/kr/spot/study/application/ports/HasActiveStudyAsLeaderService.java new file mode 100644 index 00000000..132929fe --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/ports/HasActiveStudyAsLeaderService.java @@ -0,0 +1,20 @@ +package kr.spot.study.application.ports; + +import kr.spot.ports.HasActiveStudyAsLeaderPort; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class HasActiveStudyAsLeaderService implements HasActiveStudyAsLeaderPort { + + private final StudyRepository studyRepository; + + @Override + public boolean hasActiveStudyAsLeader(long memberId) { + return studyRepository.existsByLeaderId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java index 8e449bf4..71a499eb 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java @@ -14,4 +14,6 @@ default Study getStudyById(Long studyId) { } long countByLeaderIdAndRecruitingStatus(long leaderId, RecruitingStatus recruitingStatus); + + boolean existsByLeaderId(long leaderId); } diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java index bf097e28..cbb7bab1 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java @@ -26,4 +26,8 @@ int saveStudyLike(@Param("id") long id, """, nativeQuery = true) int hardDelete(@Param("studyId") long studyId, @Param("memberId") long memberId); + + @Modifying + @Query(value = "DELETE FROM study_like WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); } diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java index df1985f0..e33c9858 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -25,4 +25,5 @@ long countByMemberIdAndStudyMemberStatusIn(long memberId, long countByMemberIdAndStudyMemberStatus(long memberId, StudyMemberStatus studyMemberStatus); + void deleteByMemberId(long memberId); } diff --git a/modules/study/src/main/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawn.java new file mode 100644 index 00000000..36c1336f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawn.java @@ -0,0 +1,22 @@ +package kr.spot.todo.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.todo.infrastructure.jpa.TodoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(3) +@Component +@RequiredArgsConstructor +public class CleanupTodoOnMemberWithdrawn { + + private final TodoRepository todoRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + todoRepository.deleteByMemberId(event.memberId()); + } +} diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java index 623fe3c7..49dbab69 100644 --- a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java +++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java @@ -11,4 +11,6 @@ default Todo getById(long todoId) { return findById(todoId).orElseThrow( () -> new GeneralException(ErrorStatus._TODO_NOT_FOUND)); } + + void deleteByMemberId(long memberId); } From 50c0acce77d8080c6d0c6c456d5c0837b5c88135 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 23:20:29 +0900 Subject: [PATCH 077/126] =?UTF-8?q?[Feature]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=EB=84=88=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RevokeTokenOnMemberWithdrawnTest.java | 40 ++++++++++++++ ...anupNotificationOnMemberWithdrawnTest.java | 41 ++++++++++++++ .../CleanupPostOnMemberWithdrawnTest.java | 53 +++++++++++++++++++ ...CleanupStudyPostOnMemberWithdrawnTest.java | 53 +++++++++++++++++++ .../CleanupReviewOnMemberWithdrawnTest.java | 48 +++++++++++++++++ ...leanupAttendanceOnMemberWithdrawnTest.java | 41 ++++++++++++++ ...pStudyMembershipOnMemberWithdrawnTest.java | 46 ++++++++++++++++ .../CleanupTodoOnMemberWithdrawnTest.java | 41 ++++++++++++++ 8 files changed, 363 insertions(+) create mode 100644 modules/auth/src/test/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawnTest.java create mode 100644 modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java create mode 100644 modules/post/src/test/java/kr/spot/application/event/CleanupPostOnMemberWithdrawnTest.java create mode 100644 modules/study/src/test/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawnTest.java create mode 100644 modules/study/src/test/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawnTest.java create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawnTest.java create mode 100644 modules/study/src/test/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawnTest.java create mode 100644 modules/study/src/test/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawnTest.java diff --git a/modules/auth/src/test/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawnTest.java b/modules/auth/src/test/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawnTest.java new file mode 100644 index 00000000..672ef9e8 --- /dev/null +++ b/modules/auth/src/test/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawnTest.java @@ -0,0 +1,40 @@ +package kr.spot.application.event; + +import static kr.spot.common.AuthFixture.MEMBER_ID; +import static org.mockito.Mockito.verify; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RevokeTokenOnMemberWithdrawnTest { + + @Mock + RefreshTokenRepository refreshTokenRepository; + + RevokeTokenOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new RevokeTokenOnMemberWithdrawn(refreshTokenRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ํ•ด๋‹น ํšŒ์›์˜ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteRefreshToken_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + verify(refreshTokenRepository).deleteByMemberId(MEMBER_ID); + } +} diff --git a/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java b/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java new file mode 100644 index 00000000..5a9b7bc4 --- /dev/null +++ b/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java @@ -0,0 +1,41 @@ +package kr.spot.application.event; + +import static org.mockito.Mockito.verify; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupNotificationOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + NotificationRepository notificationRepository; + + CleanupNotificationOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupNotificationOnMemberWithdrawn(notificationRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ์•Œ๋ฆผ์„ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteNotifications_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + verify(notificationRepository).deleteByTargetTargetMemberId(MEMBER_ID); + } +} diff --git a/modules/post/src/test/java/kr/spot/application/event/CleanupPostOnMemberWithdrawnTest.java b/modules/post/src/test/java/kr/spot/application/event/CleanupPostOnMemberWithdrawnTest.java new file mode 100644 index 00000000..650bf0d5 --- /dev/null +++ b/modules/post/src/test/java/kr/spot/application/event/CleanupPostOnMemberWithdrawnTest.java @@ -0,0 +1,53 @@ +package kr.spot.application.event; + +import static org.mockito.Mockito.inOrder; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.CommentRepository; +import kr.spot.infrastructure.jpa.PostLikeRepository; +import kr.spot.infrastructure.jpa.PostRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupPostOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + PostLikeRepository postLikeRepository; + + @Mock + CommentRepository commentRepository; + + @Mock + PostRepository postRepository; + + CleanupPostOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupPostOnMemberWithdrawn(postLikeRepository, commentRepository, postRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ์ข‹์•„์š”, ๋Œ“๊ธ€, ๊ฒŒ์‹œ๊ธ€ ์ˆœ์„œ๋กœ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteLikesThenCommentsThenPosts_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + InOrder inOrder = inOrder(postLikeRepository, commentRepository, postRepository); + inOrder.verify(postLikeRepository).deleteAllByMemberId(MEMBER_ID); + inOrder.verify(commentRepository).deleteByWriterInfoWriterId(MEMBER_ID); + inOrder.verify(postRepository).deleteByWriterInfoWriterId(MEMBER_ID); + } +} diff --git a/modules/study/src/test/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawnTest.java b/modules/study/src/test/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawnTest.java new file mode 100644 index 00000000..c0dcd680 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawnTest.java @@ -0,0 +1,53 @@ +package kr.spot.post.application.event; + +import static org.mockito.Mockito.inOrder; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.post.infrastructure.jpa.CommentRepository; +import kr.spot.post.infrastructure.jpa.PostLikeRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupStudyPostOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + PostLikeRepository postLikeRepository; + + @Mock + CommentRepository commentRepository; + + @Mock + PostRepository postRepository; + + CleanupStudyPostOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupStudyPostOnMemberWithdrawn(postLikeRepository, commentRepository, postRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ์ข‹์•„์š”, ๋Œ“๊ธ€, ๊ฒŒ์‹œ๊ธ€ ์ˆœ์„œ๋กœ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteLikesThenCommentsThenPosts_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + InOrder inOrder = inOrder(postLikeRepository, commentRepository, postRepository); + inOrder.verify(postLikeRepository).deleteAllByMemberId(MEMBER_ID); + inOrder.verify(commentRepository).deleteByWriterInfoWriterId(MEMBER_ID); + inOrder.verify(postRepository).deleteByWriterInfoWriterId(MEMBER_ID); + } +} diff --git a/modules/study/src/test/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawnTest.java b/modules/study/src/test/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawnTest.java new file mode 100644 index 00000000..2ba82946 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawnTest.java @@ -0,0 +1,48 @@ +package kr.spot.review.application.event; + +import static org.mockito.Mockito.inOrder; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupReviewOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + ReviewReactionRepository reviewReactionRepository; + + @Mock + ReviewRepository reviewRepository; + + CleanupReviewOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupReviewOnMemberWithdrawn(reviewReactionRepository, reviewRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ๋ฆฌ๋ทฐ ๋ฐ˜์‘์„ ๋จผ์ € ์‚ญ์ œํ•˜๊ณ  ๋ฆฌ๋ทฐ๋ฅผ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteReactionsThenReviews_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + InOrder inOrder = inOrder(reviewReactionRepository, reviewRepository); + inOrder.verify(reviewReactionRepository).deleteAllByMemberId(MEMBER_ID); + inOrder.verify(reviewRepository).deleteByWriterInfoWriterId(MEMBER_ID); + } +} diff --git a/modules/study/src/test/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawnTest.java b/modules/study/src/test/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawnTest.java new file mode 100644 index 00000000..c9fb8259 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawnTest.java @@ -0,0 +1,41 @@ +package kr.spot.schedule.application.event; + +import static org.mockito.Mockito.verify; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupAttendanceOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + AttendanceRepository attendanceRepository; + + CleanupAttendanceOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupAttendanceOnMemberWithdrawn(attendanceRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ์ถœ์„ ๊ธฐ๋ก์„ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteAttendance_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + verify(attendanceRepository).deleteByMemberInfoMemberId(MEMBER_ID); + } +} diff --git a/modules/study/src/test/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawnTest.java b/modules/study/src/test/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawnTest.java new file mode 100644 index 00000000..c1dd2590 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawnTest.java @@ -0,0 +1,46 @@ +package kr.spot.study.application.event; + +import static org.mockito.Mockito.verify; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupStudyMembershipOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + StudyMemberRepository studyMemberRepository; + + @Mock + StudyLikeRepository studyLikeRepository; + + CleanupStudyMembershipOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupStudyMembershipOnMemberWithdrawn(studyMemberRepository, studyLikeRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ์Šคํ„ฐ๋”” ๋ฉค๋ฒ„์‹ญ๊ณผ ์ข‹์•„์š”๋ฅผ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteStudyMembershipAndLikes_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + verify(studyMemberRepository).deleteByMemberId(MEMBER_ID); + verify(studyLikeRepository).deleteAllByMemberId(MEMBER_ID); + } +} diff --git a/modules/study/src/test/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawnTest.java b/modules/study/src/test/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawnTest.java new file mode 100644 index 00000000..8216e014 --- /dev/null +++ b/modules/study/src/test/java/kr/spot/todo/application/event/CleanupTodoOnMemberWithdrawnTest.java @@ -0,0 +1,41 @@ +package kr.spot.todo.application.event; + +import static org.mockito.Mockito.verify; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.todo.infrastructure.jpa.TodoRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupTodoOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + TodoRepository todoRepository; + + CleanupTodoOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupTodoOnMemberWithdrawn(todoRepository); + } + + @Test + @DisplayName("ํšŒ์› ํƒˆํ‡ด ์‹œ ํˆฌ๋‘๋ฅผ ์‚ญ์ œํ•œ๋‹ค") + void should_deleteTodo_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + verify(todoRepository).deleteByMemberId(MEMBER_ID); + } +} From f398ce505f5bcdd738dbf152aa3addfbb9938714 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 23:38:41 +0900 Subject: [PATCH 078/126] =?UTF-8?q?[Feature]=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=94=94=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetMyStudyInfoService.java | 47 ++++++++ .../jpa/querydsl/StudyQueryRepository.java | 42 ++++++++ .../query/StudySearchController.java | 8 +- .../query/GetMyStudyInfoServiceTest.java | 101 ++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java index 358c796c..e024e834 100644 --- a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -1,5 +1,7 @@ package kr.spot.study.application.query; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import kr.spot.ports.GetPreferredCategoryPort; import kr.spot.ports.GetPreferredRegionPort; @@ -116,6 +118,51 @@ public GetStudyOverviewResponse getLikedStudies(long memberId, Long cursor, int return toCursorPage(rows, pageSize, totalElements); } + public GetStudyOverviewResponse getRecommendedStudies(long memberId) { + final int recommendCount = 3; + + List preferredCategories = getPreferredCategories(memberId); + List result = pickRandomStudiesFromPreferred(preferredCategories, recommendCount); + fillWithPopularStudies(result, recommendCount); + + return StudyDTOMapper.toDTO(result, false, null, (long) result.size()); + } + + private List getPreferredCategories(long memberId) { + return getPreferredCategoryPort.get(memberId) + .stream() + .map(Category::fromString) + .toList(); + } + + private List pickRandomStudiesFromPreferred(List categories, int count) { + if (categories.isEmpty()) { + return new ArrayList<>(); + } + + final int fetchLimit = 20; + List candidates = studyQueryRepository.findRecruitingStudiesByCategories( + categories, fetchLimit); + + List shuffled = new ArrayList<>(candidates); + Collections.shuffle(shuffled); + + return new ArrayList<>(shuffled.stream().limit(count).toList()); + } + + private void fillWithPopularStudies(List result, int targetCount) { + if (result.size() >= targetCount) { + return; + } + + List excludeIds = result.stream().map(Study::getId).toList(); + int remaining = targetCount - result.size(); + + List popularStudies = studyQueryRepository.findPopularRecruitingStudies( + excludeIds, remaining); + result.addAll(popularStudies); + } + private GetStudyOverviewResponse toCursorPage(List rows, int pageSize, Long totalElements) { boolean hasNext = rows.size() > pageSize; diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java index cda33d5b..f6aa8510 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java @@ -236,4 +236,46 @@ public long countLikedStudies(long memberId) { .where(studyLike.memberId.eq(memberId)) .fetchOne(); } + + public List findRecruitingStudiesByCategories(List categories, int limit) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + + return query + .select(study) + .from(study) + .join(studyCategory).on(studyCategory.studyId.eq(study.id)) + .where( + studyCategory.category.in(categories), + study.recruitingStatus.eq(RecruitingStatus.RECRUITING) + ) + .groupBy(study.id) + .orderBy(study.id.desc()) + .limit(limit) + .fetch(); + } + + public List findPopularRecruitingStudies(List excludeIds, int limit) { + QStudy study = QStudy.study; + QStudyStats studyStats = QStudyStats.studyStats; + + return query + .select(study) + .from(study) + .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + notInIds(excludeIds, study) + ) + .orderBy(studyStats.likeCount.desc(), study.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression notInIds(List excludeIds, QStudy study) { + if (excludeIds == null || excludeIds.isEmpty()) { + return null; + } + return study.id.notIn(excludeIds); + } } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java index d1e5d89b..d9a837b7 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java @@ -55,12 +55,14 @@ public ResponseEntity> getHotStudies( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); } - @Operation(summary = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋”” ์กฐํšŒ - ๊ตฌํ˜„ ์ค‘", - description = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. (page, size ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ)") + @Operation(summary = "์ „๊ณต/์ง„๋กœํ•™์Šต ์ด๊ฑด ์–ด๋•Œ์š”? ์Šคํ„ฐ๋”” ์กฐํšŒ", + description = "ํšŒ์›์˜ ์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ์— ํ•ด๋‹นํ•˜๋Š” ์Šคํ„ฐ๋”” ์ค‘ ๋žœ๋คํ•˜๊ฒŒ 3๊ฐœ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๋งค ์š”์ฒญ๋งˆ๋‹ค ๊ฒฐ๊ณผ๊ฐ€ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") @GetMapping("/recommended") public ResponseEntity> getRecommendedStudies( + @CurrentMember @Parameter(hidden = true) long memberId ) { - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, + getMyStudyInfoService.getRecommendedStudies(memberId))); } // ํ•„ํ„ฐ๋ง O diff --git a/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java index 3c8ea97d..908f3efd 100644 --- a/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import java.util.Collections; @@ -306,4 +307,104 @@ void should_return_empty_when_no_preferred_regions() { assertThat(response.nextCursor()).isNull(); } } + + @Nested + @DisplayName("์ถ”์ฒœ ์Šคํ„ฐ๋”” ์กฐํšŒ (getRecommendedStudies)") + class GetRecommendedStudies { + + private final long memberId = 1L; + + @Test + @DisplayName("์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ ์Šคํ„ฐ๋””๊ฐ€ 3๊ฐœ ์ด์ƒ์ด๋ฉด, ์ธ๊ธฐ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•˜์ง€ ์•Š๋Š”๋‹ค") + void should_not_fetch_popular_when_enough_preferred_studies() { + // given + List preferredCategoryNames = List.of("SELF_STUDY", "CAREER"); + List preferredCategories = List.of(Category.SELF_STUDY, Category.CAREER); + List candidates = createStudies(10); + + given(getPreferredCategoryPort.get(memberId)).willReturn(preferredCategoryNames); + given(studyQueryRepository.findRecruitingStudiesByCategories(preferredCategories, 20)) + .willReturn(candidates); + + // when + GetStudyOverviewResponse response = getMyStudyInfoService.getRecommendedStudies(memberId); + + // then + verify(getPreferredCategoryPort).get(memberId); + verify(studyQueryRepository).findRecruitingStudiesByCategories(preferredCategories, 20); + verify(studyQueryRepository, never()).findPopularRecruitingStudies(any(), anyInt()); + assertThat(response.content()).hasSize(3); + assertThat(response.totalElements()).isEqualTo(3L); + } + + @Test + @DisplayName("์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ ์Šคํ„ฐ๋””๊ฐ€ 3๊ฐœ ๋ฏธ๋งŒ์ด๋ฉด, ์ธ๊ธฐ ์Šคํ„ฐ๋””๋กœ ๋ถ€์กฑํ•œ ๋งŒํผ ์ฑ„์šด๋‹ค") + void should_fill_with_popular_studies_when_not_enough_preferred() { + // given + List preferredCategoryNames = List.of("SELF_STUDY"); + List preferredCategories = List.of(Category.SELF_STUDY); + List preferredStudies = createStudies(1); + List popularStudies = createStudies(2); + + given(getPreferredCategoryPort.get(memberId)).willReturn(preferredCategoryNames); + given(studyQueryRepository.findRecruitingStudiesByCategories(preferredCategories, 20)) + .willReturn(preferredStudies); + given(studyQueryRepository.findPopularRecruitingStudies( + List.of(preferredStudies.getFirst().getId()), 2)) + .willReturn(popularStudies); + + // when + GetStudyOverviewResponse response = getMyStudyInfoService.getRecommendedStudies(memberId); + + // then + verify(studyQueryRepository).findRecruitingStudiesByCategories(preferredCategories, 20); + verify(studyQueryRepository).findPopularRecruitingStudies( + List.of(preferredStudies.getFirst().getId()), 2); + assertThat(response.content()).hasSize(3); + assertThat(response.totalElements()).isEqualTo(3L); + } + + @Test + @DisplayName("์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด, ์ธ๊ธฐ ์Šคํ„ฐ๋”” 3๊ฐœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_popular_studies_when_no_preferred_categories() { + // given + List popularStudies = createStudies(3); + + given(getPreferredCategoryPort.get(memberId)).willReturn(Collections.emptyList()); + given(studyQueryRepository.findPopularRecruitingStudies(Collections.emptyList(), 3)) + .willReturn(popularStudies); + + // when + GetStudyOverviewResponse response = getMyStudyInfoService.getRecommendedStudies(memberId); + + // then + verify(getPreferredCategoryPort).get(memberId); + verify(studyQueryRepository).findPopularRecruitingStudies(Collections.emptyList(), 3); + assertThat(response.content()).hasSize(3); + assertThat(response.totalElements()).isEqualTo(3L); + } + + @Test + @DisplayName("์„ ํ˜ธ ์นดํ…Œ๊ณ ๋ฆฌ ์Šคํ„ฐ๋””์™€ ์ธ๊ธฐ ์Šคํ„ฐ๋”” ๋ชจ๋‘ ์—†์œผ๋ฉด, ๋นˆ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void should_return_empty_when_no_studies_at_all() { + // given + List preferredCategoryNames = List.of("SELF_STUDY"); + List preferredCategories = List.of(Category.SELF_STUDY); + + given(getPreferredCategoryPort.get(memberId)).willReturn(preferredCategoryNames); + given(studyQueryRepository.findRecruitingStudiesByCategories(preferredCategories, 20)) + .willReturn(Collections.emptyList()); + given(studyQueryRepository.findPopularRecruitingStudies(Collections.emptyList(), 3)) + .willReturn(Collections.emptyList()); + + // when + GetStudyOverviewResponse response = getMyStudyInfoService.getRecommendedStudies(memberId); + + // then + verify(studyQueryRepository).findRecruitingStudiesByCategories(preferredCategories, 20); + verify(studyQueryRepository).findPopularRecruitingStudies(Collections.emptyList(), 3); + assertThat(response.content()).isEmpty(); + assertThat(response.totalElements()).isEqualTo(0L); + } + } } From d22f0f2e00a45140eac8f32635a21bb1164dc794 Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 31 Dec 2025 23:54:14 +0900 Subject: [PATCH 079/126] =?UTF-8?q?[Feature]=20=EB=AA=A8=EC=A7=91=20?= =?UTF-8?q?=EC=A4=91,=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B3=84?= =?UTF-8?q?=20=EC=8A=A4=ED=84=B0=EB=94=94=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/GetMyStudyInfoService.java | 84 +++++++++-- .../main/java/kr/spot/study/domain/Study.java | 12 +- .../jpa/querydsl/StudyQueryRepository.java | 130 +++++++++++++++++- .../query/StudySearchController.java | 55 +++++++- .../query/GetMyStudyInfoServiceTest.java | 50 +++---- 5 files changed, 286 insertions(+), 45 deletions(-) diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java index e024e834..28664f1c 100644 --- a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -51,62 +51,128 @@ public GetStudyOverviewResponse getMyStudyOverview( } public GetStudyOverviewResponse getMyPreferredRegionStudies( - Long viewerId, + long viewerId, RecruitingStatus recruitingStatus, FeeCategory feeCategory, List categories, + Boolean isOnline, SortBy sortBy, Long cursor, - Integer size, + int size, List regionCodes ) { final int pageSize = Math.min(size, MAX_PAGE_SIZE); List preferredRegionCodes = getPreferredRegionPort.get(viewerId); + List filteredRegionCodes = filterPreferredRegionCodes(regionCodes, preferredRegionCodes); List rows = studyQueryRepository.findMyPreferredRegionStudies( recruitingStatus, feeCategory, categories, + isOnline, sortBy, cursor, pageSize + 1, - filterPreferredRegionCodes(regionCodes, preferredRegionCodes) + filteredRegionCodes ); long totalElements = studyQueryRepository.countMyPreferredRegionStudies( recruitingStatus, feeCategory, categories, - filterPreferredRegionCodes(regionCodes, preferredRegionCodes) + isOnline, + filteredRegionCodes ); return toCursorPage(rows, pageSize, totalElements); } public GetStudyOverviewResponse getMyPreferredCategoryStudies( - Long viewerId, + long viewerId, Category category, RecruitingStatus recruitingStatus, FeeCategory feeCategory, + Boolean isOnline, SortBy sortBy, Long cursor, - Integer size + int size ) { final int pageSize = Math.min(size, MAX_PAGE_SIZE); - List preferredRegionCodes = getCategories(viewerId, category); + List preferredCategories = getCategories(viewerId, category); List rows = studyQueryRepository.findMyPreferredCategoryStudies( recruitingStatus, feeCategory, + isOnline, sortBy, cursor, pageSize + 1, - preferredRegionCodes + preferredCategories ); long totalElements = studyQueryRepository.countMyPreferredCategoryStudies( recruitingStatus, feeCategory, - preferredRegionCodes + isOnline, + preferredCategories + ); + return toCursorPage(rows, pageSize, totalElements); + } + + public GetStudyOverviewResponse getRecruitingStudies( + long viewerId, + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + + List rows = studyQueryRepository.findRecruitingStudies( + feeCategory, + categories, + isOnline, + sortBy, + cursor, + pageSize + 1 + ); + + long totalElements = studyQueryRepository.countRecruitingStudies( + feeCategory, + categories, + isOnline + ); + return toCursorPage(rows, pageSize, totalElements); + } + + public GetStudyOverviewResponse getStudiesByCategory( + long viewerId, + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + + List rows = studyQueryRepository.findStudiesByCategory( + recruitingStatus, + feeCategory, + category, + isOnline, + sortBy, + cursor, + pageSize + 1 + ); + + long totalElements = studyQueryRepository.countStudiesByCategory( + recruitingStatus, + feeCategory, + category, + isOnline ); return toCursorPage(rows, pageSize, totalElements); } diff --git a/modules/study/src/main/java/kr/spot/study/domain/Study.java b/modules/study/src/main/java/kr/spot/study/domain/Study.java index 4c74946b..f40a161e 100644 --- a/modules/study/src/main/java/kr/spot/study/domain/Study.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Study.java @@ -57,17 +57,25 @@ public class Study extends BaseEntity { @Enumerated(EnumType.STRING) private RecruitingStatus recruitingStatus; + @Column(nullable = false) + private Boolean isOnline = false; + public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, String description) { - return of(id, leaderId, name, maxMembers, fee, null, description); + return of(id, leaderId, name, maxMembers, fee, null, description, false); } public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, String imageUrl, String description) { + return of(id, leaderId, name, maxMembers, fee, imageUrl, description, false); + } + + public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, + String imageUrl, String description, Boolean isOnline) { validateStudyNameIsNotBlank(name); validateMaxMembers(maxMembers); return new Study(id, leaderId, name, maxMembers, CURRENT_MEMBERS, fee, imageUrl, description, - RecruitingStatus.RECRUITING); + RecruitingStatus.RECRUITING, isOnline != null && isOnline); } private static void validateStudyNameIsNotBlank(String name) { diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java index f6aa8510..85c4c7d4 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java @@ -69,6 +69,7 @@ public List findMyPreferredRegionStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, List categories, + Boolean isOnline, SortBy sortBy, Long cursor, int limit, @@ -90,6 +91,7 @@ public List findMyPreferredRegionStudies( eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), inCategories(categories, studyCategory), + eqIsOnline(isOnline, study), ltCursor(cursor, study) ) .groupBy(study.id) @@ -105,6 +107,7 @@ public long countMyPreferredRegionStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, List categories, + Boolean isOnline, List regionCodes ) { QStudy study = QStudy.study; @@ -120,7 +123,8 @@ public long countMyPreferredRegionStudies( studyRegion.regionCode.in(regionCodes), eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory) + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study) ) .fetchOne(); } @@ -128,6 +132,7 @@ public long countMyPreferredRegionStudies( public List findMyPreferredCategoryStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, + Boolean isOnline, SortBy sortBy, Long cursor, int limit, @@ -146,6 +151,7 @@ public List findMyPreferredCategoryStudies( eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), inCategories(categories, studyCategory), + eqIsOnline(isOnline, study), ltCursor(cursor, study) ) .groupBy(study.id) @@ -160,6 +166,7 @@ public List findMyPreferredCategoryStudies( public long countMyPreferredCategoryStudies( RecruitingStatus recruitingStatus, FeeCategory feeCategory, + Boolean isOnline, List categories ) { QStudy study = QStudy.study; @@ -172,7 +179,118 @@ public long countMyPreferredCategoryStudies( .where( eqRecruitingStatus(recruitingStatus, study), eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory) + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + public List findRecruitingStudies( + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + QStudyStats studyStats = QStudyStats.studyStats; + + return query + .select(study) + .from(study) + .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) + .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + eqFeeCategory(feeCategory, study), + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .groupBy(study.id) + .orderBy( + orderBy(sortBy, study, studyStats), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countRecruitingStudies( + FeeCategory feeCategory, + List categories, + Boolean isOnline + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + + return query + .select(study.id.countDistinct()) + .from(study) + .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + eqFeeCategory(feeCategory, study), + inCategories(categories, studyCategory), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + public List findStudiesByCategory( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + QStudyStats studyStats = QStudyStats.studyStats; + + return query + .select(study) + .from(study) + .join(studyCategory).on(studyCategory.studyId.eq(study.id)) + .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) + .where( + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqCategory(category, studyCategory), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .groupBy(study.id) + .orderBy( + orderBy(sortBy, study, studyStats), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countStudiesByCategory( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline + ) { + QStudy study = QStudy.study; + QStudyCategory studyCategory = QStudyCategory.studyCategory; + + return query + .select(study.id.countDistinct()) + .from(study) + .join(studyCategory).on(studyCategory.studyId.eq(study.id)) + .where( + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqCategory(category, studyCategory), + eqIsOnline(isOnline, study) ) .fetchOne(); } @@ -192,6 +310,14 @@ private BooleanExpression inCategories(List categories, QStudyCategory return studyCategory.category.in(categories); } + private BooleanExpression eqCategory(Category category, QStudyCategory studyCategory) { + return category == null ? null : studyCategory.category.eq(category); + } + + private BooleanExpression eqIsOnline(Boolean isOnline, QStudy study) { + return isOnline == null ? null : study.isOnline.eq(isOnline); + } + private BooleanExpression ltCursor(Long cursor, QStudy study) { return cursor == null ? null : study.id.lt(cursor); } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java index d9a837b7..4ff628e2 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudySearchController.java @@ -75,6 +75,7 @@ public ResponseEntity> getRegionStudies( @RequestParam(required = false) RecruitingStatus recruitingStatus, @RequestParam(required = false) FeeCategory feeCategory, @RequestParam(required = false) List categories, + @RequestParam(required = false) Boolean isOnline, @RequestParam(required = false) SortBy sortBy, @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size, @@ -86,6 +87,7 @@ public ResponseEntity> getRegionStudies( recruitingStatus, feeCategory, categories, + isOnline, sortBy, cursor, size, @@ -102,6 +104,7 @@ public ResponseEntity> getMyPreferredCateg @RequestParam(required = false) Category category, @RequestParam(required = false) RecruitingStatus recruitingStatus, @RequestParam(required = false) FeeCategory feeCategory, + @RequestParam(required = false) Boolean isOnline, @RequestParam(required = false) SortBy sortBy, @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size @@ -113,6 +116,7 @@ public ResponseEntity> getMyPreferredCateg category, recruitingStatus, feeCategory, + isOnline, sortBy, cursor, size @@ -132,7 +136,54 @@ public ResponseEntity> getLikedStudies( getMyStudyInfoService.getLikedStudies(viewerId, cursor, size))); } - // TODO ๋ชจ์ง‘์ค‘ ์Šคํ„ฐ๋”” - // TODO ์ „์ฒด ์Šคํ„ฐ๋”” ์กฐํšŒ + @Operation(summary = "๋ชจ์ง‘ ์ค‘ ์Šคํ„ฐ๋”” ์กฐํšŒ", + description = "๋ชจ์ง‘ ์ค‘์ธ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์นดํ…Œ๊ณ ๋ฆฌ, ์ •๋ ฌ ๋ฐฉ์‹ ๋“ฑ์„ ํ•„ํ„ฐ๋งํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + @GetMapping("/recruiting") + public ResponseEntity> getRecruitingStudies( + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false) FeeCategory feeCategory, + @RequestParam(required = false) List categories, + @RequestParam(required = false) Boolean isOnline, + @RequestParam(required = false) SortBy sortBy, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, getMyStudyInfoService.getRecruitingStudies( + viewerId, + feeCategory, + categories, + isOnline, + sortBy, + cursor, + size + ))); + } + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ ๋ณ„ ์Šคํ„ฐ๋”” ์กฐํšŒ", + description = "์นดํ…Œ๊ณ ๋ฆฌ ๋ณ„ ์Šคํ„ฐ๋””๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๋ชจ์ง‘ ์ƒํƒœ, ์ •๋ ฌ ๋ฐฉ์‹ ๋“ฑ์„ ํ•„ํ„ฐ๋งํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + @GetMapping("/categories") + public ResponseEntity> getStudiesByCategory( + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false) RecruitingStatus recruitingStatus, + @RequestParam(required = false) FeeCategory feeCategory, + @RequestParam(required = false) Category category, + @RequestParam(required = false) Boolean isOnline, + @RequestParam(required = false) SortBy sortBy, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, getMyStudyInfoService.getStudiesByCategory( + viewerId, + recruitingStatus, + feeCategory, + category, + isOnline, + sortBy, + cursor, + size + ))); + } } diff --git a/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java index 908f3efd..a5ea8f11 100644 --- a/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java @@ -145,7 +145,7 @@ void should_cap_page_size_at_max_page_size() { @DisplayName("๋‚ด ๊ด€์‹ฌ ์ง€์—ญ ์Šคํ„ฐ๋”” ๋ชฉ๋ก ์กฐํšŒ (getMyPreferredRegionStudies)") class GetMyPreferredRegionStudies { - private final Long viewerId = 1L; + private final long viewerId = 1L; private final int pageSize = 10; @Test @@ -158,23 +158,20 @@ void should_find_studies_in_all_preferred_regions_when_no_filter() { given(getPreferredRegionPort.get(viewerId)).willReturn(preferredRegions); given(studyQueryRepository.findMyPreferredRegionStudies(any(), any(), any(), any(), any(), - anyInt(), - eq(preferredRegions))) + any(), anyInt(), eq(preferredRegions))) .willReturn(studies); // when GetStudyOverviewResponse response = getMyStudyInfoService.getMyPreferredRegionStudies( viewerId, RecruitingStatus.RECRUITING, FeeCategory.ABOVE_50K, Collections.emptyList(), - SortBy.HITS, - null, pageSize, Collections.emptyList() + null, SortBy.HITS, null, pageSize, Collections.emptyList() ); // then verify(getPreferredRegionPort).get(viewerId); verify(studyQueryRepository).findMyPreferredRegionStudies( eq(RecruitingStatus.RECRUITING), eq(FeeCategory.ABOVE_50K), eq(Collections.emptyList()), - eq(SortBy.HITS), - eq(null), eq(pageSize + 1), eq(preferredRegions) + eq(null), eq(SortBy.HITS), eq(null), eq(pageSize + 1), eq(preferredRegions) ); assertThat(response.content()).hasSize(pageSize); assertThat(response.hasNext()).isTrue(); @@ -192,21 +189,19 @@ void should_find_studies_in_filtered_preferred_regions() { given(getPreferredRegionPort.get(viewerId)).willReturn(preferredRegions); given(studyQueryRepository.findMyPreferredRegionStudies(any(), any(), any(), any(), any(), - anyInt(), - eq(expectedRegions))) + any(), anyInt(), eq(expectedRegions))) .willReturn(studies); // when GetStudyOverviewResponse response = getMyStudyInfoService.getMyPreferredRegionStudies( viewerId, RecruitingStatus.RECRUITING, FeeCategory.ABOVE_50K, Collections.emptyList(), - SortBy.HITS, - null, pageSize, filterRegions + null, SortBy.HITS, null, pageSize, filterRegions ); // then verify(getPreferredRegionPort).get(viewerId); verify(studyQueryRepository).findMyPreferredRegionStudies( - any(), any(), any(), any(), any(), eq(pageSize + 1), eq(expectedRegions) + any(), any(), any(), any(), any(), any(), eq(pageSize + 1), eq(expectedRegions) ); assertThat(response.content()).hasSize(5); assertThat(response.hasNext()).isFalse(); @@ -218,21 +213,19 @@ void should_return_empty_when_no_preferred_regions() { // given given(getPreferredRegionPort.get(viewerId)).willReturn(Collections.emptyList()); given(studyQueryRepository.findMyPreferredRegionStudies(any(), any(), any(), any(), any(), - anyInt(), - eq(Collections.emptyList()))) + any(), anyInt(), eq(Collections.emptyList()))) .willReturn(Collections.emptyList()); // when GetStudyOverviewResponse response = getMyStudyInfoService.getMyPreferredRegionStudies( viewerId, RecruitingStatus.RECRUITING, FeeCategory.ABOVE_50K, Collections.emptyList(), - SortBy.HITS, - null, pageSize, Collections.emptyList() + null, SortBy.HITS, null, pageSize, Collections.emptyList() ); // then verify(getPreferredRegionPort).get(viewerId); verify(studyQueryRepository).findMyPreferredRegionStudies( - any(), any(), any(), any(), any(), eq(pageSize + 1), eq(Collections.emptyList()) + any(), any(), any(), any(), any(), any(), eq(pageSize + 1), eq(Collections.emptyList()) ); assertThat(response.content()).isEmpty(); assertThat(response.hasNext()).isFalse(); @@ -244,7 +237,7 @@ void should_return_empty_when_no_preferred_regions() { @DisplayName("๋‚ด ๊ด€์‹ฌ ์นดํ…Œ๊ณ ๋ฆฌ ์Šคํ„ฐ๋”” ๋ชฉ๋ก ์กฐํšŒ (getMyPreferredCategoryStudies)") class GetMyPreferredCategoryStudies { - private final Long viewerId = 1L; + private final long viewerId = 1L; private final int pageSize = 10; @Test @@ -257,23 +250,21 @@ void should_find_studies_in_all_preferred_regions_when_no_filter() { Long expectedNextCursor = studies.get(pageSize - 1).getId(); given(getPreferredCategoryPort.get(viewerId)).willReturn(preferredCategoryName); - given( - studyQueryRepository.findMyPreferredCategoryStudies(any(), any(), any(), any(), anyInt(), - eq(preferredCategory))) + given(studyQueryRepository.findMyPreferredCategoryStudies(any(), any(), any(), any(), any(), + anyInt(), eq(preferredCategory))) .willReturn(studies); // when GetStudyOverviewResponse response = getMyStudyInfoService.getMyPreferredCategoryStudies( viewerId, null, RecruitingStatus.RECRUITING, FeeCategory.ABOVE_50K, - SortBy.HITS, null, pageSize + null, SortBy.HITS, null, pageSize ); // then verify(getPreferredCategoryPort).get(viewerId); verify(studyQueryRepository).findMyPreferredCategoryStudies( - eq(RecruitingStatus.RECRUITING), eq(FeeCategory.ABOVE_50K), eq(SortBy.HITS), - eq(null), eq(pageSize + 1), - eq(List.of(Category.SELF_STUDY, Category.CAREER)) + eq(RecruitingStatus.RECRUITING), eq(FeeCategory.ABOVE_50K), eq(null), eq(SortBy.HITS), + eq(null), eq(pageSize + 1), eq(preferredCategory) ); assertThat(response.content()).hasSize(pageSize); assertThat(response.hasNext()).isTrue(); @@ -286,21 +277,20 @@ void should_find_studies_in_all_preferred_regions_when_no_filter() { void should_return_empty_when_no_preferred_regions() { // given given(getPreferredCategoryPort.get(viewerId)).willReturn(Collections.emptyList()); - given( - studyQueryRepository.findMyPreferredCategoryStudies(any(), any(), any(), any(), anyInt(), - eq(Collections.emptyList()))) + given(studyQueryRepository.findMyPreferredCategoryStudies(any(), any(), any(), any(), any(), + anyInt(), eq(Collections.emptyList()))) .willReturn(Collections.emptyList()); // when GetStudyOverviewResponse response = getMyStudyInfoService.getMyPreferredCategoryStudies( viewerId, null, RecruitingStatus.RECRUITING, FeeCategory.ABOVE_50K, - SortBy.HITS, null, pageSize + null, SortBy.HITS, null, pageSize ); // then verify(getPreferredCategoryPort).get(viewerId); verify(studyQueryRepository).findMyPreferredCategoryStudies( - any(), any(), any(), any(), eq(pageSize + 1), eq(Collections.emptyList()) + any(), any(), any(), any(), any(), eq(pageSize + 1), eq(Collections.emptyList()) ); assertThat(response.content()).isEmpty(); assertThat(response.hasNext()).isFalse(); From a2e89c1f158f7049a770ed4cbd5564510edddc7d Mon Sep 17 00:00:00 2001 From: msk226 Date: Thu, 1 Jan 2026 00:05:11 +0900 Subject: [PATCH 080/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=8B=9C,=20=EC=98=A8/=EC=98=A4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=9C=A0=EB=AC=B4=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/spot/study/application/command/CreateStudyService.java | 2 +- .../presentation/command/dto/request/CreateStudyRequest.java | 1 + .../spot/study/application/command/CreateStudyServiceTest.java | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java index 7a7f03ef..03970adf 100644 --- a/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java @@ -40,7 +40,7 @@ public class CreateStudyService { public void createStudy(CreateStudyRequest request, Long leaderId, MultipartFile imageFile) { long studyId = idGenerator.nextId(); Study study = Study.of(studyId, leaderId, request.name(), request.maxMembers(), - Fee.of(request.hasFee(), request.amount()), request.description()); + Fee.of(request.hasFee(), request.amount()), null, request.description(), request.isOnline()); StudyStats studyStats = StudyStats.of(studyId); StudyMember studyMember = StudyMember.create(idGenerator.nextId(), studyId, leaderId); diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java index 551eba53..06094850 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java @@ -10,6 +10,7 @@ public record CreateStudyRequest( Boolean hasFee, Integer amount, String description, + Boolean isOnline, Set categories, Set