diff --git a/build.gradle b/build.gradle index 442ee48e..b90ab79b 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,8 @@ sonarqube { '**/*Dto*.java, **/*Request*.java, **/*Response*.java, **/*Exception*.java, **/*ErrorCode*.java, **/*Validator*.java,' + '**/*FcmService*.java, **/EventImage.java, **/Notification.java, **/Favorites.java ,**/ImageEventListener.java,' + '**/TempAlbum.java, **/TempAlbumImage.java, **/TempAlbumType.java, **/TempAlbumType.java, **/FileExtension.java,' + - '**/RefundTask.java, **/RefundTaskStatus.java, cherrypic-batch/**, **/StorageUnitConverter.java, **/ImageRepositoryImpl.java' + '**/RefundTask.java, **/RefundTaskStatus.java, cherrypic-batch/**, **/StorageUnitConverter.java, **/ImageRepositoryImpl.java' + + '**/AlbumParticipationHistory.java, **/ParticipationAction.java' property 'sonar.java.coveragePlugin', 'jacoco' } } diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepository.java b/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepository.java new file mode 100644 index 00000000..6c6a0ea8 --- /dev/null +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepository.java @@ -0,0 +1,8 @@ +package org.cherrypic.domain.album.repository; + +import org.cherrypic.album.entity.AlbumParticipationHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AlbumParticipationHistoryRepository + extends JpaRepository, + AlbumParticipationHistoryRepositoryCustom {} diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepositoryCustom.java b/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepositoryCustom.java new file mode 100644 index 00000000..5d025b1d --- /dev/null +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepositoryCustom.java @@ -0,0 +1,10 @@ +package org.cherrypic.domain.album.repository; + +import org.cherrypic.domain.member.dto.response.ParticipationHistoryResponse; +import org.cherrypic.global.pagination.SortDirection; +import org.springframework.data.domain.Slice; + +public interface AlbumParticipationHistoryRepositoryCustom { + Slice findParticipationHistory( + Long memberId, Long lastHistoryId, int size, SortDirection direction); +} diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepositoryImpl.java b/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepositoryImpl.java new file mode 100644 index 00000000..e9439b70 --- /dev/null +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/album/repository/AlbumParticipationHistoryRepositoryImpl.java @@ -0,0 +1,71 @@ +package org.cherrypic.domain.album.repository; + +import static org.cherrypic.album.entity.QAlbumParticipationHistory.albumParticipationHistory; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.cherrypic.domain.member.dto.response.ParticipationHistoryResponse; +import org.cherrypic.global.pagination.SortDirection; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class AlbumParticipationHistoryRepositoryImpl + implements AlbumParticipationHistoryRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Slice findParticipationHistory( + Long memberId, Long lastHistoryId, int size, SortDirection direction) { + List results = + queryFactory + .select( + Projections.constructor( + ParticipationHistoryResponse.class, + albumParticipationHistory.id, + albumParticipationHistory.albumTitleSnapshot, + albumParticipationHistory.action, + albumParticipationHistory.createdAt)) + .from(albumParticipationHistory) + .where( + albumParticipationHistory.memberId.eq(memberId), + lastHistoryIdCondition(lastHistoryId, direction)) + .orderBy( + direction == SortDirection.DESC + ? albumParticipationHistory.id.desc() + : albumParticipationHistory.id.asc()) + .limit(size + 1) + .fetch(); + + return checkLastPage(size, results); + } + + private BooleanExpression lastHistoryIdCondition(Long historyId, SortDirection direction) { + if (historyId == null) { + return null; + } + + return direction == SortDirection.DESC + ? albumParticipationHistory.id.lt(historyId) + : albumParticipationHistory.id.gt(historyId); + } + + private Slice checkLastPage( + int pageSize, List results) { + boolean hasNext = false; + + if (results.size() > pageSize) { + hasNext = true; + results.remove(pageSize); + } + + return new SliceImpl<>(results, PageRequest.of(0, pageSize), hasNext); + } +} diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/album/service/AlbumServiceImpl.java b/cherrypic-api/src/main/java/org/cherrypic/domain/album/service/AlbumServiceImpl.java index 5eda0f35..993a1837 100644 --- a/cherrypic-api/src/main/java/org/cherrypic/domain/album/service/AlbumServiceImpl.java +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/album/service/AlbumServiceImpl.java @@ -5,14 +5,17 @@ import java.util.Objects; import lombok.RequiredArgsConstructor; import org.cherrypic.album.entity.Album; +import org.cherrypic.album.entity.AlbumParticipationHistory; import org.cherrypic.album.entity.InvitationCode; import org.cherrypic.album.enums.AlbumType; +import org.cherrypic.album.enums.ParticipationAction; import org.cherrypic.domain.album.dto.event.AlbumDeleteNotificationSendEvent; import org.cherrypic.domain.album.dto.event.AlbumImagesDeleteEvent; import org.cherrypic.domain.album.dto.request.AlbumCreateRequest; import org.cherrypic.domain.album.dto.request.AlbumUpdateRequest; import org.cherrypic.domain.album.dto.response.*; import org.cherrypic.domain.album.exception.AlbumErrorCode; +import org.cherrypic.domain.album.repository.AlbumParticipationHistoryRepository; import org.cherrypic.domain.album.repository.AlbumRepository; import org.cherrypic.domain.album.repository.InvitationCodeRepository; import org.cherrypic.domain.event.repository.EventImageRepository; @@ -65,6 +68,7 @@ public class AlbumServiceImpl implements AlbumService { private final ImageRepository imageRepository; private final EventImageRepository eventImageRepository; private final NotificationRepository notificationRepository; + private final AlbumParticipationHistoryRepository albumParticipationHistoryRepository; private final ApplicationEventPublisher eventPublisher; @@ -105,6 +109,10 @@ public AlbumCreateResponse createAlbum(AlbumCreateRequest request) { Subscription.createSubscription(currentMember, album, payment.getPaidAt())); } + albumParticipationHistoryRepository.save( + AlbumParticipationHistory.createAlbumParticipationHistory( + currentMember.getId(), album.getTitle(), ParticipationAction.JOIN)); + return AlbumCreateResponse.from(album); } @@ -190,6 +198,10 @@ public AlbumJoinResponse joinAlbum(Long albumId, String code) { favoritesRepository.save(Favorites.createFavorites(participant)); + albumParticipationHistoryRepository.save( + AlbumParticipationHistory.createAlbumParticipationHistory( + currentMember.getId(), album.getTitle(), ParticipationAction.JOIN)); + return AlbumJoinResponse.from(participant); } @@ -272,6 +284,10 @@ public void deleteAlbum(Long albumId) { paymentRepository.deleteAllByAlbumId(album.getId()); albumRepository.deleteByAlbumId(album.getId()); + + albumParticipationHistoryRepository.save( + AlbumParticipationHistory.createAlbumParticipationHistory( + currentMember.getId(), album.getTitle(), ParticipationAction.DELETED)); } private Album getAlbumById(Long albumId) { diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/member/controller/MemberController.java b/cherrypic-api/src/main/java/org/cherrypic/domain/member/controller/MemberController.java index 37030307..8447f7f4 100644 --- a/cherrypic-api/src/main/java/org/cherrypic/domain/member/controller/MemberController.java +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/member/controller/MemberController.java @@ -1,6 +1,7 @@ package org.cherrypic.domain.member.controller; 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.Valid; import lombok.RequiredArgsConstructor; @@ -9,17 +10,25 @@ import org.cherrypic.domain.member.dto.response.LocalImageDeletionToggleResponse; import org.cherrypic.domain.member.dto.response.MemberInfoResponse; import org.cherrypic.domain.member.dto.response.MemberProfileUpdateResponse; +import org.cherrypic.domain.member.dto.response.ParticipationHistoryResponse; import org.cherrypic.domain.member.service.MemberService; +import org.cherrypic.domain.member.service.ParticipationHistoryQueryService; +import org.cherrypic.global.annotation.PageSize; +import org.cherrypic.global.pagination.SliceResponse; +import org.cherrypic.global.pagination.SortDirection; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/members") @RequiredArgsConstructor @Tag(name = "1-2. 회원 API", description = "회원 관련 API입니다.") +@Validated public class MemberController { private final MemberService memberService; + private final ParticipationHistoryQueryService participationHistoryQueryService; @GetMapping("/me") @Operation(summary = "회원 정보 조회", description = "로그인한 회원 정보를 조회합니다.") @@ -40,6 +49,20 @@ public LocalImageDeletionToggleResponse localImageDeletionToggle() { return memberService.toggleLocalImageDeletion(); } + @GetMapping("/me/participation-history") + @Operation(summary = "앨범 참여 이력 조회", description = "사용자가 생성/입장, 삭제, 퇴장, 강퇴된 앨범 이력을 조회합니다.") + public SliceResponse participationHistoryGet( + @Parameter(description = "이전 페이지의 마지막 앨범 참여 이력 ID (첫 요청 시 생략)") + @RequestParam(required = false) + Long lastHistoryId, + @Parameter(description = "페이지당 조회할 참여 이력 수") @RequestParam @PageSize Integer size, + @Parameter(description = "정렬 방향 (ASC: 오래된순, DESC: 최신순)") + @RequestParam(defaultValue = "DESC") + SortDirection direction) { + return participationHistoryQueryService.getParticipationHistory( + lastHistoryId, size, direction); + } + @PostMapping("/fcm-tokens") @Operation( summary = "FCM 토큰 저장", diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/member/dto/response/ParticipationHistoryResponse.java b/cherrypic-api/src/main/java/org/cherrypic/domain/member/dto/response/ParticipationHistoryResponse.java new file mode 100644 index 00000000..d9d2c70f --- /dev/null +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/member/dto/response/ParticipationHistoryResponse.java @@ -0,0 +1,17 @@ +package org.cherrypic.domain.member.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import org.cherrypic.album.enums.ParticipationAction; + +public record ParticipationHistoryResponse( + @Schema(description = "앨범 참여 이력 ID", example = "1") Long historyId, + @Schema(description = "앨범 이름 스냅샷 (액션 발생 직전 이름 기준)", example = "가족여행") String albumTitle, + @Schema(description = "액션 타입", example = "JOIN") ParticipationAction action, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd", + timezone = "Asia/Seoul") + @Schema(description = "액션 발생일", example = "2025-08-01") + LocalDateTime eventTime) {} diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/member/service/ParticipationHistoryQueryService.java b/cherrypic-api/src/main/java/org/cherrypic/domain/member/service/ParticipationHistoryQueryService.java new file mode 100644 index 00000000..988a493a --- /dev/null +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/member/service/ParticipationHistoryQueryService.java @@ -0,0 +1,32 @@ +package org.cherrypic.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.cherrypic.domain.album.repository.AlbumParticipationHistoryRepository; +import org.cherrypic.domain.member.dto.response.ParticipationHistoryResponse; +import org.cherrypic.global.pagination.SliceResponse; +import org.cherrypic.global.pagination.SortDirection; +import org.cherrypic.global.util.MemberUtil; +import org.cherrypic.member.entity.Member; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ParticipationHistoryQueryService { + + private final MemberUtil memberUtil; + private final AlbumParticipationHistoryRepository albumParticipationHistoryRepository; + + public SliceResponse getParticipationHistory( + Long lastHistoryId, int size, SortDirection direction) { + final Member currentMember = memberUtil.getCurrentMember(); + + Slice results = + albumParticipationHistoryRepository.findParticipationHistory( + currentMember.getId(), lastHistoryId, size, direction); + + return SliceResponse.from(results); + } +} diff --git a/cherrypic-api/src/main/java/org/cherrypic/domain/participant/service/ParticipantServiceImpl.java b/cherrypic-api/src/main/java/org/cherrypic/domain/participant/service/ParticipantServiceImpl.java index c4a62470..a05691dc 100644 --- a/cherrypic-api/src/main/java/org/cherrypic/domain/participant/service/ParticipantServiceImpl.java +++ b/cherrypic-api/src/main/java/org/cherrypic/domain/participant/service/ParticipantServiceImpl.java @@ -4,8 +4,11 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.cherrypic.album.entity.Album; +import org.cherrypic.album.entity.AlbumParticipationHistory; import org.cherrypic.album.enums.AlbumType; +import org.cherrypic.album.enums.ParticipationAction; import org.cherrypic.domain.album.exception.AlbumErrorCode; +import org.cherrypic.domain.album.repository.AlbumParticipationHistoryRepository; import org.cherrypic.domain.album.repository.AlbumRepository; import org.cherrypic.domain.favorites.repository.FavoritesRepository; import org.cherrypic.domain.notification.repository.NotificationRepository; @@ -40,6 +43,7 @@ public class ParticipantServiceImpl implements ParticipantService { private final SubscriptionRepository subscriptionRepository; private final FavoritesRepository favoritesRepository; private final NotificationRepository notificationRepository; + private final AlbumParticipationHistoryRepository albumParticipationHistoryRepository; @Override public void leaveAlbum(Long albumId) { @@ -57,6 +61,10 @@ public void leaveAlbum(Long albumId) { notificationRepository.deleteByReceiverIdAndAlbumId(currentMember.getId(), albumId); } catch (ObjectOptimisticLockingFailureException ignored) { } + + albumParticipationHistoryRepository.save( + AlbumParticipationHistory.createAlbumParticipationHistory( + currentMember.getId(), album.getTitle(), ParticipationAction.LEAVE)); } @Override @@ -78,6 +86,10 @@ public void kickParticipant(Long albumId, Long participantId) { target.getMember().getId(), album.getId()); } catch (ObjectOptimisticLockingFailureException ignored) { } + + albumParticipationHistoryRepository.save( + AlbumParticipationHistory.createAlbumParticipationHistory( + target.getMember().getId(), album.getTitle(), ParticipationAction.KICK)); } @Override diff --git a/cherrypic-api/src/test/java/org/cherrypic/member/controller/MemberControllerTest.java b/cherrypic-api/src/test/java/org/cherrypic/member/controller/MemberControllerTest.java index dda65a23..fc652179 100644 --- a/cherrypic-api/src/test/java/org/cherrypic/member/controller/MemberControllerTest.java +++ b/cherrypic-api/src/test/java/org/cherrypic/member/controller/MemberControllerTest.java @@ -6,13 +6,20 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.List; +import org.cherrypic.album.enums.ParticipationAction; import org.cherrypic.domain.member.controller.MemberController; import org.cherrypic.domain.member.dto.request.FcmTokenSaveRequest; import org.cherrypic.domain.member.dto.request.MemberProfileUpdateRequest; import org.cherrypic.domain.member.dto.response.LocalImageDeletionToggleResponse; import org.cherrypic.domain.member.dto.response.MemberInfoResponse; import org.cherrypic.domain.member.dto.response.MemberProfileUpdateResponse; +import org.cherrypic.domain.member.dto.response.ParticipationHistoryResponse; import org.cherrypic.domain.member.service.MemberService; +import org.cherrypic.domain.member.service.ParticipationHistoryQueryService; +import org.cherrypic.global.pagination.SliceResponse; +import org.cherrypic.global.pagination.SortDirection; import org.cherrypic.member.enums.MemberRole; import org.cherrypic.member.enums.MemberStatus; import org.junit.jupiter.api.Nested; @@ -38,6 +45,7 @@ class MemberControllerTest { @Autowired private ObjectMapper objectMapper; @MockitoBean private MemberService memberService; + @MockitoBean private ParticipationHistoryQueryService participationHistoryQueryService; @Nested class 회원_정보_조회_요청_시 { @@ -161,6 +169,205 @@ class 회원_프로필_수정_요청_시 { } } + @Nested + class 로컬_이미지_삭제_허용_여부_변경_요청_시 { + + @Test + void 유효한_요청이면_로컬_이미지_삭제_허용_여부를_변경한다() throws Exception { + // given + LocalImageDeletionToggleResponse response = new LocalImageDeletionToggleResponse(true); + + given(memberService.toggleLocalImageDeletion()).willReturn(response); + + // when & then + ResultActions perform = + mockMvc.perform( + patch("/members/me/local-image-deletion") + .contentType(MediaType.APPLICATION_JSON)); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.localImageDeletion").value(true)); + } + } + + @Nested + class 앨범의_참여_이력_조회_요청_시 { + + @Test + void 정렬_조건이_ASC이면_historyId를_오름차순으로_응답한다() throws Exception { + // given + List responses = + List.of( + new ParticipationHistoryResponse( + 1L, + "testTitle1", + ParticipationAction.JOIN, + LocalDateTime.of(2025, 8, 1, 0, 0)), + new ParticipationHistoryResponse( + 2L, + "testTitle2", + ParticipationAction.LEAVE, + LocalDateTime.of(2025, 8, 2, 0, 0)), + new ParticipationHistoryResponse( + 3L, + "testTitle3", + ParticipationAction.KICK, + LocalDateTime.of(2025, 8, 3, 0, 0))); + + given( + participationHistoryQueryService.getParticipationHistory( + null, 3, SortDirection.ASC)) + .willReturn(new SliceResponse<>(responses, true)); + + // when & then + ResultActions perform = + mockMvc.perform( + get("/members/me/participation-history") + .param("size", "3") + .param("direction", "ASC")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.content[0].historyId").value(1)) + .andExpect(jsonPath("$.data.content[1].historyId").value(2)) + .andExpect(jsonPath("$.data.content[2].historyId").value(3)) + .andExpect(jsonPath("$.data.isLast").value(true)); + } + + @Test + void 정렬_조건이_DESC이면_historyId를_내림차순으로_응답한다() throws Exception { + // given + List responses = + List.of( + new ParticipationHistoryResponse( + 3L, + "testTitle3", + ParticipationAction.KICK, + LocalDateTime.of(2025, 8, 3, 0, 0)), + new ParticipationHistoryResponse( + 2L, + "testTitle2", + ParticipationAction.LEAVE, + LocalDateTime.of(2025, 8, 2, 0, 0)), + new ParticipationHistoryResponse( + 1L, + "testTitle1", + ParticipationAction.JOIN, + LocalDateTime.of(2025, 8, 1, 0, 0))); + + given( + participationHistoryQueryService.getParticipationHistory( + null, 3, SortDirection.DESC)) + .willReturn(new SliceResponse<>(responses, true)); + + // when & then + ResultActions perform = + mockMvc.perform(get("/members/me/participation-history").param("size", "3")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.content[0].historyId").value(3)) + .andExpect(jsonPath("$.data.content[1].historyId").value(2)) + .andExpect(jsonPath("$.data.content[2].historyId").value(1)) + .andExpect(jsonPath("$.data.isLast").value(true)); + } + + @Test + void 마지막_페이지인_경우_isLast를_true로_응답한다() throws Exception { + // given + List responses = + List.of( + new ParticipationHistoryResponse( + 1L, + "testTitle1", + ParticipationAction.JOIN, + LocalDateTime.of(2025, 8, 1, 0, 0))); + + given( + participationHistoryQueryService.getParticipationHistory( + null, 1, SortDirection.DESC)) + .willReturn(new SliceResponse<>(responses, true)); + + // when & then + ResultActions perform = + mockMvc.perform(get("/members/me/participation-history").param("size", "1")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.content[0].historyId").value(1)) + .andExpect(jsonPath("$.data.isLast").value(true)); + } + + @Test + void 마지막_페이지가_아닌_경우_isLast를_false로_응답한다() throws Exception { + // given + List responses = + List.of( + new ParticipationHistoryResponse( + 2L, + "testTitle2", + ParticipationAction.LEAVE, + LocalDateTime.of(2025, 8, 2, 0, 0)), + new ParticipationHistoryResponse( + 1L, + "testTitle1", + ParticipationAction.JOIN, + LocalDateTime.of(2025, 8, 1, 0, 0))); + + given( + participationHistoryQueryService.getParticipationHistory( + null, 1, SortDirection.DESC)) + .willReturn(new SliceResponse<>(responses, false)); + + // when & then + ResultActions perform = + mockMvc.perform(get("/members/me/participation-history").param("size", "1")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.content[0].historyId").value(2)) + .andExpect(jsonPath("$.data.isLast").value(false)); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "-999", "0"}) + void 페이지_크기를_0_이하로_설정하면_예외가_발생한다(String pageSize) throws Exception { + // when & then + ResultActions perform = + mockMvc.perform( + get("/members/me/participation-history").param("size", pageSize)); + + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(jsonPath("$.data.code").value("ConstraintViolationException")) + .andExpect(jsonPath("$.data.message").value("페이지 크기는 0보다 큰 값만 가능합니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"ASCC", "DESCC", "OLDEST", "NEWEST"}) + void 존재하지_않는_정렬_기준을_입력한_경우_예외가_발생한다(String sort) throws Exception { + // when & then + ResultActions perform = + mockMvc.perform( + get("/members/me/participation-history") + .param("size", "2") + .param("direction", sort)); + + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value())) + .andExpect(jsonPath("$.data.code").value("METHOD_ARGUMENT_TYPE_MISMATCH")) + .andExpect(jsonPath("$.data.message").value("요청한 값의 타입이 잘못되어 처리할 수 없습니다.")); + } + } + @Nested class FCM_토큰_저장_요청_시 { @@ -205,27 +412,4 @@ class FCM_토큰_저장_요청_시 { .andExpect(jsonPath("$.data.message").value("FCM Token은 비워둘 수 없습니다.")); } } - - @Nested - class 로컬_이미지_삭제_허용_여부_변경_요청_시 { - - @Test - void 유효한_요청이면_로컬_이미지_삭제_허용_여부를_변경한다() throws Exception { - // given - LocalImageDeletionToggleResponse response = new LocalImageDeletionToggleResponse(true); - - given(memberService.toggleLocalImageDeletion()).willReturn(response); - - // when & then - ResultActions perform = - mockMvc.perform( - patch("/members/me/local-image-deletion") - .contentType(MediaType.APPLICATION_JSON)); - - perform.andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.localImageDeletion").value(true)); - } - } } diff --git a/cherrypic-api/src/test/java/org/cherrypic/member/service/ParticipationHistoryQueryServiceTest.java b/cherrypic-api/src/test/java/org/cherrypic/member/service/ParticipationHistoryQueryServiceTest.java new file mode 100644 index 00000000..6404e864 --- /dev/null +++ b/cherrypic-api/src/test/java/org/cherrypic/member/service/ParticipationHistoryQueryServiceTest.java @@ -0,0 +1,114 @@ +package org.cherrypic.member.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; +import org.cherrypic.IntegrationTest; +import org.cherrypic.album.entity.AlbumParticipationHistory; +import org.cherrypic.album.enums.ParticipationAction; +import org.cherrypic.domain.album.repository.AlbumParticipationHistoryRepository; +import org.cherrypic.domain.member.dto.response.ParticipationHistoryResponse; +import org.cherrypic.domain.member.repository.MemberRepository; +import org.cherrypic.domain.member.service.ParticipationHistoryQueryService; +import org.cherrypic.global.pagination.SliceResponse; +import org.cherrypic.global.pagination.SortDirection; +import org.cherrypic.global.util.MemberUtil; +import org.cherrypic.member.entity.Member; +import org.cherrypic.member.entity.OauthInfo; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class ParticipationHistoryQueryServiceTest extends IntegrationTest { + + @Autowired private ParticipationHistoryQueryService participationHistoryQueryService; + @Autowired private MemberRepository memberRepository; + @Autowired private AlbumParticipationHistoryRepository albumParticipationHistoryRepository; + + @MockitoBean private MemberUtil memberUtil; + + @Nested + class 앨범의_참여_이력을_조회할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider"), + "testNickname", + "testProfileImageUrl"); + memberRepository.save(member); + given(memberUtil.getCurrentMember()).willReturn(member); + + AlbumParticipationHistory participationHistory1 = + AlbumParticipationHistory.createAlbumParticipationHistory( + member.getId(), "testTitle1", ParticipationAction.JOIN); + AlbumParticipationHistory participationHistory2 = + AlbumParticipationHistory.createAlbumParticipationHistory( + member.getId(), "testTitle2", ParticipationAction.LEAVE); + AlbumParticipationHistory participationHistory3 = + AlbumParticipationHistory.createAlbumParticipationHistory( + member.getId(), "testTitle3", ParticipationAction.KICK); + + albumParticipationHistoryRepository.saveAll( + List.of(participationHistory1, participationHistory2, participationHistory3)); + } + + @Test + void 정렬_조건이_ASC이면_historyId를_오름차순으로_조회한다() { + // when + SliceResponse response = + participationHistoryQueryService.getParticipationHistory( + null, 3, SortDirection.ASC); + + // then + Assertions.assertAll( + () -> + assertThat(response.content()) + .extracting("historyId") + .containsExactly(1L, 2L, 3L), + () -> assertThat(response.isLast()).isTrue()); + } + + @Test + void 정렬_조건이_DESC이면_historyId를_내림차순으로_조회한다() { + // when + SliceResponse response = + participationHistoryQueryService.getParticipationHistory( + null, 3, SortDirection.DESC); + + // then + Assertions.assertAll( + () -> + assertThat(response.content()) + .extracting("historyId") + .containsExactly(3L, 2L, 1L), + () -> assertThat(response.isLast()).isTrue()); + } + + @Test + void 마지막_페이지인_경우_isLast를_true로_반환한다() { + SliceResponse response = + participationHistoryQueryService.getParticipationHistory( + null, 3, SortDirection.DESC); + + // then + Assertions.assertAll( + () -> assertThat(response.content().size()).isEqualTo(3), + () -> assertThat(response.isLast()).isTrue()); + } + + @Test + void 마지막_페이지가_아닌_경우_isLast를_false로_반환한다() { + SliceResponse response = + participationHistoryQueryService.getParticipationHistory( + null, 1, SortDirection.DESC); + + // then + Assertions.assertAll( + () -> assertThat(response.content().size()).isEqualTo(1), + () -> assertThat(response.isLast()).isFalse()); + } + } +} diff --git a/cherrypic-domain/src/main/java/org/cherrypic/album/entity/AlbumParticipationHistory.java b/cherrypic-domain/src/main/java/org/cherrypic/album/entity/AlbumParticipationHistory.java new file mode 100644 index 00000000..b8331eb2 --- /dev/null +++ b/cherrypic-domain/src/main/java/org/cherrypic/album/entity/AlbumParticipationHistory.java @@ -0,0 +1,45 @@ +package org.cherrypic.album.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.cherrypic.album.enums.ParticipationAction; +import org.cherrypic.common.model.BaseTimeEntity; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AlbumParticipationHistory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull private Long memberId; + + @NotNull private String albumTitleSnapshot; + + @NotNull + @Enumerated(EnumType.STRING) + private ParticipationAction action; + + @Builder(access = AccessLevel.PRIVATE) + private AlbumParticipationHistory( + Long memberId, String albumTitleSnapshot, ParticipationAction action) { + this.memberId = memberId; + this.albumTitleSnapshot = albumTitleSnapshot; + this.action = action; + } + + public static AlbumParticipationHistory createAlbumParticipationHistory( + Long memberId, String albumTitleSnapshot, ParticipationAction action) { + return AlbumParticipationHistory.builder() + .memberId(memberId) + .albumTitleSnapshot(albumTitleSnapshot) + .action(action) + .build(); + } +} diff --git a/cherrypic-domain/src/main/java/org/cherrypic/album/enums/ParticipationAction.java b/cherrypic-domain/src/main/java/org/cherrypic/album/enums/ParticipationAction.java new file mode 100644 index 00000000..027676d6 --- /dev/null +++ b/cherrypic-domain/src/main/java/org/cherrypic/album/enums/ParticipationAction.java @@ -0,0 +1,8 @@ +package org.cherrypic.album.enums; + +public enum ParticipationAction { + JOIN, + LEAVE, + KICK, + DELETED +} diff --git a/cherrypic-domain/src/main/resources/db/migration/V5__create_album_participation_history_table.sql b/cherrypic-domain/src/main/resources/db/migration/V5__create_album_participation_history_table.sql new file mode 100644 index 00000000..02044e77 --- /dev/null +++ b/cherrypic-domain/src/main/resources/db/migration/V5__create_album_participation_history_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE album_participation_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + album_title_snapshot VARCHAR(20) NOT NULL, + action VARCHAR(20) NOT NULL CHECK (action IN ('JOIN','LEAVE','KICK','DELETED')), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL +);