diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java new file mode 100644 index 000000000..984dfb50f --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/integration/BookmarkIntegrationTest.java @@ -0,0 +1,514 @@ +package umc.cockple.demo.domain.bookmark.integration; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; +import umc.cockple.demo.domain.bookmark.enums.BookmarkedExerciseOrderType; +import umc.cockple.demo.domain.bookmark.exception.BookmarkErrorCode; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.enums.PartyOrderType; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.global.enums.Role; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalTime; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class BookmarkIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired ExerciseBookmarkRepository exerciseBookmarkRepository; + @Autowired PartyBookmarkRepository partyBookmarkRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + + + private Member member; + private Party bookmarkParty; + private Exercise bookmarkExercise; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.createMember("테스터", Gender.MALE, Level.A, 1001L)); + + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("경기도", "안산시")); + bookmarkParty = partyRepository.save(PartyFixture.createParty("테스트 모임", member.getId(), addr)); + + memberPartyRepository.save(MemberFixture.createMemberParty(bookmarkParty, member, Role.party_MANAGER)); + + bookmarkExercise = exerciseRepository.save(Exercise.builder() + .party(bookmarkParty) + .date(LocalDate.of(2026, 12, 31)) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .exerciseAddr(ExerciseAddr.builder() + .addr1("경기도") + .addr2("안산시") + .streetAddr("경기도 안산시 한양대학로 1") + .buildingName("테스트 체육관") + .latitude(37.5) + .longitude(127.0) + .build()) + .build()); + } + + @AfterEach + void tearDown() { + exerciseBookmarkRepository.deleteAll(); + partyBookmarkRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + + @Nested + @DisplayName("POST /api/parties/{partyId}/bookmark - 모임 찜하기") + class PartyBookmarkCreate { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 모임을 찜하면 partyBookmarkId를 반환한다") + void partyBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNumber()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 모임이면 에러를 반환한다") + void partyNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 찜한 모임이면 에러를 반환한다") + void alreadyBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + partyBookmarkRepository.save(PartyBookmark.builder() + .party(bookmarkParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_BOOKMARK.getMessage())); + } + + @Test + @DisplayName("400 - 삭제된 모임은 찜할 수 없다") + void partyIsDeleted() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + bookmarkParty.delete(); + partyRepository.save(bookmarkParty); + + mockMvc.perform(post("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_IS_DELETED.getCode())) + .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_IS_DELETED.getMessage())); + } + } + } + + + @Nested + @DisplayName("DELETE /api/parties/{partyId}/bookmark - 모임 찜 해제") + class PartyBookmarkRelease { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 찜한 모임을 해제하면 200 응답을 반환한다") + void releasePartyBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + partyBookmarkRepository.save(PartyBookmark.builder() + .party(bookmarkParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + + mockMvc.perform(delete("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 모임이면 에러를 반환한다") + void partyNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PartyErrorCode.PARTY_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(PartyErrorCode.PARTY_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 찜하지 않은 모임을 해제하려 하면 에러를 반환한다") + void notBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/parties/{partyId}/bookmark", bookmarkParty.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getMessage())); + } + } + } + + + @Nested + @DisplayName("POST /api/exercises/{exerciseId}/bookmark - 운동 찜하기") + class ExerciseBookmarkCreate { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 운동을 찜하면 exerciseBookmarkId를 반환한다") + void exerciseBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isNumber()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 이미 찜한 운동이면 에러를 반환한다") + void alreadyBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(bookmarkExercise) + .build()); + + mockMvc.perform(post("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_BOOKMARK.getMessage())); + } + } + } + + + @Nested + @DisplayName("DELETE /api/exercises/{exerciseId}/bookmark - 운동 찜 해제") + class ExerciseBookmarkRelease { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - 찜한 운동을 해제하면 200 응답을 반환한다") + void releaseExerciseBookmark_success() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(bookmarkExercise) + .build()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("404 - 존재하지 않는 운동이면 에러를 반환한다") + void exerciseNotFound() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/bookmark", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("400 - 찜하지 않은 운동을 해제하려 하면 에러를 반환한다") + void notBookmarked() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(delete("/api/exercises/{exerciseId}/bookmark", bookmarkExercise.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getCode())) + .andExpect(jsonPath("$.message").value(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK.getMessage())); + } + } + } + + + @Nested + @DisplayName("GET /api/exercises/bookmarks - 찜한 운동 전체 조회") + class GetAllExerciseBookmarks { + + private Exercise newExercise; + + @BeforeEach + void setUp() { + // 먼저 저장 = 오래된 북마크 + memberExerciseRepository.save(MemberFixture.createMemberExercise(member, bookmarkExercise)); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(bookmarkExercise) + .build()); + + // 나중에 저장 = 최신 북마크 + newExercise = exerciseRepository.save(Exercise.builder() + .party(bookmarkParty) + .date(LocalDate.of(2027, 6, 30)) + .startTime(LocalTime.of(14, 0)) + .endTime(LocalTime.of(16, 0)) + .maxCapacity(8) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .exerciseAddr(ExerciseAddr.builder() + .addr1("경기도") + .addr2("안산시") + .streetAddr("경기도 안산시 한양대학로 1") + .buildingName("테스트 체육관") + .latitude(37.5) + .longitude(127.0) + .build()) + .build()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(member, newExercise)); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(member) + .exercise(newExercise) + .build()); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - LATEST 정렬로 찜한 운동을 전체 조회하면 최신순으로 반환한다") + void getAllExerciseBookmarks_latest_allFields() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/exercises/bookmarks") + .param("orderType", BookmarkedExerciseOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].exerciseId").value(newExercise.getId())) + .andExpect(jsonPath("$.data[1].exerciseId").value(bookmarkExercise.getId())) + .andExpect(jsonPath("$.data[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data[0].streetAddr").value("경기도 안산시 한양대학로 1")) + .andExpect(jsonPath("$.data[0].femaleLevel").isArray()) + .andExpect(jsonPath("$.data[0].maleLevel").isArray()) + .andExpect(jsonPath("$.data[0].date").value("2027-06-30")) + .andExpect(jsonPath("$.data[0].startExerciseTime").value("14:00:00")) + .andExpect(jsonPath("$.data[0].endExerciseTime").value("16:00:00")) + .andExpect(jsonPath("$.data[0].maxMemberCnt").value(8)) + .andExpect(jsonPath("$.data[0].nowMemberCnt").isNumber()) + .andExpect(jsonPath("$.data[0].includeParty").value(true)) + .andExpect(jsonPath("$.data[0].includeExercise").value(true)); + } + + @Test + @DisplayName("200 - EARLIEST 정렬로 찜한 운동을 전체 조회하면 오래된순으로 반환한다") + void getAllExerciseBookmarks_earliest() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/exercises/bookmarks") + .param("orderType", BookmarkedExerciseOrderType.EARLIEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].exerciseId").value(bookmarkExercise.getId())) + .andExpect(jsonPath("$.data[1].exerciseId").value(newExercise.getId())); + } + + @Test + @DisplayName("200 - 찜한 운동이 없으면 빈 리스트를 반환한다") + void noBookmarks_returnsEmptyList() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/exercises/bookmarks") + .param("orderType", BookmarkedExerciseOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + } + } + + // ============================================================ + // GET /api/parties/bookmarks - 찜한 모임 전체 조회 + // ============================================================ + + @Nested + @DisplayName("GET /api/parties/bookmarks - 찜한 모임 전체 조회") + class GetAllPartyBookmarks { + + private Party newParty; + + @BeforeEach + void setUp() { + // 먼저 저장 = 오래된 북마크 + partyBookmarkRepository.save(PartyBookmark.builder() + .party(bookmarkParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + + // 나중에 저장 = 최신 북마크 + PartyAddr newAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + newParty = partyRepository.save(PartyFixture.createParty("새 테스트 모임", member.getId(), newAddr)); + + partyBookmarkRepository.save(PartyBookmark.builder() + .party(newParty) + .member(member) + .orderType(PartyOrderType.LATEST) + .build()); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("200 - LATEST 정렬로 찜한 모임을 전체 조회하면 최신순으로 반환한다") + void getAllPartyBookmarks_latest_allFields() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].partyId").value(newParty.getId())) + .andExpect(jsonPath("$.data[1].partyId").value(bookmarkParty.getId())) + .andExpect(jsonPath("$.data[1].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data[1].addr1").value("경기도")) + .andExpect(jsonPath("$.data[1].addr2").value("안산시")) + .andExpect(jsonPath("$.data[1].maleLevel").isArray()) + .andExpect(jsonPath("$.data[1].femaleLevel").isArray()) + .andExpect(jsonPath("$.data[1].latestExerciseDate").value("2026-12-31")) + .andExpect(jsonPath("$.data[1].latestExerciseTime").value("MORNING")) + .andExpect(jsonPath("$.data[1].exerciseCnt").isNumber()) + .andExpect(jsonPath("$.data[1].profileImgUrl").value(nullValue())); + } + + @Test + @DisplayName("200 - EXERCISE_COUNT 정렬로 찜한 모임 전체 조회 시 성공한다") + void getAllPartyBookmarks_exerciseCount() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.EXERCISE_COUNT.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))); + } + + @Test + @DisplayName("200 - OLDEST 정렬로 찜한 모임을 전체 조회하면 오래된순으로 반환한다") + void getAllPartyBookmarks_oldest() throws Exception { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.OLDEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[0].partyId").value(bookmarkParty.getId())) + .andExpect(jsonPath("$.data[1].partyId").value(newParty.getId())); + } + + @Test + @DisplayName("200 - 찜한 모임이 없으면 빈 리스트를 반환한다") + void noBookmarks_returnsEmptyList() throws Exception { + Member otherMember = memberRepository.save( + MemberFixture.createMember("다른멤버", Gender.FEMALE, Level.B, 2002L)); + SecurityContextHelper.setAuthentication(otherMember.getId(), otherMember.getNickname()); + + mockMvc.perform(get("/api/parties/bookmarks") + .param("orderType", PartyOrderType.LATEST.name())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkCommandServiceTest.java new file mode 100644 index 000000000..f4ae228d0 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkCommandServiceTest.java @@ -0,0 +1,498 @@ +package umc.cockple.demo.domain.bookmark.service; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; +import umc.cockple.demo.domain.bookmark.exception.BookmarkErrorCode; +import umc.cockple.demo.domain.bookmark.exception.BookmarkException; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.PartyOrderType; +import umc.cockple.demo.domain.party.enums.PartyStatus; +import umc.cockple.demo.domain.party.exception.PartyErrorCode; +import umc.cockple.demo.domain.party.exception.PartyException; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookmarkCommandService") +class BookmarkCommandServiceTest { + + @InjectMocks + private BookmarkCommandService bookmarkCommandService; + + @Mock private PartyBookmarkRepository partyBookmarkRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private MemberRepository memberRepository; + @Mock private PartyRepository partyRepository; + @Mock private ExerciseRepository exerciseRepository; + + private Member member; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스트 유저", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + exercise = ExerciseFixture.createExercise(party, LocalDate.of(2099, 12, 31)); + ReflectionTestUtils.setField(exercise, "id", 100L); + } + + @Nested + @DisplayName("partyBookmark - 모임 찜하기") + class PartyBookmarks { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임을 찜하면 저장된 북마크 id를 반환한다") + void createPartyBookmark_returnsBookmarkId() { + // given + PartyBookmark savedBookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 50L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(false); + given(partyBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>()); + given(partyBookmarkRepository.save(any(PartyBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.partyBookmark(member.getId(), party.getId()); + + // then + assertThat(result).isEqualTo(50L); + then(partyBookmarkRepository).should().save(any(PartyBookmark.class)); + } + + @Test + @DisplayName("찜 목록이 15개 이상이면 가장 오래된 북마크를 삭제하고 새로 저장한다") + void createPartyBookmark_deletesOldestWhenLimitExceeded() { + // given + List existingBookmarks = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + existingBookmarks.add(PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build()); + } + + PartyBookmark oldestBookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + + PartyBookmark savedBookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 99L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(false); + given(partyBookmarkRepository.findAllByMember(member)).willReturn(existingBookmarks); + given(partyBookmarkRepository.findFirstByMemberOrderByCreatedAtAsc(member)) + .willReturn(Optional.of(oldestBookmark)); + given(partyBookmarkRepository.save(any(PartyBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.partyBookmark(member.getId(), party.getId()); + + // then + assertThat(result).isEqualTo(99L); + then(partyBookmarkRepository).should().delete(oldestBookmark); + then(partyBookmarkRepository).should().save(any(PartyBookmark.class)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(999L, party.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던진다") + void partyNotFound_throwsPartyException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(member.getId(), 999L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("비활성화된 모임이면 PartyException(PARTY_IS_DELETED)을 던진다") + void partyIsInactive_throwsPartyException() { + Party inactiveParty = PartyFixture.createParty("삭제된 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(inactiveParty, "id", 20L); + ReflectionTestUtils.setField(inactiveParty, "status", PartyStatus.INACTIVE); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(20L)).willReturn(Optional.of(inactiveParty)); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(member.getId(), 20L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_IS_DELETED)); + } + + @Test + @DisplayName("이미 찜한 모임이면 BookmarkException(ALREADY_BOOKMARK)을 던진다") + void alreadyBookmarked_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.existsByMemberAndParty(member, party)).willReturn(true); + + assertThatThrownBy(() -> + bookmarkCommandService.partyBookmark(member.getId(), party.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_BOOKMARK)); + + then(partyBookmarkRepository).should(never()).save(any()); + } + } + } + + @Nested + @DisplayName("releasePartyBookmark - 모임 찜 해제") + class ReleasePartyBookmarks { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 모임을 해제하면 북마크를 삭제한다") + void releasePartyBookmark_deletesBookmark() { + // given + PartyBookmark bookmark = PartyBookmark.builder() + .member(member) + .party(party) + .orderType(PartyOrderType.LATEST) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.findByMemberAndParty(member, party)) + .willReturn(Optional.of(bookmark)); + + // when + bookmarkCommandService.releasePartyBookmark(member.getId(), party.getId()); + + // then + then(partyBookmarkRepository).should().delete(bookmark); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releasePartyBookmark(999L, party.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 모임이면 PartyException(PARTY_NOT_FOUND)을 던진다") + void partyNotFound_throwsPartyException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releasePartyBookmark(member.getId(), 999L)) + .isInstanceOf(PartyException.class) + .satisfies(e -> assertThat(((PartyException) e).getCode()) + .isEqualTo(PartyErrorCode.PARTY_NOT_FOUND)); + } + + @Test + @DisplayName("찜하지 않은 모임이면 BookmarkException(ALREADY_RELEASE_BOOKMARK)을 던진다") + void bookmarkNotFound_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyRepository.findById(party.getId())).willReturn(Optional.of(party)); + given(partyBookmarkRepository.findByMemberAndParty(member, party)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releasePartyBookmark(member.getId(), party.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK)); + + then(partyBookmarkRepository).should(never()).delete(any()); + } + } + } + + @Nested + @DisplayName("exerciseBookmark - 운동 찜하기") + class ExerciseBookmarkCreate { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("운동을 찜하면 저장된 북마크 id를 반환한다") + void createExerciseBookmark_returnsBookmarkId() { + // given + ExerciseBookmark savedBookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 200L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(false); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>()); + given(exerciseBookmarkRepository.save(any(ExerciseBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId()); + + // then + assertThat(result).isEqualTo(200L); + then(exerciseBookmarkRepository).should().save(any(ExerciseBookmark.class)); + } + + @Test + @DisplayName("찜 목록이 50개 이상이면 가장 오래된 북마크를 삭제하고 새로 저장한다") + void createExerciseBookmark_deletesOldestWhenLimitExceeded() { + // given + List existingBookmarks = new ArrayList<>(); + for (int i = 0; i < 50; i++) { + existingBookmarks.add(ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build()); + } + + ExerciseBookmark oldestBookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + + ExerciseBookmark savedBookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + ReflectionTestUtils.setField(savedBookmark, "id", 300L); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(false); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(existingBookmarks); + given(exerciseBookmarkRepository.findFirstByMemberOrderByCreatedAtAsc(member)) + .willReturn(Optional.of(oldestBookmark)); + given(exerciseBookmarkRepository.save(any(ExerciseBookmark.class))).willReturn(savedBookmark); + + // when + Long result = bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId()); + + // then + assertThat(result).isEqualTo(300L); + then(exerciseBookmarkRepository).should().delete(oldestBookmark); + then(exerciseBookmarkRepository).should().save(any(ExerciseBookmark.class)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.exerciseBookmark(999L, exercise.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsExerciseException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.exerciseBookmark(member.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("이미 찜한 운동이면 BookmarkException(ALREADY_BOOKMARK)을 던진다") + void alreadyBookmarked_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.existsByMemberAndExercise(member, exercise)).willReturn(true); + + assertThatThrownBy(() -> + bookmarkCommandService.exerciseBookmark(member.getId(), exercise.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_BOOKMARK)); + + then(exerciseBookmarkRepository).should(never()).save(any()); + } + } + } + + @Nested + @DisplayName("releaseExerciseBookmark - 운동 찜 해제") + class ReleaseExerciseBookmark { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 운동을 해제하면 북마크를 삭제한다") + void releaseExerciseBookmark_deletesBookmark() { + // given + ExerciseBookmark bookmark = ExerciseBookmark.builder() + .member(member) + .exercise(exercise) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.findByMemberAndExercise(member, exercise)) + .willReturn(Optional.of(bookmark)); + + // when + bookmarkCommandService.releaseExerciseBookmark(member.getId(), exercise.getId()); + + // then + then(exerciseBookmarkRepository).should().delete(bookmark); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releaseExerciseBookmark(999L, exercise.getId())) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("존재하지 않는 운동이면 ExerciseException(EXERCISE_NOT_FOUND)을 던진다") + void exerciseNotFound_throwsExerciseException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releaseExerciseBookmark(member.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.EXERCISE_NOT_FOUND)); + } + + @Test + @DisplayName("찜하지 않은 운동이면 BookmarkException(ALREADY_RELEASE_BOOKMARK)을 던진다") + void bookmarkNotFound_throwsBookmarkException() { + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseRepository.findById(exercise.getId())).willReturn(Optional.of(exercise)); + given(exerciseBookmarkRepository.findByMemberAndExercise(member, exercise)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkCommandService.releaseExerciseBookmark(member.getId(), exercise.getId())) + .isInstanceOf(BookmarkException.class) + .satisfies(e -> assertThat(((BookmarkException) e).getCode()) + .isEqualTo(BookmarkErrorCode.ALREADY_RELEASE_BOOKMARK)); + + then(exerciseBookmarkRepository).should(never()).delete(any()); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java new file mode 100644 index 000000000..75602159e --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/bookmark/service/BookmarkQueryServiceTest.java @@ -0,0 +1,503 @@ +package umc.cockple.demo.domain.bookmark.service; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import umc.cockple.demo.domain.bookmark.converter.BookmarkConverter; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.domain.PartyBookmark; +import umc.cockple.demo.domain.bookmark.dto.GetAllExerciseBookmarksResponseDTO; +import umc.cockple.demo.domain.bookmark.dto.GetAllPartyBookmarkResponseDTO; +import umc.cockple.demo.domain.bookmark.enums.BookmarkedExerciseOrderType; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.exception.MemberErrorCode; +import umc.cockple.demo.domain.member.exception.MemberException; +import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; +import umc.cockple.demo.domain.member.repository.MemberPartyRepository; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.enums.ActivityTime; +import umc.cockple.demo.domain.party.enums.PartyOrderType; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("BookmarkQueryService") +class BookmarkQueryServiceTest { + + @InjectMocks + private BookmarkQueryService bookmarkQueryService; + + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private PartyBookmarkRepository partyBookmarkRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private BookmarkConverter bookmarkConverter; + + private Member member; + private Party party; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember("테스트 유저", Gender.MALE, Level.A, 1001L); + ReflectionTestUtils.setField(member, "id", 1L); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + } + + @Nested + @DisplayName("getAllExerciseBookmarks - 찜한 운동 목록 조회") + class GetAllExerciseBookmarks { + + private Exercise oldExercise; + private Exercise newExercise; + + @BeforeEach + void setUp() { + oldExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 6, 1)); + ReflectionTestUtils.setField(oldExercise, "id", 101L); + + newExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31)); + ReflectionTestUtils.setField(newExercise, "id", 102L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 운동이 없으면 빈 목록을 반환한다") + void noBookmarks_returnsEmptyList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>()); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) + .willReturn(new ArrayList<>()); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) + .willReturn(new ArrayList<>()); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("LATEST 정렬 시 최신순으로 반환한다") + void latestOrder_returnsNewestFirst() { + // given + ExerciseBookmark bookmarkOld = ExerciseBookmark.builder() + .member(member).exercise(oldExercise).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + ExerciseBookmark bookmarkNew = ExerciseBookmark.builder() + .member(member).exercise(newExercise).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + // 레포지토리에서 오래된 순서로 반환 + List bookmarks = new ArrayList<>(List.of(bookmarkOld, bookmarkNew)); + + GetAllExerciseBookmarksResponseDTO dtoOld = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(101L).partyName("테스트 모임").build(); + GetAllExerciseBookmarksResponseDTO dtoNew = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(102L).partyName("테스트 모임").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(bookmarks); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) + .willReturn(List.of(party.getId())); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) + .willReturn(List.of(oldExercise.getId(), newExercise.getId())); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoNew); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoOld); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).exerciseId()).isEqualTo(102L); // 최신 것이 먼저 + assertThat(result.get(1).exerciseId()).isEqualTo(101L); + } + + @Test + @DisplayName("EARLIEST 정렬 시 오래된 순으로 반환한다") + void earliestOrder_returnsOldestFirst() { + // given + ExerciseBookmark bookmarkOld = ExerciseBookmark.builder() + .member(member).exercise(oldExercise).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + ExerciseBookmark bookmarkNew = ExerciseBookmark.builder() + .member(member).exercise(newExercise).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + // 레포지토리에서 최신 순서로 반환 + List bookmarks = new ArrayList<>(List.of(bookmarkNew, bookmarkOld)); + + GetAllExerciseBookmarksResponseDTO dtoOld = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(101L).partyName("테스트 모임").build(); + GetAllExerciseBookmarksResponseDTO dtoNew = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(102L).partyName("테스트 모임").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(bookmarks); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(anyLong(), anyList())) + .willReturn(List.of(party.getId())); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(anyLong(), anyList())) + .willReturn(List.of(oldExercise.getId(), newExercise.getId())); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkOld), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoOld); + given(bookmarkConverter.exerciseBookmarkToDTO(eq(bookmarkNew), any(Boolean.class), any(Boolean.class))) + .willReturn(dtoNew); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.EARLIEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).exerciseId()).isEqualTo(101L); // 오래된 것이 먼저 + assertThat(result.get(1).exerciseId()).isEqualTo(102L); + } + + @Test + @DisplayName("includeParty, includeExercise 정보를 정확히 반영하여 변환한다") + void convertsBookmarkWithCorrectIncludeFlags() { + // given + Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31)); + ReflectionTestUtils.setField(exercise, "id", 101L); + + ExerciseBookmark bookmark = ExerciseBookmark.builder() + .member(member).exercise(exercise).build(); + ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now()); + + GetAllExerciseBookmarksResponseDTO dto = GetAllExerciseBookmarksResponseDTO.builder() + .exerciseId(101L).includeParty(true).includeExercise(false).build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(exerciseBookmarkRepository.findAllByMember(member)).willReturn(new ArrayList<>(List.of(bookmark))); + given(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(eq(member.getId()), anyList())) + .willReturn(List.of(party.getId())); // 모임 멤버 + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(eq(member.getId()), anyList())) + .willReturn(new ArrayList<>()); // 운동 미참여 + given(bookmarkConverter.exerciseBookmarkToDTO(bookmark, true, false)).willReturn(dto); + + // when + List result = + bookmarkQueryService.getAllExerciseBookmarks(member.getId(), BookmarkedExerciseOrderType.LATEST); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).includeParty()).isTrue(); + assertThat(result.get(0).includeExercise()).isFalse(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkQueryService.getAllExerciseBookmarks(999L, BookmarkedExerciseOrderType.LATEST)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } + + @Nested + @DisplayName("getAllPartyBookmarks - 찜한 모임 목록 조회") + class GetAllPartyBookmarks { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("찜한 모임이 없으면 빈 목록을 반환한다") + void noBookmarks_returnsEmptyList() { + // given + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)).willReturn(new ArrayList<>()); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("LATEST 정렬 시 최신순으로 반환한다") + void latestOrder_returnsNewestFirst() { + // given + Party partyA = PartyFixture.createParty("모임A", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(partyA, "id", 11L); + + Party partyB = PartyFixture.createParty("모임B", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "종로구")); + ReflectionTestUtils.setField(partyB, "id", 12L); + + PartyBookmark bookmarkOld = PartyBookmark.builder() + .member(member).party(partyA) + .orderType(PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + PartyBookmark bookmarkNew = PartyBookmark.builder() + .member(member).party(partyB) + .orderType(PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + GetAllPartyBookmarkResponseDTO dtoA = GetAllPartyBookmarkResponseDTO.builder() + .partyId(11L).partyName("모임A").build(); + GetAllPartyBookmarkResponseDTO dtoB = GetAllPartyBookmarkResponseDTO.builder() + .partyId(12L).partyName("모임B").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmarkOld, bookmarkNew))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkNew), any(), any(), any())) + .willReturn(dtoB); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkOld), any(), any(), any())) + .willReturn(dtoA); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).partyId()).isEqualTo(12L); // 최신 것이 먼저 + assertThat(result.get(1).partyId()).isEqualTo(11L); + } + + @Test + @DisplayName("OLDEST 정렬 시 오래된 순으로 반환한다") + void oldestOrder_returnsOldestFirst() { + // given + Party partyA = PartyFixture.createParty("모임A", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(partyA, "id", 11L); + + Party partyB = PartyFixture.createParty("모임B", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "종로구")); + ReflectionTestUtils.setField(partyB, "id", 12L); + + PartyBookmark bookmarkOld = PartyBookmark.builder() + .member(member).party(partyA) + .orderType(PartyOrderType.OLDEST).build(); + ReflectionTestUtils.setField(bookmarkOld, "createdAt", LocalDateTime.now().minusDays(2)); + + PartyBookmark bookmarkNew = PartyBookmark.builder() + .member(member).party(partyB) + .orderType(PartyOrderType.OLDEST).build(); + ReflectionTestUtils.setField(bookmarkNew, "createdAt", LocalDateTime.now().minusDays(1)); + + GetAllPartyBookmarkResponseDTO dtoA = GetAllPartyBookmarkResponseDTO.builder() + .partyId(11L).partyName("모임A").build(); + GetAllPartyBookmarkResponseDTO dtoB = GetAllPartyBookmarkResponseDTO.builder() + .partyId(12L).partyName("모임B").build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmarkNew, bookmarkOld))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkOld), any(), any(), any())) + .willReturn(dtoA); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkNew), any(), any(), any())) + .willReturn(dtoB); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.OLDEST); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).partyId()).isEqualTo(11L); // 오래된 것이 먼저 + assertThat(result.get(1).partyId()).isEqualTo(12L); + } + + @Test + @DisplayName("EXERCISE_COUNT 정렬 시 운동 횟수 많은 순으로 반환한다") + void exerciseCountOrder_returnsMostExercisedFirst() { + // given + Party partyLow = PartyFixture.createParty("운동 적은 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(partyLow, "id", 11L); + ReflectionTestUtils.setField(partyLow, "exerciseCount", 2); + + Party partyHigh = PartyFixture.createParty("운동 많은 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "종로구")); + ReflectionTestUtils.setField(partyHigh, "id", 12L); + ReflectionTestUtils.setField(partyHigh, "exerciseCount", 10); + + PartyBookmark bookmarkLow = PartyBookmark.builder() + .member(member).party(partyLow) + .orderType(PartyOrderType.EXERCISE_COUNT).build(); + ReflectionTestUtils.setField(bookmarkLow, "createdAt", LocalDateTime.now().minusDays(1)); + + PartyBookmark bookmarkHigh = PartyBookmark.builder() + .member(member).party(partyHigh) + .orderType(PartyOrderType.EXERCISE_COUNT).build(); + ReflectionTestUtils.setField(bookmarkHigh, "createdAt", LocalDateTime.now().minusDays(2)); + + GetAllPartyBookmarkResponseDTO dtoLow = GetAllPartyBookmarkResponseDTO.builder() + .partyId(11L).partyName("운동 적은 모임").exerciseCnt(2).build(); + GetAllPartyBookmarkResponseDTO dtoHigh = GetAllPartyBookmarkResponseDTO.builder() + .partyId(12L).partyName("운동 많은 모임").exerciseCnt(10).build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmarkLow, bookmarkHigh))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkHigh), any(), any(), any())) + .willReturn(dtoHigh); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmarkLow), any(), any(), any())) + .willReturn(dtoLow); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.EXERCISE_COUNT); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).partyId()).isEqualTo(12L); // 운동 많은 모임이 먼저 + assertThat(result.get(1).partyId()).isEqualTo(11L); + } + + @Test + @DisplayName("파티에 미래 운동이 있을 때 가장 가까운 운동 정보를 함께 반환한다") + void partyWithFutureExercise_returnsLatestExerciseInfo() { + // given + Exercise futureExercise = ExerciseFixture.createExercise(party, + LocalDate.now().plusDays(7), LocalTime.of(10, 0), true, false); + party.addExercise(futureExercise); + + PartyBookmark bookmark = PartyBookmark.builder() + .member(member).party(party) + .orderType(umc.cockple.demo.domain.party.enums.PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now()); + + GetAllPartyBookmarkResponseDTO dto = GetAllPartyBookmarkResponseDTO.builder() + .partyId(party.getId()) + .latestExerciseDate(LocalDate.now().plusDays(7)) + .latestExerciseTime(ActivityTime.MORNING) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmark))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmark), + eq(futureExercise), eq(ActivityTime.MORNING), any())) + .willReturn(dto); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).latestExerciseDate()).isEqualTo(LocalDate.now().plusDays(7)); + assertThat(result.get(0).latestExerciseTime()).isEqualTo(ActivityTime.MORNING); + } + + @Test + @DisplayName("파티에 미래 운동이 없을 때 exercise는 null로 변환된다") + void partyWithNoFutureExercise_passesNullExercise() { + // given - 과거 운동만 있는 파티 + Exercise pastExercise = ExerciseFixture.createExercise(party, + LocalDate.now().minusDays(1), LocalTime.of(10, 0), true, false); + party.addExercise(pastExercise); + + PartyBookmark bookmark = PartyBookmark.builder() + .member(member).party(party) + .orderType(umc.cockple.demo.domain.party.enums.PartyOrderType.LATEST).build(); + ReflectionTestUtils.setField(bookmark, "createdAt", LocalDateTime.now()); + + GetAllPartyBookmarkResponseDTO dto = GetAllPartyBookmarkResponseDTO.builder() + .partyId(party.getId()) + .latestExerciseDate(null) + .latestExerciseTime(null) + .build(); + + given(memberRepository.findById(member.getId())).willReturn(Optional.of(member)); + given(partyBookmarkRepository.findAllByMemberWithParty(member)) + .willReturn(new ArrayList<>(List.of(bookmark))); + given(bookmarkConverter.partyBookmarkToDTO(eq(bookmark), eq(null), eq(null), any())) + .willReturn(dto); + + // when + List result = + bookmarkQueryService.getAllPartyBookmarks(member.getId(), PartyOrderType.LATEST); + + // then + assertThat(result).hasSize(1); + // null exercise 로 converter가 호출되었는지 검증 + verify(bookmarkConverter, times(1)) + .partyBookmarkToDTO(eq(bookmark), eq(null), eq(null), any()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MemberException(MEMBER_NOT_FOUND)을 던진다") + void memberNotFound_throwsMemberException() { + given(memberRepository.findById(999L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> + bookmarkQueryService.getAllPartyBookmarks(999L, PartyOrderType.LATEST)) + .isInstanceOf(MemberException.class) + .satisfies(e -> assertThat(((MemberException) e).getCode()) + .isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND)); + } + } + } +}