From 689ca2c926662cefdb45d66ea25f6e2d51c7f7ef Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 23 Mar 2026 20:18:12 +0900 Subject: [PATCH 01/18] =?UTF-8?q?chore:=20opencode=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=EC=84=9C=20AGENTS.md=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=80=20gitignore=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 69585293d..95baf9825 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ terraform/terraform.tfstate.backup terraform/terraform.tfvars ### firebase ### -src/main/resources/firebase/*.json \ No newline at end of file +src/main/resources/firebase/*.json + +### local agent docs ### +AGENTS.md \ No newline at end of file From 3f61a07054ef09415601bfa7eb815ff6c10413a2 Mon Sep 17 00:00:00 2001 From: dmori Date: Tue, 24 Mar 2026 20:06:00 +0900 Subject: [PATCH 02/18] =?UTF-8?q?test:=20ExerciseQuery=20getExerciseDetail?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...va => ExerciseCommandIntegrationTest.java} | 203 +--------- .../ExerciseQueryIntegrationTest.java | 347 ++++++++++++++++++ .../service/ExerciseQueryServiceTest.java | 166 +++++++-- .../demo/support/fixture/ExerciseFixture.java | 28 +- .../demo/support/fixture/MemberFixture.java | 8 + 5 files changed, 513 insertions(+), 239 deletions(-) rename src/test/java/umc/cockple/demo/domain/exercise/integration/{ExerciseIntegrationTest.java => ExerciseCommandIntegrationTest.java} (82%) create mode 100644 src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java similarity index 82% rename from src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java rename to src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java index 43d522a4d..fa378c12c 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseCommandIntegrationTest.java @@ -44,7 +44,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -class ExerciseIntegrationTest extends IntegrationTestBase { +class ExerciseCommandIntegrationTest extends IntegrationTestBase { @Autowired MockMvc mockMvc; @Autowired ObjectMapper objectMapper; @@ -1096,205 +1096,4 @@ void guestNotInvitedByMember() throws Exception { } } - @Nested - @DisplayName("GET /api/exercises/{exerciseId} - 운동 상세 조회") - class GetExerciseDetail { - - private Exercise exercise; - - @BeforeEach - void setUp() { - exercise = exerciseRepository.save( - ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1))); - } - - @Nested - @DisplayName("성공 케이스") - class Success { - - @Test - @DisplayName("응답의 모든 주요 필드가 올바르게 반환된다") - void 응답의_모든_주요_필드가_올바르게_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isManager").value(true)) - .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관")) - .andExpect(jsonPath("$.data.info.location").value("서울특별시 강남구 테헤란로 1")) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) - .andExpect(jsonPath("$.data.participants.totalCount").value(10)) - .andExpect(jsonPath("$.data.participants.manCount").value(1)) - .andExpect(jsonPath("$.data.participants.womenCount").value(0)) - .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) - .andExpect(jsonPath("$.data.participants.list[0].name").isString()) - .andExpect(jsonPath("$.data.participants.list[0].gender").value("MALE")) - .andExpect(jsonPath("$.data.participants.list[0].level").isString()) - .andExpect(jsonPath("$.data.participants.list[0].participantType").isString()) - .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)) - .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(0)); - } - - @Test - @DisplayName("활성 회원 참가자는 isWithdrawn false로 반환된다") - void 활성_회원_참가자는_isWithdrawn_false로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)); - } - - @Test - @DisplayName("탈퇴 회원 참가자는 isWithdrawn true로 반환된다") - void 탈퇴_회원_참가자는_isWithdrawn_true로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - Member withdrawnMember = memberRepository.save( - MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 8888L)); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(withdrawnMember, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(true)); - } - - @Test - @DisplayName("모임장이 조회하면 isManager true로 반환된다") - void 모임장이_조회하면_isManager_true로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isManager").value(true)); - } - - @Test - @DisplayName("일반 멤버가 조회하면 isManager false로 반환된다") - void 일반_멤버가_조회하면_isManager_false로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.isManager").value(false)); - } - - @Test - @DisplayName("정원 초과 참가자는 대기자로 반환된다") - void 정원_초과_참가자는_대기자로_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - Exercise smallExercise = exerciseRepository.save( - ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) - .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)); - } - - @Test - @DisplayName("게스트 참가자는 inviterName이 반환된다") - void 게스트_참가자는_inviterName이_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.list[0].participantType").value("GUEST")) - .andExpect(jsonPath("$.data.participants.list[0].inviterName").isString()); - } - - @Test - @DisplayName("먼저 가입한 참가자가 더 낮은 participantNumber를 받는다") - void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) - .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) - .andExpect(jsonPath("$.data.participants.list[1].participantNumber").value(2)) - .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName())) - .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName())); - } - - @Test - @DisplayName("대기자의 성별 카운트가 올바르게 반환된다") - void 대기자의_성별_카운트가_올바르게_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - // 정원 1명짜리 운동: normalMember(MALE) 참가, subManager(FEMALE) 대기 - Exercise smallExercise = exerciseRepository.save( - ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); - - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) - .andExpect(jsonPath("$.data.participants.manCount").value(1)) - .andExpect(jsonPath("$.data.participants.womenCount").value(0)) - .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)) - .andExpect(jsonPath("$.data.waiting.manCount").value(0)) - .andExpect(jsonPath("$.data.waiting.womenCount").value(1)); - } - - @Test - @DisplayName("남성과 여성 참가자가 있을 때 성별 카운트가 올바르게 반환된다") - void 남성과_여성_참가자가_있을_때_성별_카운트가_올바르게_반환된다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - // normalMember: MALE, subManager: FEMALE - memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); - memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) - .andExpect(jsonPath("$.data.participants.manCount").value(1)) - .andExpect(jsonPath("$.data.participants.womenCount").value(1)); - } - } - - @Nested - @DisplayName("실패 케이스") - class Failure { - - @Test - @DisplayName("존재하지 않는 운동이면 에러를 반환한다") - void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); - - mockMvc.perform(get("/api/exercises/{exerciseId}", 999L)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("존재하지 않는 멤버면 에러를 반환한다") - void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { - SecurityContextHelper.setAuthentication(999L, "없는멤버"); - - mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) - .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - } - } } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java new file mode 100644 index 000000000..a1cf10198 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -0,0 +1,347 @@ +package umc.cockple.demo.domain.exercise.integration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +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.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.ExerciseFixture; +import umc.cockple.demo.support.fixture.GuestFixture; +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.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ExerciseQueryIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + @Autowired GuestRepository guestRepository; + + private Member manager; + private Member subManager; + private Member normalMember; + private Member outsider; + private Party party; + + @BeforeEach + void setUp() { + manager = memberRepository.save(MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1001L, LocalDate.of(2000, 1, 1))); + subManager = memberRepository.save(MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 1002L, LocalDate.of(2000, 1, 1))); + normalMember = memberRepository.save(MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 1003L, LocalDate.of(2000, 1, 1))); + outsider = memberRepository.save(MemberFixture.createMember("외부회원", Gender.FEMALE, Level.B, 1004L, LocalDate.of(2001, 1, 1))); + + PartyAddr addr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), addr)); + + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER)); + memberPartyRepository.save(MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER)); + } + + @AfterEach + void tearDown() { + guestRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + @Nested + @DisplayName("GET /api/exercises/{exerciseId} - 운동 상세 조회") + class GetExerciseDetail { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("응답의 모든 주요 필드가 올바르게 반환된다") + void 응답의_모든_주요_필드가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise smallExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(true)) + .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.info.location").value("서울특별시 강남구 테헤란로 1")) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) + .andExpect(jsonPath("$.data.participants.totalCount").value(1)) + .andExpect(jsonPath("$.data.participants.manCount").value(1)) + .andExpect(jsonPath("$.data.participants.womenCount").value(0)) + .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) + .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[0].gender").value("MALE")) + .andExpect(jsonPath("$.data.participants.list[0].level").isString()) + .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("party_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)) + .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)) + .andExpect(jsonPath("$.data.waiting.manCount").value(0)) + .andExpect(jsonPath("$.data.waiting.womenCount").value(1)) + .andExpect(jsonPath("$.data.waiting.list[0].name").value(subManager.getMemberName())) + .andExpect(jsonPath("$.data.waiting.list[0].gender").value("FEMALE")) + .andExpect(jsonPath("$.data.waiting.list[0].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.waiting.list[0].partyPosition").value("party_SUBMANAGER")) + .andExpect(jsonPath("$.data.waiting.list[0].isWithdrawn").value(false)); + } + + @Test + @DisplayName("모임장이 조회하면 isManager true로 반환된다") + void 모임장이_조회하면_isManager_true로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(true)); + } + + @Test + @DisplayName("일반 멤버가 조회하면 isManager false로 반환된다") + void 일반_멤버가_조회하면_isManager_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(false)); + } + + @Test + @DisplayName("부모임장이 조회해도 isManager false로 반환된다") + void 부모임장이_조회해도_isManager_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(subManager.getId(), subManager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(false)); + } + + @Test + @DisplayName("모임 외부 회원이 조회해도 isManager false로 반환된다") + void 모임_외부_회원이_조회해도_isManager_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isManager").value(false)) + .andExpect(jsonPath("$.data.info.buildingName").value("테스트 체육관")); + } + + @Test + @DisplayName("정원 초과 참가자는 대기자로 반환된다") + void 정원_초과_참가자는_대기자로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise smallExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) + .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)); + } + + @Test + @DisplayName("먼저 가입한 참가자가 더 낮은 participantNumber를 받는다") + void 먼저_가입한_참가자가_더_낮은_participantNumber를_받는다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) + .andExpect(jsonPath("$.data.participants.list[0].participantNumber").value(1)) + .andExpect(jsonPath("$.data.participants.list[1].participantNumber").value(2)) + .andExpect(jsonPath("$.data.participants.list[0].name").value(normalMember.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName())); + } + + @Test + @DisplayName("참가자의 성별 카운트가 올바르게 반환된다") + void 참가자의_성별_카운트가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(2)) + .andExpect(jsonPath("$.data.participants.manCount").value(1)) + .andExpect(jsonPath("$.data.participants.womenCount").value(1)); + } + + @Test + @DisplayName("대기자의 성별 카운트가 올바르게 반환된다") + void 대기자의_성별_카운트가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Exercise smallExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, smallExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, smallExercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", smallExercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.currentParticipantCount").value(1)) + .andExpect(jsonPath("$.data.participants.manCount").value(1)) + .andExpect(jsonPath("$.data.participants.womenCount").value(0)) + .andExpect(jsonPath("$.data.waiting.currentWaitingCount").value(1)) + .andExpect(jsonPath("$.data.waiting.manCount").value(0)) + .andExpect(jsonPath("$.data.waiting.womenCount").value(1)); + } + + @Test + @DisplayName("참가자 유형별 partyPosition이 올바르게 반환된다") + void 참가자_유형별_partyPosition이_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(manager, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, exercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + memberExerciseRepository.save(MemberFixture.createExternalMemberExercise(outsider, exercise)); + guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].name").value(manager.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[0].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[0].partyPosition").value("party_MANAGER")) + .andExpect(jsonPath("$.data.participants.list[1].name").value(subManager.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[1].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[1].partyPosition").value("party_SUBMANAGER")) + .andExpect(jsonPath("$.data.participants.list[2].name").value(normalMember.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[2].participantType").value("PARTY_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[2].partyPosition").value("party_MEMBER")) + .andExpect(jsonPath("$.data.participants.list[3].name").value(outsider.getMemberName())) + .andExpect(jsonPath("$.data.participants.list[3].participantType").value("EXTERNAL_PARTICIPANT")) + .andExpect(jsonPath("$.data.participants.list[3].partyPosition").value(nullValue())) + .andExpect(jsonPath("$.data.participants.list[4].name").value("게스트")) + .andExpect(jsonPath("$.data.participants.list[4].participantType").value("GUEST")) + .andExpect(jsonPath("$.data.participants.list[4].partyPosition").value(nullValue())); + } + + @Test + @DisplayName("게스트 참가자의 inviterName이 반환된다") + void 게스트_참가자의_inviterName이_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + guestRepository.save(GuestFixture.createGuest(exercise, manager.getId())); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].participantType").value("GUEST")) + .andExpect(jsonPath("$.data.participants.list[0].inviterName").value(manager.getMemberName())); + } + + @Test + @DisplayName("활성 회원 참가자는 isWithdrawn false로 반환된다") + void 활성_회원_참가자는_isWithdrawn_false로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(false)); + } + + @Test + @DisplayName("탈퇴 회원 참가자는 isWithdrawn true로 반환된다") + void 탈퇴_회원_참가자는_isWithdrawn_true로_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + Member withdrawnMember = memberRepository.save( + MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 8888L)); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(withdrawnMember, exercise)); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.participants.list[0].isWithdrawn").value(true)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 에러를 반환한다") + void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/{exerciseId}", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 59c0863e9..6527bc96e 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -12,9 +12,9 @@ import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; import umc.cockple.demo.domain.exercise.domain.Exercise; -import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; import umc.cockple.demo.domain.exercise.domain.Guest; import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; @@ -23,7 +23,6 @@ import umc.cockple.demo.domain.member.domain.Member; import umc.cockple.demo.domain.member.domain.MemberExercise; import umc.cockple.demo.domain.member.domain.MemberParty; -import umc.cockple.demo.domain.member.enums.MemberStatus; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; @@ -39,12 +38,14 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -85,15 +86,7 @@ void setUp() { exercise = ExerciseFixture.createExercise(party, LocalDate.now().minusDays(1)); ReflectionTestUtils.setField(exercise, "id", 100L); - ExerciseAddr exerciseAddr = ExerciseAddr.builder() - .addr1("서울특별시") - .addr2("강남구") - .streetAddr("서울특별시 강남구 테헤란로 1") - .buildingName("테스트 체육관") - .latitude(37.5) - .longitude(127.0) - .build(); - ReflectionTestUtils.setField(exercise, "exerciseAddr", exerciseAddr); + ReflectionTestUtils.setField(exercise, "exerciseAddr", ExerciseFixture.createExerciseAddr()); } @Nested @@ -129,8 +122,35 @@ class Success { } @Test - @DisplayName("일반_멤버면_isManager_false로_반환된다") - void 일반_멤버면_isManager_false로_반환된다() { + @DisplayName("부모임장이_조회하면_isManager_false로_반환된다") + void 부모임장이_조회하면_isManager_false로_반환된다() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 2003L); + ReflectionTestUtils.setField(subManager, "id", 21L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(subManager.getId())) + .willReturn(Optional.of(subManager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), subManager.getId(), Role.party_MANAGER)) + .willReturn(false); + + // when + ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( + exercise.getId(), subManager.getId()); + + // then + assertThat(response.isManager()).isFalse(); + } + + @Test + @DisplayName("모임_일반_멤버여도_isManager_false로_반환된다") + void 모임_일반_멤버여도_isManager_false로_반환된다() { // given Member normalMember = MemberFixture.createMember("일반멤버", Gender.FEMALE, Level.B, 2002L); ReflectionTestUtils.setField(normalMember, "id", 2L); @@ -155,22 +175,42 @@ class Success { assertThat(response.isManager()).isFalse(); } + @Test + @DisplayName("모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다") + void 모임_외부_회원도_상세_조회에_성공하고_isManager_false로_반환된다() { + // given + Member outsider = MemberFixture.createMember("외부회원", Gender.MALE, Level.C, 3003L); + ReflectionTestUtils.setField(outsider, "id", 3L); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(outsider.getId())) + .willReturn(Optional.of(outsider)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of()); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), outsider.getId(), Role.party_MANAGER)) + .willReturn(false); + + // when + ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( + exercise.getId(), outsider.getId()); + + // then + assertThat(response.isManager()).isFalse(); + assertThat(response.info().buildingName()).isEqualTo("테스트 체육관"); + } + @Test @DisplayName("탈퇴_회원은_isWithdrawn_true로_반환된다") void 탈퇴_회원은_isWithdrawn_true로_반환된다() { // given - Member withdrawnMember = Member.builder() - .memberName("탈퇴회원") - .nickname("탈퇴닉네임") - .gender(Gender.MALE) - .level(Level.C) - .isActive(MemberStatus.INACTIVE) - .socialId(9999L) - .build(); + Member withdrawnMember = MemberFixture.createWithdrawnMember("탈퇴회원", "탈퇴닉네임", 9999L); ReflectionTestUtils.setField(withdrawnMember, "id", 99L); MemberExercise memberExercise = MemberFixture.createMemberExercise(withdrawnMember, exercise); - ReflectionTestUtils.setField(memberExercise, "createdAt", LocalDateTime.now()); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); @@ -205,7 +245,6 @@ class Success { ReflectionTestUtils.setField(activeMember, "id", 2L); MemberExercise memberExercise = MemberFixture.createMemberExercise(activeMember, exercise); - ReflectionTestUtils.setField(memberExercise, "createdAt", LocalDateTime.now()); MemberParty memberParty = MemberFixture.createMemberParty(party, activeMember, Role.party_MEMBER); @@ -238,6 +277,9 @@ class Success { @DisplayName("게스트는_isWithdrawn_false로_반환된다") void 게스트는_isWithdrawn_false로_반환된다() { // given + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 70L); + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); given(memberRepository.findById(manager.getId())) @@ -245,10 +287,12 @@ class Success { given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) .willReturn(List.of()); given(guestRepository.findByExerciseId(exercise.getId())) - .willReturn(List.of()); + .willReturn(List.of(guest)); given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( party.getId(), manager.getId(), Role.party_MANAGER)) .willReturn(true); + given(memberRepository.findMemberNamesByIds(any())) + .willReturn(Map.of(manager.getId(), "모임장")); // when ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( @@ -256,7 +300,78 @@ class Success { // then List participants = response.participants().list(); - assertThat(participants).isEmpty(); + assertThat(participants).hasSize(1); + assertThat(participants.get(0).isWithdrawn()).isFalse(); + assertThat(participants.get(0).partyPosition()).isNull(); + } + + @Test + @DisplayName("참가자_유형별_partyPosition이_올바르게_반환된다") + void 참가자_유형별_partyPosition이_올바르게_반환된다() { + // given + Member subManager = MemberFixture.createMember("부모임장", Gender.FEMALE, Level.B, 5003L); + ReflectionTestUtils.setField(subManager, "id", 31L); + + Member normalMember = MemberFixture.createMember("일반멤버", Gender.MALE, Level.C, 5004L); + ReflectionTestUtils.setField(normalMember, "id", 32L); + + Member outsider = MemberFixture.createMember("외부회원", Gender.FEMALE, Level.B, 5005L); + ReflectionTestUtils.setField(outsider, "id", 33L); + + MemberExercise managerExercise = MemberFixture.createMemberExercise(manager, exercise); + ReflectionTestUtils.setField(managerExercise, "createdAt", LocalDateTime.now().minusMinutes(5)); + + MemberExercise subManagerExercise = MemberFixture.createMemberExercise(subManager, exercise); + ReflectionTestUtils.setField(subManagerExercise, "createdAt", LocalDateTime.now().minusMinutes(4)); + + MemberExercise normalMemberExercise = MemberFixture.createMemberExercise(normalMember, exercise); + ReflectionTestUtils.setField(normalMemberExercise, "createdAt", LocalDateTime.now().minusMinutes(3)); + + MemberExercise outsiderExercise = MemberFixture.createExternalMemberExercise(outsider, exercise); + ReflectionTestUtils.setField(outsiderExercise, "createdAt", LocalDateTime.now().minusMinutes(2)); + + Guest guest = GuestFixture.createGuest(exercise, manager.getId()); + ReflectionTestUtils.setField(guest, "id", 71L); + ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now().minusMinutes(1)); + + MemberParty managerParty = MemberFixture.createMemberParty(party, manager, Role.party_MANAGER); + MemberParty subManagerParty = MemberFixture.createMemberParty(party, subManager, Role.party_SUBMANAGER); + MemberParty memberParty = MemberFixture.createMemberParty(party, normalMember, Role.party_MEMBER); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of(managerExercise, subManagerExercise, normalMemberExercise, outsiderExercise)); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(guest)); + given(memberPartyRepository.existsByPartyIdAndMemberIdAndRole( + party.getId(), manager.getId(), Role.party_MANAGER)) + .willReturn(true); + given(memberPartyRepository.findMemberRolesByPartyAndMembers( + party.getId(), List.of(manager.getId(), subManager.getId(), normalMember.getId(), outsider.getId()))) + .willReturn(List.of(managerParty, subManagerParty, memberParty)); + given(memberRepository.findMemberNamesByIds(any())) + .willReturn(Map.of(manager.getId(), "모임장")); + + // when + ExerciseDetailDTO.Response response = exerciseQueryService.getExerciseDetail( + exercise.getId(), manager.getId()); + + // then + assertThat(response.participants().list()) + .extracting( + ExerciseDetailDTO.ParticipantInfo::name, + ExerciseDetailDTO.ParticipantInfo::participantType, + ExerciseDetailDTO.ParticipantInfo::partyPosition) + .containsExactly( + tuple("모임장", "PARTY_MEMBER", "party_MANAGER"), + tuple("부모임장", "PARTY_MEMBER", "party_SUBMANAGER"), + tuple("일반멤버", "PARTY_MEMBER", "party_MEMBER"), + tuple("외부회원", "EXTERNAL_PARTICIPANT", null), + tuple("게스트", "GUEST", null) + ); } @Test @@ -311,7 +426,6 @@ class Success { // given Guest guest = GuestFixture.createGuest(exercise, manager.getId()); ReflectionTestUtils.setField(guest, "id", 50L); - ReflectionTestUtils.setField(guest, "createdAt", LocalDateTime.now()); given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) .willReturn(Optional.of(exercise)); diff --git a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java index aca0f3fde..88ec98387 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java @@ -9,6 +9,21 @@ public class ExerciseFixture { + public static ExerciseAddr createExerciseAddr() { + return createExerciseAddr("테스트 체육관", "서울특별시 강남구 테헤란로 1"); + } + + public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr) { + return ExerciseAddr.builder() + .addr1("서울특별시") + .addr2("강남구") + .streetAddr(streetAddr) + .buildingName(buildingName) + .latitude(37.5) + .longitude(127.0) + .build(); + } + public static Exercise createExercise(Party party, LocalDate date) { return Exercise.builder() .party(party) @@ -21,7 +36,7 @@ public static Exercise createExercise(Party party, LocalDate date) { } public static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, - boolean partyGuestAccept, boolean outsideGuestAccept) { + boolean partyGuestAccept, boolean outsideGuestAccept) { return Exercise.builder() .party(party) .date(date) @@ -38,15 +53,6 @@ public static Exercise createExerciseWithAddr(Party party, LocalDate date) { } public static Exercise createExerciseWithAddr(Party party, LocalDate date, int maxCapacity) { - ExerciseAddr addr = ExerciseAddr.builder() - .addr1("서울특별시") - .addr2("강남구") - .streetAddr("서울특별시 강남구 테헤란로 1") - .buildingName("테스트 체육관") - .latitude(37.5) - .longitude(127.0) - .build(); - return Exercise.builder() .party(party) .date(date) @@ -54,7 +60,7 @@ public static Exercise createExerciseWithAddr(Party party, LocalDate date, int m .maxCapacity(maxCapacity) .partyGuestAccept(true) .outsideGuestAccept(false) - .exerciseAddr(addr) + .exerciseAddr(createExerciseAddr()) .build(); } } diff --git a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java index 1ed7b6cf3..96d1f147a 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/MemberFixture.java @@ -79,4 +79,12 @@ public static MemberExercise createMemberExercise(Member member, Exercise exerci .exerciseMemberShipStatus(ExerciseMemberShipStatus.PARTY_MEMBER) .build(); } + + public static MemberExercise createExternalMemberExercise(Member member, Exercise exercise) { + return MemberExercise.builder() + .member(member) + .exercise(exercise) + .exerciseMemberShipStatus(ExerciseMemberShipStatus.EXTERNAL_PARTICIPANT) + .build(); + } } From ba43ab526e09aadffdaf38ea68688596bdd2077e Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 25 Mar 2026 13:24:36 +0900 Subject: [PATCH 03/18] =?UTF-8?q?test:=20=EC=9A=B4=EB=8F=99=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=9A=A9=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=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 --- .../ExerciseQueryIntegrationTest.java | 57 +++++++++++++++++++ .../service/ExerciseQueryServiceTest.java | 57 +++++++++++++++++++ .../demo/support/fixture/ExerciseFixture.java | 14 +++++ 3 files changed, 128 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index a1cf10198..de9fe5527 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -344,4 +344,61 @@ class Failure { } } } + + @Nested + @DisplayName("GET /api/exercises/{exerciseId}/for-edit - 운동 수정용 상세 조회") + class GetExerciseForEdit { + + private Exercise exercise; + + @BeforeEach + void setUp() { + Exercise exerciseForEdit = ExerciseFixture.createExerciseWithAddr( + party, LocalDate.of(2026, 3, 24), 18); + ReflectionTestUtils.setField(exerciseForEdit, "endTime", LocalTime.of(12, 30)); + ReflectionTestUtils.setField(exerciseForEdit, "notice", "수정 공지사항"); + exercise = exerciseRepository.save(exerciseForEdit); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("응답의 모든 수정용 필드가 올바르게 반환된다") + void 응답의_모든_수정용_필드가_올바르게_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}/for-edit", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("2026-03-24")) + .andExpect(jsonPath("$.data.buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.roadAddress").value("서울특별시 강남구 테헤란로 1")) + .andExpect(jsonPath("$.data.latitude").value(37.5)) + .andExpect(jsonPath("$.data.longitude").value(127.0)) + .andExpect(jsonPath("$.data.startTime").value("10:00:00")) + .andExpect(jsonPath("$.data.endTime").value("12:30:00")) + .andExpect(jsonPath("$.data.maxCapacity").value(18)) + .andExpect(jsonPath("$.data.allowMemberGuestsInvitation").value(true)) + .andExpect(jsonPath("$.data.allowExternalGuests").value(false)) + .andExpect(jsonPath("$.data.notice").value("수정 공지사항")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 운동이면 에러를 반환한다") + void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}/for-edit", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + } + } } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 6527bc96e..7f8e9169e 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -623,4 +623,61 @@ class Failure { } } } + + @Nested + @DisplayName("getExerciseForEdit") + class GetExerciseForEdit { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("운동 수정용 상세 정보의 모든 필드가 올바르게 반환된다") + void 운동_수정용_상세_정보의_모든_필드가_올바르게_반환된다() { + // given + LocalDate targetDate = LocalDate.of(2026, 3, 24); + Exercise exerciseForEdit = ExerciseFixture.createExerciseForEdit(party, targetDate); + ReflectionTestUtils.setField(exerciseForEdit, "id", 101L); + + given(exerciseRepository.findExerciseWithBasicInfo(exerciseForEdit.getId())) + .willReturn(Optional.of(exerciseForEdit)); + + // when + ExerciseEditDetailDTO.Response response = exerciseQueryService.getExerciseForEdit( + exerciseForEdit.getId(), manager.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.buildingName()).isEqualTo("테스트 체육관"); + assertThat(response.roadAddress()).isEqualTo("서울특별시 강남구 테헤란로 1"); + assertThat(response.latitude()).isEqualTo(37.5); + assertThat(response.longitude()).isEqualTo(127.0); + assertThat(response.startTime()).isEqualTo(LocalTime.of(10, 0)); + assertThat(response.endTime()).isEqualTo(LocalTime.of(12, 30)); + assertThat(response.maxCapacity()).isEqualTo(18); + assertThat(response.allowMemberGuestsInvitation()).isTrue(); + assertThat(response.allowExternalGuests()).isFalse(); + assertThat(response.notice()).isEqualTo("수정 공지사항"); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_예외를_던진다") + void 존재하지_않는_운동이면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseForEdit(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); + } + } + } } diff --git a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java index 88ec98387..7e761d036 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java @@ -63,4 +63,18 @@ public static Exercise createExerciseWithAddr(Party party, LocalDate date, int m .exerciseAddr(createExerciseAddr()) .build(); } + + public static Exercise createExerciseForEdit(Party party, LocalDate date) { + return Exercise.builder() + .party(party) + .date(date) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 30)) + .maxCapacity(18) + .partyGuestAccept(true) + .outsideGuestAccept(false) + .notice("수정 공지사항") + .exerciseAddr(createExerciseAddr()) + .build(); + } } From c309736194487c4febe17b44f733440d23cb636a Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 25 Mar 2026 13:51:14 +0900 Subject: [PATCH 04/18] =?UTF-8?q?test:=20=EB=82=B4=EA=B0=80=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=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 --- .../ExerciseQueryIntegrationTest.java | 81 ++++++++++++ .../service/ExerciseQueryServiceTest.java | 119 ++++++++++++++++++ .../demo/support/fixture/GuestFixture.java | 10 +- 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index de9fe5527..508d8dfe6 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -401,4 +401,85 @@ class Failure { } } } + + @Nested + @DisplayName("GET /api/exercises/{exerciseId}/guests - 내가 초대한 운동 게스트 조회") + class GetMyInvitedGuests { + + private Exercise exercise; + + @BeforeEach + void setUp() { + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1), 1)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다") + void 내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + var myFirstGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트1", Gender.MALE); + guestRepository.save(myFirstGuest); + + var otherGuest = GuestFixture.createGuest(exercise, normalMember.getId(), "다른사람게스트", Gender.MALE); + guestRepository.save(otherGuest); + + var mySecondGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트2", Gender.FEMALE); + guestRepository.save(mySecondGuest); + + mockMvc.perform(get("/api/exercises/{exerciseId}/guests", exercise.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.maleCount").value(1)) + .andExpect(jsonPath("$.data.femaleCount").value(1)) + .andExpect(jsonPath("$.data.list[0].guestId").isNumber()) + .andExpect(jsonPath("$.data.list[0].isWaiting").value(false)) + .andExpect(jsonPath("$.data.list[0].participantNumber").value(1)) + .andExpect(jsonPath("$.data.list[0].name").value("내게스트1")) + .andExpect(jsonPath("$.data.list[0].gender").value("MALE")) + .andExpect(jsonPath("$.data.list[0].level").value("B")) + .andExpect(jsonPath("$.data.list[0].inviterName").value(manager.getMemberName())) + .andExpect(jsonPath("$.data.list[1].guestId").isNumber()) + .andExpect(jsonPath("$.data.list[1].isWaiting").value(true)) + .andExpect(jsonPath("$.data.list[1].participantNumber").value(2)) + .andExpect(jsonPath("$.data.list[1].name").value("내게스트2")) + .andExpect(jsonPath("$.data.list[1].gender").value("FEMALE")) + .andExpect(jsonPath("$.data.list[1].level").value("B")) + .andExpect(jsonPath("$.data.list[1].inviterName").value(manager.getMemberName())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_에러를_반환한다") + void 존재하지_않는_운동이면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/{exerciseId}/guests", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.EXERCISE_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("존재하지_않는_멤버면_에러를_반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/{exerciseId}/guests", exercise.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 7f8e9169e..7721285d9 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -15,6 +15,7 @@ import umc.cockple.demo.domain.exercise.domain.Guest; import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; @@ -680,4 +681,122 @@ class Failure { } } } + + @Nested + @DisplayName("getMyInvitedGuests") + class GetMyInvitedGuests { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다") + void 내가_초대한_게스트만_참가번호와_대기상태와_함께_반환된다() { + // given + ReflectionTestUtils.setField(exercise, "maxCapacity", 1); + + Guest myFirstGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트1", Gender.MALE); + ReflectionTestUtils.setField(myFirstGuest, "id", 201L); + ReflectionTestUtils.setField(myFirstGuest, "createdAt", LocalDateTime.now().minusMinutes(3)); + + Guest otherInvitedGuest = GuestFixture.createGuest(exercise, 2L, "다른사람게스트", Gender.MALE); + ReflectionTestUtils.setField(otherInvitedGuest, "id", 202L); + ReflectionTestUtils.setField(otherInvitedGuest, "createdAt", LocalDateTime.now().minusMinutes(2)); + + Guest mySecondGuest = GuestFixture.createGuest(exercise, manager.getId(), "내게스트2", Gender.FEMALE); + ReflectionTestUtils.setField(mySecondGuest, "id", 203L); + ReflectionTestUtils.setField(mySecondGuest, "createdAt", LocalDateTime.now().minusMinutes(1)); + + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) + .willReturn(List.of(myFirstGuest, mySecondGuest)); + given(memberExerciseRepository.findByExerciseIdWithMemberAndProfile(exercise.getId())) + .willReturn(List.of()); + given(guestRepository.findByExerciseId(exercise.getId())) + .willReturn(List.of(myFirstGuest, otherInvitedGuest, mySecondGuest)); + + // when + ExerciseMyGuestListDTO.Response response = exerciseQueryService.getMyInvitedGuests( + exercise.getId(), manager.getId()); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.maleCount()).isEqualTo(1); + assertThat(response.femaleCount()).isEqualTo(1); + assertThat(response.list()) + .extracting( + ExerciseMyGuestListDTO.GuestInfo::guestId, + ExerciseMyGuestListDTO.GuestInfo::isWaiting, + ExerciseMyGuestListDTO.GuestInfo::participantNumber, + ExerciseMyGuestListDTO.GuestInfo::name, + ExerciseMyGuestListDTO.GuestInfo::gender, + ExerciseMyGuestListDTO.GuestInfo::level, + ExerciseMyGuestListDTO.GuestInfo::inviterName + ) + .containsExactly( + tuple(201L, false, 1, "내게스트1", Gender.MALE, Level.B, manager.getMemberName()), + tuple(203L, true, 2, "내게스트2", Gender.FEMALE, Level.B, manager.getMemberName()) + ); + } + + @Test + @DisplayName("초대한_게스트가_없으면_빈_응답을_반환한다") + void 초대한_게스트가_없으면_빈_응답을_반환한다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(manager.getId())) + .willReturn(Optional.of(manager)); + given(guestRepository.findByExerciseIdAndInviterId(exercise.getId(), manager.getId())) + .willReturn(List.of()); + + // when + ExerciseMyGuestListDTO.Response response = exerciseQueryService.getMyInvitedGuests( + exercise.getId(), manager.getId()); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.maleCount()).isZero(); + assertThat(response.femaleCount()).isZero(); + assertThat(response.list()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_운동이면_예외를_던진다") + void 존재하지_않는_운동이면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyInvitedGuests(999L, manager.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.EXERCISE_NOT_FOUND); + } + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(exerciseRepository.findExerciseWithBasicInfo(exercise.getId())) + .willReturn(Optional.of(exercise)); + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyInvitedGuests(exercise.getId(), 999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + } } diff --git a/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java index 1fd4044d2..23ccffb05 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/GuestFixture.java @@ -8,13 +8,17 @@ public class GuestFixture { public static Guest createGuest(Exercise exercise, Long inviterId) { + return createGuest(exercise, inviterId, "게스트", Gender.MALE); + } + + public static Guest createGuest(Exercise exercise, Long inviterId, String guestName, Gender gender) { Guest guest = Guest.builder() - .guestName("게스트") - .gender(Gender.MALE) + .guestName(guestName) + .gender(gender) .level(Level.B) .inviterId(inviterId) .build(); guest.setExercise(exercise); return guest; } -} \ No newline at end of file +} From 5f0680637da8c61106e6296aeac8073440ecc56f Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 25 Mar 2026 14:08:37 +0900 Subject: [PATCH 05/18] =?UTF-8?q?test:=20=EB=AA=A8=EC=9E=84=20=EC=9A=B4?= =?UTF-8?q?=EB=8F=99=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=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 --- .../ExerciseQueryIntegrationTest.java | 82 ++++++++++ .../service/ExerciseQueryServiceTest.java | 147 ++++++++++++++++++ 2 files changed, 229 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index 508d8dfe6..b557539dc 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -482,4 +482,86 @@ class Failure { } } + @Nested + @DisplayName("GET /api/parties/{partyId}/exercises/calender - 모임 운동 캘린더 조회") + class GetPartyExerciseCalendar { + + private Exercise exercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 24))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("요청한 기간의 모임 운동 캘린더가 반환된다") + void 요청한_기간의_모임_운동_캘린더가_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId()) + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.isMember").value(true)) + .andExpect(jsonPath("$.data.partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[1].date").value("2026-03-24")) + .andExpect(jsonPath("$.data.weeks[0].days[1].dayOfWeek").value("TUESDAY")) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].exerciseId").value(exercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].currentParticipants").value(1)) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].maxCapacity").value(10)) + .andExpect(jsonPath("$.data.weeks[0].days[1].exercises[0].isParticipating").value(true)); + } + + @Test + @DisplayName("기간 내 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_운동이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId()) + .param("startDate", "2026-03-30") + .param("endDate", "2026-04-05")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-30")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.isMember").value(false)) + .andExpect(jsonPath("$.data.partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 에러를 반환한다") + void 시작일과_종료일이_함께_오지_않으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId()) + .param("startDate", startDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getMessage())); + } + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 7721285d9..b9f2a8ab6 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -16,6 +16,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; @@ -28,6 +29,9 @@ 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.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; @@ -799,4 +803,147 @@ class Failure { } } } + + @Nested + @DisplayName("getPartyExerciseCalendar") + class GetPartyExerciseCalendar { + + private Member partyMember; + private Member outsiderMember; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + partyMember = MemberFixture.createMember("파티멤버", Gender.FEMALE, Level.B, 3001L); + ReflectionTestUtils.setField(partyMember, "id", 2L); + + outsiderMember = MemberFixture.createMember("외부멤버", Gender.MALE, Level.C, 3002L); + ReflectionTestUtils.setField(outsiderMember, "id", 3L); + + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + ReflectionTestUtils.setField(exercise, "date", LocalDate.of(2026, 3, 24)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("모임 운동 캘린더를 주차별_일자별로 반환한다") + void 모임_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) + .willReturn(true); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) + .willReturn(List.of(exercise)); + given(exerciseRepository.findExerciseParticipantCounts(party.getId(), startDate, endDate)) + .willReturn(java.util.Collections.singletonList(new Object[]{exercise.getId(), 2})); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + partyMember.getId(), List.of(exercise.getId()))) + .willReturn(List.of(exercise.getId())); + given(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds( + partyMember.getId(), List.of(exercise.getId()))) + .willReturn(List.of(exercise.getId())); + + // when + PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.isMember()).isTrue(); + assertThat(response.partyName()).isEqualTo(party.getPartyName()); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(1).date()) + .isEqualTo(LocalDate.of(2026, 3, 24)); + assertThat(response.weeks().get(0).days().get(1).exercises()) + .extracting( + PartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + PartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, + PartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + PartyExerciseCalendarDTO.ExerciseCalendarItem::currentParticipants, + PartyExerciseCalendarDTO.ExerciseCalendarItem::maxCapacity, + PartyExerciseCalendarDTO.ExerciseCalendarItem::isParticipating) + .containsExactly(tuple(exercise.getId(), true, "테스트 체육관", 2, 10, true)); + } + + @Test + @DisplayName("기간 내 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(outsiderMember.getId())) + .willReturn(Optional.of(outsiderMember)); + given(memberPartyRepository.existsByPartyAndMember(party, outsiderMember)) + .willReturn(false); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), startDate, endDate)) + .willReturn(List.of()); + + // when + PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( + party.getId(), outsiderMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.isMember()).isFalse(); + assertThat(response.partyName()).isEqualTo(party.getPartyName()); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") + void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { + // given + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, null)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + @Test + @DisplayName("삭제된 모임이면 예외를 던진다") + void 삭제된_모임이면_예외를_던진다() { + // given + ReflectionTestUtils.setField(party, "status", PartyStatus.INACTIVE); + + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), startDate, endDate)) + .isInstanceOf(PartyException.class) + .hasFieldOrPropertyWithValue("code", PartyErrorCode.PARTY_IS_DELETED); + } + } + } } From 6a0a3f5e52b6f4260d53c6a69ffc94df44840173 Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 25 Mar 2026 14:32:51 +0900 Subject: [PATCH 06/18] =?UTF-8?q?test:=20=EB=AA=A8=EC=9E=84=20=EC=9A=B4?= =?UTF-8?q?=EB=8F=99=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=EC=97=90=EC=84=9C=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExerciseQueryIntegrationTest.java | 25 +++++++++++++++- .../service/ExerciseQueryServiceTest.java | 29 ++++++++++++++++++ .../support/ExerciseCalendarTestHelper.java | 30 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index b557539dc..bd0ffe957 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -23,6 +23,7 @@ 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.ExerciseCalendarTestHelper; import umc.cockple.demo.support.IntegrationTestBase; import umc.cockple.demo.support.SecurityContextHelper; import umc.cockple.demo.support.fixture.ExerciseFixture; @@ -32,7 +33,6 @@ import java.time.LocalDate; import java.time.LocalTime; - import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -544,6 +544,29 @@ class Success { .andExpect(jsonPath("$.data.partyName").value("테스트 모임")) .andExpect(jsonPath("$.data.weeks").isEmpty()); } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(8); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate)); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, defaultExercise)); + + mockMvc.perform(get("/api/parties/{partyId}/exercises/calender", party.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].isParticipating").value(true)); + } } @Nested diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index b9f2a8ab6..98a0bbac7 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -16,6 +16,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; @@ -36,6 +37,7 @@ 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.ExerciseCalendarTestHelper; import umc.cockple.demo.support.fixture.ExerciseFixture; import umc.cockple.demo.support.fixture.GuestFixture; import umc.cockple.demo.support.fixture.MemberFixture; @@ -905,6 +907,33 @@ class Success { assertThat(response.partyName()).isEqualTo(party.getPartyName()); assertThat(response.weeks()).isEmpty(); } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(partyRepository.findByIdWithLevels(party.getId())) + .willReturn(Optional.of(party)); + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.existsByPartyAndMember(party, partyMember)) + .willReturn(true); + given(exerciseRepository.findByPartyIdAndDateRange(party.getId(), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + PartyExerciseCalendarDTO.Response response = exerciseQueryService.getPartyExerciseCalendar( + party.getId(), partyMember.getId(), null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.isMember()).isTrue(); + assertThat(response.weeks()).isEmpty(); + } } @Nested diff --git a/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java b/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java new file mode 100644 index 000000000..9a4dccaa2 --- /dev/null +++ b/src/test/java/umc/cockple/demo/support/ExerciseCalendarTestHelper.java @@ -0,0 +1,30 @@ +package umc.cockple.demo.support; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +public final class ExerciseCalendarTestHelper { + + private ExerciseCalendarTestHelper() { + } + + public static LocalDate expectedDefaultStartDate() { + LocalDate today = LocalDate.now(); + LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1L); + return thisWeekMonday.minusWeeks(1); + } + + public static LocalDate expectedDefaultEndDate() { + LocalDate today = LocalDate.now(); + LocalDate thisWeekMonday = today.minusDays(today.getDayOfWeek().getValue() - 1L); + return thisWeekMonday.plusWeeks(3).plusDays(6); + } + + public static int weekIndexFor(LocalDate expectedStart, LocalDate targetDate) { + return (int) (ChronoUnit.DAYS.between(expectedStart, targetDate) / 7); + } + + public static int dayIndexFor(LocalDate targetDate) { + return targetDate.getDayOfWeek().getValue() - 1; + } +} From 402e072b6cbb10a847c27687b41d81568b4f0ff6 Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 25 Mar 2026 14:37:05 +0900 Subject: [PATCH 07/18] =?UTF-8?q?test:=20=EB=82=B4=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A1=B0=ED=9A=8C=20API=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 --- .../ExerciseQueryIntegrationTest.java | 109 ++++++++++++- .../service/ExerciseQueryServiceTest.java | 148 ++++++++++++++++++ 2 files changed, 252 insertions(+), 5 deletions(-) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index bd0ffe957..240f234ca 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -1,10 +1,6 @@ package umc.cockple.demo.domain.exercise.integration; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; @@ -33,6 +29,7 @@ import java.time.LocalDate; import java.time.LocalTime; + import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -587,4 +584,106 @@ class Failure { } } + @Nested + @DisplayName("GET /api/exercises/my/calender - 내 운동 캘린더 조회") + class GetMyExerciseCalendar { + + private Exercise exercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("요청한 기간의 내 운동 캘린더가 반환된다") + void 요청한_기간의_내_운동_캘린더가_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, exercise)); + + mockMvc.perform(get("/api/exercises/my/calender") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[2].date").value("2026-03-25")) + .andExpect(jsonPath("$.data.weeks[0].days[2].dayOfWeek").value("WEDNESDAY")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].startTime").value("10:00:00")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].endTime").value(nullValue())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].profileImageUrl").value(nullValue())); + } + + @Test + @DisplayName("기간 내 참여 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_참여_운동이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/my/calender") + .param("startDate", "2026-03-30") + .param("endDate", "2026-04-05")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-30")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(8); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate)); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, defaultExercise)); + + mockMvc.perform(get("/api/exercises/my/calender")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].partyId").value(party.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 에러를 반환한다") + void 시작일과_종료일이_함께_오지_않으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(manager.getId(), manager.getNickname()); + + mockMvc.perform(get("/api/exercises/my/calender") + .param("startDate", startDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_DATE_RANGE.getMessage())); + } + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 98a0bbac7..410c6876e 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -975,4 +975,152 @@ class Failure { } } } + + @Nested + @DisplayName("getMyExerciseCalendar") + class GetMyExerciseCalendar { + + private Member calendarMember; + private LocalDate startDate; + private LocalDate endDate; + private Exercise myExercise; + + @BeforeEach + void setUp() { + calendarMember = MemberFixture.createMember("캘린더멤버", Gender.FEMALE, Level.B, 4001L); + ReflectionTestUtils.setField(calendarMember, "id", 4L); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + myExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); + ReflectionTestUtils.setField(myExercise, "id", 200L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 운동 캘린더를 주차별_일자별로 반환한다") + void 내_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) + .willReturn(List.of(myExercise)); + + // when + MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(2).date()) + .isEqualTo(LocalDate.of(2026, 3, 25)); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + MyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + MyExerciseCalendarDTO.ExerciseCalendarItem::partyId, + MyExerciseCalendarDTO.ExerciseCalendarItem::partyName, + MyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + MyExerciseCalendarDTO.ExerciseCalendarItem::startTime, + MyExerciseCalendarDTO.ExerciseCalendarItem::endTime, + MyExerciseCalendarDTO.ExerciseCalendarItem::profileImageUrl) + .containsExactly(tuple(200L, 10L, "테스트 모임", "테스트 체육관", LocalTime.of(10, 0), null, null)); + } + + @Test + @DisplayName("기간 내 참여 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_참여_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), startDate, endDate)) + .willReturn(List.of()); + + // when + MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(exerciseRepository.findByMemberIdAndDateRange(calendarMember.getId(), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + MyExerciseCalendarDTO.Response response = exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar(999L, startDate, endDate)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("시작일과 종료일이 함께 오지 않으면 예외를 던진다") + void 시작일과_종료일이_함께_오지_않으면_예외를_던진다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), startDate, null)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_DATE_RANGE); + } + + @Test + @DisplayName("시작일이 종료일과 같거나 늦으면 예외를 던진다") + void 시작일이_종료일과_같거나_늦으면_예외를_던진다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExerciseCalendar( + calendarMember.getId(), endDate, startDate)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INVALID_DATE_RANGE); + } + } + } } From f4165af1ba87c4e7e165ef5464c2337c714b7dd5 Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 25 Mar 2026 15:42:10 +0900 Subject: [PATCH 08/18] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=9D=98=20=EC=8B=9C=EA=B0=84=20=EC=A1=B4?= =?UTF-8?q?=EC=9D=84=20Asia=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/umc/cockple/demo/support/IntegrationTestConfig.java | 3 ++- src/test/resources/mysql-conf/timezone.cnf | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/mysql-conf/timezone.cnf diff --git a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java index 5c65162b4..097910997 100644 --- a/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java +++ b/src/test/java/umc/cockple/demo/support/IntegrationTestConfig.java @@ -14,7 +14,8 @@ public class IntegrationTestConfig { private static final MySQLContainer mysql = - new MySQLContainer<>("mysql:8.0.36"); + new MySQLContainer<>("mysql:8.0.36") + .withConfigurationOverride("mysql-conf"); private static final RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7.2-alpine")); diff --git a/src/test/resources/mysql-conf/timezone.cnf b/src/test/resources/mysql-conf/timezone.cnf new file mode 100644 index 000000000..577235cfe --- /dev/null +++ b/src/test/resources/mysql-conf/timezone.cnf @@ -0,0 +1,2 @@ +[mysqld] +default-time-zone = '+09:00' From a8b23e4bdc70b441f5068658e55b48d3c8fc5be3 Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 25 Mar 2026 15:44:26 +0900 Subject: [PATCH 09/18] =?UTF-8?q?test:=20=EB=82=B4=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EC=9A=B4=EB=8F=99=20=EC=A1=B0=ED=9A=8C=20API=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 --- .../ExerciseQueryIntegrationTest.java | 86 +++++++++++++++ .../service/ExerciseQueryServiceTest.java | 100 ++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index 240f234ca..19ebb6547 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import umc.cockple.demo.domain.exercise.domain.Exercise; @@ -29,6 +30,9 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.Map; + +import javax.sql.DataSource; import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -45,6 +49,7 @@ class ExerciseQueryIntegrationTest extends IntegrationTestBase { @Autowired ExerciseRepository exerciseRepository; @Autowired MemberExerciseRepository memberExerciseRepository; @Autowired GuestRepository guestRepository; + @Autowired DataSource dataSource; private Member manager; private Member subManager; @@ -686,4 +691,85 @@ class Failure { } } + @Nested + @DisplayName("GET /api/exercises/parties/my - 내 모임 운동 조회") + class GetMyPartyExercise { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("시작한 운동은 제외하고 내가 속한 모임의 예정된 운동을 최대 6개까지 시간순으로 반환한다") + void 시작한_운동은_제외하고_내가_속한_모임의_예정된_운동을_최대_6개까지_시간순으로_반환한다() throws Exception { + PartyAddr otherAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "송파구")); + Party otherParty = partyRepository.save(PartyFixture.createParty("다른 모임", outsider.getId(), otherAddr)); + + Exercise pastExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().minusDays(1))); + Exercise startedTodayExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.now()); + ReflectionTestUtils.setField(startedTodayExercise, "startTime", LocalTime.now().minusMinutes(30)); + startedTodayExercise = exerciseRepository.save(startedTodayExercise); + Exercise firstExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(1))); + Exercise secondExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(2))); + Exercise thirdExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(3))); + Exercise fourthExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(4))); + Exercise fifthExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(5))); + Exercise sixthExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(6))); + exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(7))); + exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(otherParty, LocalDate.now().plusDays(1))); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(6)) + .andExpect(jsonPath("$.data.exercises.length()").value(6)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(firstExercise.getId())) + .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(secondExercise.getId())) + .andExpect(jsonPath("$.data.exercises[2].exerciseId").value(thirdExercise.getId())) + .andExpect(jsonPath("$.data.exercises[3].exerciseId").value(fourthExercise.getId())) + .andExpect(jsonPath("$.data.exercises[4].exerciseId").value(fifthExercise.getId())) + .andExpect(jsonPath("$.data.exercises[5].exerciseId").value(sixthExercise.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.exercises[0].profileImageUrl").value(nullValue())); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 응답을 반환한다") + void 속한_모임이_없으면_빈_응답을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/parties/my")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 410c6876e..2ac6d8ec4 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -17,6 +17,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; @@ -54,7 +55,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @DisplayName("ExerciseQueryService") @@ -1123,4 +1128,99 @@ class Failure { } } } + + @Nested + @DisplayName("getMyPartyExercise") + class GetMyPartyExercise { + + private Member partyMember; + private Exercise firstUpcomingExercise; + private Exercise secondUpcomingExercise; + + @BeforeEach + void setUp() { + partyMember = MemberFixture.createMember("내모임멤버", Gender.MALE, Level.B, 5001L); + ReflectionTestUtils.setField(partyMember, "id", 5L); + + firstUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 1)); + ReflectionTestUtils.setField(firstUpcomingExercise, "id", 301L); + + secondUpcomingExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 4, 2)); + ReflectionTestUtils.setField(secondUpcomingExercise, "id", 302L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 모임의 예정된 운동 목록을 반환한다") + void 내_모임의_예정된_운동_목록을_반환한다() { + // given + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findRecentExercisesByPartyIds(eq(List.of(party.getId())), argThat( + (org.springframework.data.domain.Pageable pageable) -> pageable.getPageNumber() == 0 && pageable.getPageSize() == 6))) + .willReturn(List.of(firstUpcomingExercise, secondUpcomingExercise)); + + // when + MyPartyExerciseDTO.Response response = exerciseQueryService.getMyPartyExercise(partyMember.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(2); + assertThat(response.exercises()) + .extracting( + MyPartyExerciseDTO.Exercises::exerciseId, + MyPartyExerciseDTO.Exercises::partyId, + MyPartyExerciseDTO.Exercises::partyName, + MyPartyExerciseDTO.Exercises::buildingName, + MyPartyExerciseDTO.Exercises::date, + MyPartyExerciseDTO.Exercises::dayOfWeek, + MyPartyExerciseDTO.Exercises::startTime, + MyPartyExerciseDTO.Exercises::profileImageUrl) + .containsExactly( + tuple(301L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 1), "WEDNESDAY", LocalTime.of(10, 0), null), + tuple(302L, 10L, "테스트 모임", "테스트 체육관", LocalDate.of(2026, 4, 2), "THURSDAY", LocalTime.of(10, 0), null) + ); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 응답을 반환한다") + void 속한_모임이_없으면_빈_응답을_반환한다() { + // given + given(memberRepository.findById(partyMember.getId())) + .willReturn(Optional.of(partyMember)); + given(memberPartyRepository.findPartyIdsByMemberId(partyMember.getId())) + .willReturn(List.of()); + + // when + MyPartyExerciseDTO.Response response = exerciseQueryService.getMyPartyExercise(partyMember.getId()); + + // then + assertThat(response.totalExercises()).isZero(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository, never()).findRecentExercisesByPartyIds(any(), any()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyPartyExercise(999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + } } From 81f5a929261e372127d33c4c62244b5c6a6e40f3 Mon Sep 17 00:00:00 2001 From: dmori Date: Thu, 26 Mar 2026 18:49:57 +0900 Subject: [PATCH 10/18] =?UTF-8?q?chore:=20claude=20code=EC=99=80=20omc=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EA=B9=83=20?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=85=B8=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 95baf9825..c03094428 100644 --- a/.gitignore +++ b/.gitignore @@ -52,5 +52,6 @@ terraform/terraform.tfvars ### firebase ### src/main/resources/firebase/*.json -### local agent docs ### -AGENTS.md \ No newline at end of file +### Claude / OMC ### +.claude/ +.omc/ \ No newline at end of file From 76a8e25fb966e679e8748c9ffe7ae7b70114e18e Mon Sep 17 00:00:00 2001 From: dmori Date: Thu, 26 Mar 2026 18:50:14 +0900 Subject: [PATCH 11/18] =?UTF-8?q?test:=20=EB=82=B4=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EC=9A=B4=EB=8F=99=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=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 --- .../ExerciseQueryIntegrationTest.java | 129 +++++++++++++ .../service/ExerciseQueryServiceTest.java | 180 ++++++++++++++++++ 2 files changed, 309 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index 19ebb6547..c4d03f11c 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -772,4 +772,133 @@ class Failure { } } + @Nested + @DisplayName("GET /api/exercises/parties/my/calendar - 내 모임 운동 캘린더 조회") + class GetMyPartyExerciseCalendar { + + private Exercise exercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("요청한 기간의 내 모임 운동 캘린더가 반환된다") + void 요청한_기간의_내_모임_운동_캘린더가_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].weekStartDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks[0].weekEndDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[2].date").value("2026-03-25")) + .andExpect(jsonPath("$.data.weeks[0].days[2].dayOfWeek").value("WEDNESDAY")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].nowCapacity").value(0)); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 캘린더를 반환한다") + void 속한_모임이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + + @Test + @DisplayName("기간 내 내 모임 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_내_모임_운동이_없으면_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", "2026-03-30") + .param("endDate", "2026-04-05")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-30")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(8); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, defaultExerciseDate)); + + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].partyId").value(party.getId())); + } + + @Test + @DisplayName("POPULARITY 정렬 옵션으로 조회 시 정상 반환된다") + void POPULARITY_정렬_옵션으로_조회_시_정상_반환된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("orderType", "POPULARITY") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-29")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(exercise.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/parties/my/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 2ac6d8ec4..d2e63962b 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -17,8 +17,10 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; @@ -47,6 +49,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -1223,4 +1226,181 @@ class Failure { } } } + + @Nested + @DisplayName("getMyPartyExerciseCalendar") + class GetMyPartyExerciseCalendar { + + private Member calendarMember; + private Exercise calendarExercise; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + calendarMember = MemberFixture.createMember("내모임캘린더멤버", Gender.FEMALE, Level.B, 6001L); + ReflectionTestUtils.setField(calendarMember, "id", 6L); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 3, 29); + + calendarExercise = ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2026, 3, 25)); + ReflectionTestUtils.setField(calendarExercise, "id", 400L); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("내 모임 운동 캘린더를 주차별_일자별로 반환한다") + void 내_모임_운동_캘린더를_주차별_일자별로_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of(calendarExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + calendarMember.getId(), List.of(calendarExercise.getId()))) + .willReturn(List.of()); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(calendarExercise.getId()), startDate, endDate)) + .willReturn(Collections.singletonList(new Object[]{calendarExercise.getId(), 3})); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(1); + assertThat(response.weeks().get(0).weekStartDate()).isEqualTo(startDate); + assertThat(response.weeks().get(0).weekEndDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(2).date()) + .isEqualTo(LocalDate.of(2026, 3, 25)); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::exerciseId, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyId, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::partyName, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::buildingName, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::isBookmarked, + MyPartyExerciseCalendarDTO.ExerciseCalendarItem::nowCapacity) + .containsExactly(tuple(400L, 10L, "테스트 모임", "테스트 체육관", false, 3)); + } + + @Test + @DisplayName("북마크한 운동은 isBookmarked가 true로 반환된다") + void 북마크한_운동은_isBookmarked가_true로_반환된다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of(calendarExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + calendarMember.getId(), List.of(calendarExercise.getId()))) + .willReturn(List.of(calendarExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(calendarExercise.getId()), startDate, endDate)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.weeks().get(0).days().get(2).exercises().get(0).isBookmarked()).isTrue(); + } + + @Test + @DisplayName("속한 모임이 없으면 빈 캘린더를 반환한다") + void 속한_모임이_없으면_빈_캘린더를_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + verify(exerciseRepository, never()).findByPartyIdsAndDateRange(any(), any(), any()); + } + + @Test + @DisplayName("기간 내 내 모임 운동이 없으면 빈 캘린더를 반환한다") + void 기간_내_내_모임_운동이_없으면_빈_캘린더를_반환한다() { + // given + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), startDate, endDate)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, startDate, endDate); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).isEmpty(); + } + + @Test + @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") + void 시작일과_종료일이_없으면_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(memberRepository.findById(calendarMember.getId())) + .willReturn(Optional.of(calendarMember)); + given(memberPartyRepository.findPartyIdsByMemberId(calendarMember.getId())) + .willReturn(List.of(party.getId())); + given(exerciseRepository.findByPartyIdsAndDateRange(List.of(party.getId()), expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + MyPartyExerciseCalendarDTO.Response response = exerciseQueryService.getMyPartyExerciseCalendar( + calendarMember.getId(), MyPartyExerciseOrderType.LATEST, null, null); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지_않는_멤버면_예외를_던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyPartyExerciseCalendar( + 999L, MyPartyExerciseOrderType.LATEST, startDate, endDate)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + } } From 524a759ac64f6ae67bf319871b01c45e50c69991 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 28 Mar 2026 01:10:09 +0900 Subject: [PATCH 12/18] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=9A=B4=EB=8F=99=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=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 --- ...ExerciseRecommendationIntegrationTest.java | 281 ++++++++++++++++ .../ExerciseRecommendationServiceTest.java | 304 ++++++++++++++++++ .../demo/support/fixture/ExerciseFixture.java | 24 +- 3 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java new file mode 100644 index 000000000..cb51d226d --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseRecommendationIntegrationTest.java @@ -0,0 +1,281 @@ +package umc.cockple.demo.domain.exercise.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.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; +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.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.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ExerciseRecommendationIntegrationTest extends IntegrationTestBase { + + @Autowired MockMvc mockMvc; + @Autowired MemberRepository memberRepository; + @Autowired MemberAddrRepository memberAddrRepository; + @Autowired MemberPartyRepository memberPartyRepository; + @Autowired MemberExerciseRepository memberExerciseRepository; + @Autowired PartyRepository partyRepository; + @Autowired PartyAddrRepository partyAddrRepository; + @Autowired ExerciseRepository exerciseRepository; + @Autowired GuestRepository guestRepository; + + // 조회 대상 회원 (모임 외부인, 추천 운동 수신 대상) + private Member outsider; + // 모임장 (모임 소속, 추천에서 제외) + private Member manager; + private Party party; + + @BeforeEach + void setUp() { + // 추천 대상 회원: MALE, Level.A, 1995년생 + outsider = memberRepository.save( + MemberFixture.createMember("외부회원", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 6, 15))); + + // 대표 주소 저장 (서울 강남구, lat=37.5, lon=127.0) + MemberAddr addr = memberAddrRepository.save(MemberAddrFixture.createMainAddr(outsider)); + + // 모임장 (모임 소속) + manager = memberRepository.save( + MemberFixture.createMember("모임장", Gender.MALE, Level.A, 1002L, LocalDate.of(1995, 1, 1))); + + // 모임 생성 (minBirthYear=1990, maxBirthYear=2005) + PartyAddr partyAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "강남구")); + party = partyRepository.save(PartyFixture.createParty("테스트 모임", manager.getId(), partyAddr)); + + // PartyLevel 추가: MALE A급 (cascade로 저장됨) + party.addLevel(Gender.MALE, Level.A); + partyRepository.save(party); + + // 모임장을 모임 멤버로 등록 + memberPartyRepository.save(MemberFixture.createMemberParty(party, manager, Role.party_MANAGER)); + } + + @AfterEach + void tearDown() { + guestRepository.deleteAll(); + memberExerciseRepository.deleteAll(); + exerciseRepository.deleteAll(); + memberPartyRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberAddrRepository.deleteAll(); + memberRepository.deleteAll(); + SecurityContextHelper.clearAuthentication(); + } + + @Nested + @DisplayName("GET /api/exercises/recommendations - 사용자 추천 운동 조회") + class GetRecommendedExercises { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("추천 운동이 존재하면 200 OK와 운동 목록을 반환한다") + void 추천_운동이_존재하면_목록을_반환한다() throws Exception { + // given + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "테스트 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(1)) + .andExpect(jsonPath("$.data.exercises").isArray()) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(false)); + } + + @Test + @DisplayName("응답 필드가 모두 존재한다") + void 응답_필드가_모두_존재한다() throws Exception { + // given + Exercise saved = exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "필드확인 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(saved.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").exists()) + .andExpect(jsonPath("$.data.exercises[0].date").exists()) + .andExpect(jsonPath("$.data.exercises[0].dayOfWeek").exists()) + .andExpect(jsonPath("$.data.exercises[0].startTime").exists()) + .andExpect(jsonPath("$.data.exercises[0].buildingName").exists()) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").exists()); + } + + @Test + @DisplayName("추천 운동이 없으면 빈 목록과 totalExercises 0을 반환한다") + void 추천_운동이_없으면_빈_목록을_반환한다() throws Exception { + // given - 운동 없음 + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + + @Test + @DisplayName("이미 소속된 모임의 운동은 추천되지 않는다") + void 소속된_모임의_운동은_추천되지_않는다() throws Exception { + // given - outsider를 모임에 가입시킴 + memberPartyRepository.save( + MemberFixture.createMemberParty(party, outsider, Role.party_MEMBER)); + + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "테스트 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then - 소속 모임이므로 추천 목록에서 제외 + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("outsideGuestAccept=false인 운동은 추천되지 않는다") + void outsideGuestAccept_false_운동은_추천되지_않는다() throws Exception { + // given - outsideGuestAccept=false 운동 + exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.now().plusDays(3))); + // createExerciseWithAddr는 outsideGuestAccept=false + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("이미 지난 운동은 추천되지 않는다") + void 지난_운동은_추천되지_않는다() throws Exception { + // given - 과거 날짜 운동 + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().minusDays(1), 37.5, 127.0, "과거 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("이미 참여한 운동은 추천되지 않는다") + void 이미_참여한_운동은_추천되지_않는다() throws Exception { + // given + Exercise ex = exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(3), 37.5, 127.0, "참여완료 체육관")); + memberExerciseRepository.save(MemberFixture.createMemberExercise(outsider, ex)); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(0)); + } + + @Test + @DisplayName("거리 가까운 순으로 정렬되어 반환된다") + void 거리_가까운_순으로_정렬된다() throws Exception { + // given + // 가까운 운동 (강남, lat=37.5 lon=127.0 - outsider 대표주소와 동일) + Exercise nearExercise = exerciseRepository.save( + ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(5), 37.5, 127.0, "가까운 체육관")); + // 먼 운동 (부산 해운대, lat=35.1 lon=129.1) + Exercise farExercise = exerciseRepository.save( + ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(1), 35.1, 129.1, "먼 체육관")); + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then - 날짜가 늦어도 가까운 운동이 먼저 + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(2)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(nearExercise.getId())) + .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(farExercise.getId())); + } + + @Test + @DisplayName("최대 10개까지만 반환된다") + void 최대_10개까지만_반환된다() throws Exception { + // given - 12개 운동 저장 + for (int i = 1; i <= 12; i++) { + exerciseRepository.save(ExerciseFixture.createRecommendableExercise(party, + LocalDate.now().plusDays(i), 37.5, 127.0, "체육관" + i)); + } + + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalExercises").value(10)) + .andExpect(jsonPath("$.data.exercises.length()").value(10)); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("대표 주소가 없으면 400 에러를 반환한다") + void 대표_주소가_없으면_400_에러를_반환한다() throws Exception { + // given - 대표 주소 없는 회원 + Member noAddrMember = memberRepository.save( + MemberFixture.createMember("주소없는회원", Gender.MALE, Level.A, 9999L, + LocalDate.of(1995, 1, 1))); + // MemberAddr 저장 안 함 + + SecurityContextHelper.setAuthentication(noAddrMember.getId(), noAddrMember.getNickname()); + + // when & then + mockMvc.perform(get("/api/exercises/recommendations")) + .andExpect(status().isBadRequest()); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java new file mode 100644 index 000000000..4420a0d00 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseRecommendationServiceTest.java @@ -0,0 +1,304 @@ +package umc.cockple.demo.domain.exercise.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.repository.ExerciseBookmarkRepository; +import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.domain.ExerciseAddr; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationDTO; +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.exercise.repository.GuestRepository; +import umc.cockple.demo.domain.file.service.FileService; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +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.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.MemberAddrFixture; +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.Collections; +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.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExerciseQueryService - 사용자 추천 운동 조회") +class ExerciseRecommendationServiceTest { + + @InjectMocks + private ExerciseQueryService exerciseQueryService; + + @Mock private ExerciseRepository exerciseRepository; + @Mock private MemberRepository memberRepository; + @Mock private MemberPartyRepository memberPartyRepository; + @Mock private MemberExerciseRepository memberExerciseRepository; + @Mock private GuestRepository guestRepository; + @Mock private PartyRepository partyRepository; + @Mock private ExerciseBookmarkRepository exerciseBookmarkRepository; + @Mock private FileService fileService; + + private Member member; + private MemberAddr mainAddr; + private Party party; + private Exercise exercise; + + @BeforeEach + void setUp() { + ExerciseConverter exerciseConverter = new ExerciseConverter(fileService); + ReflectionTestUtils.setField(exerciseQueryService, "exerciseConverter", exerciseConverter); + + member = MemberFixture.createMember("테스트회원", Gender.MALE, Level.A, 1001L, LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(member, "id", 1L); + + mainAddr = MemberAddrFixture.createMainAddr(member); + List addresses = new ArrayList<>(); + addresses.add(mainAddr); + ReflectionTestUtils.setField(member, "addresses", addresses); + + party = PartyFixture.createParty("테스트 모임", member.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(party, "id", 10L); + + ExerciseAddr exerciseAddr = ExerciseFixture.createExerciseAddr(); + exercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(3), + null, true, true); + ReflectionTestUtils.setField(exercise, "id", 100L); + ReflectionTestUtils.setField(exercise, "exerciseAddr", exerciseAddr); + } + + @Nested + @DisplayName("getRecommendedExercises") + class GetRecommendedExercises { + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("추천 운동이 존재하면 운동 목록과 총 개수를 반환한다") + void 추천_운동이_존재하면_목록과_총개수를_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(1); + assertThat(response.exercises()).hasSize(1); + } + + @Test + @DisplayName("추천 운동의 필드가 올바르게 매핑된다") + void 추천_운동_필드가_올바르게_매핑된다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + ExerciseRecommendationDTO.ExerciseItem item = response.exercises().get(0); + assertThat(item.exerciseId()).isEqualTo(100L); + assertThat(item.partyId()).isEqualTo(10L); + assertThat(item.partyName()).isEqualTo("테스트 모임"); + assertThat(item.date()).isEqualTo(exercise.getDate()); + assertThat(item.dayOfWeek()).isEqualTo(exercise.getDate().getDayOfWeek().name()); + assertThat(item.buildingName()).isEqualTo("테스트 체육관"); + assertThat(item.isBookmarked()).isFalse(); + } + + @Test + @DisplayName("찜한 운동은 isBookmarked가 true로 반환된다") + void 찜한_운동은_isBookmarked가_true로_반환된다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(exercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of(100L)); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.exercises().get(0).isBookmarked()).isTrue(); + } + + @Test + @DisplayName("추천 운동이 없으면 빈 목록과 totalExercises 0을 반환한다") + void 추천_운동이_없으면_빈_목록을_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(Collections.emptyList()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(0); + assertThat(response.exercises()).isEmpty(); + } + + @Test + @DisplayName("추천 운동이 10개를 초과하면 거리순으로 최대 10개만 반환된다") + void 추천_운동이_10개_초과하면_거리순으로_10개만_반환된다() { + // given - 같은 위치(거리 0)의 운동 12개 생성 + List candidates = new ArrayList<>(); + for (int i = 1; i <= 12; i++) { + Exercise ex = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(i), + null, true, true); + ReflectionTestUtils.setField(ex, "id", (long) (100 + i)); + ReflectionTestUtils.setField(ex, "exerciseAddr", ExerciseFixture.createExerciseAddr()); + candidates.add(ex); + } + + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(candidates); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then + assertThat(response.totalExercises()).isEqualTo(10); + assertThat(response.exercises()).hasSize(10); + } + + @Test + @DisplayName("거리가 가까운 운동이 먼저 정렬된다") + void 거리가_가까운_운동이_먼저_정렬된다() { + // given - 거리가 다른 두 운동 (좌표 차이로 구분) + ExerciseAddr nearAddr = ExerciseAddr.builder() + .addr1("서울특별시").addr2("강남구") + .streetAddr("테헤란로 1").buildingName("가까운 체육관") + .latitude(37.5).longitude(127.0) // mainAddr과 동일 위치 -> 거리 0 + .build(); + ExerciseAddr farAddr = ExerciseAddr.builder() + .addr1("부산광역시").addr2("해운대구") + .streetAddr("해운대로 1").buildingName("먼 체육관") + .latitude(35.1).longitude(129.1) // 부산 -> 거리 멀다 + .build(); + + Exercise nearExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(5), + null, true, true); + ReflectionTestUtils.setField(nearExercise, "id", 101L); + ReflectionTestUtils.setField(nearExercise, "exerciseAddr", nearAddr); + + Exercise farExercise = ExerciseFixture.createExercise(party, LocalDate.now().plusDays(1), + null, true, true); + ReflectionTestUtils.setField(farExercise, "id", 102L); + ReflectionTestUtils.setField(farExercise, "exerciseAddr", farAddr); + + given(memberRepository.findMemberWithAddresses(member.getId())) + .willReturn(Optional.of(member)); + given(exerciseRepository.findExercisesByMemberIdAndLevelAndBirthYear( + eq(member.getId()), eq(Gender.MALE), eq(Level.A), eq(1995))) + .willReturn(List.of(farExercise, nearExercise)); // 먼 것을 먼저 넣어도 + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + eq(member.getId()), anyList())) + .willReturn(List.of()); + + // when + ExerciseRecommendationDTO.Response response = + exerciseQueryService.getRecommendedExercises(member.getId()); + + // then - 가까운 운동이 먼저 + assertThat(response.exercises().get(0).exerciseId()).isEqualTo(101L); + assertThat(response.exercises().get(1).exerciseId()).isEqualTo(102L); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 회원이면 MEMBER_NOT_FOUND 예외가 발생한다") + void 존재하지_않는_회원이면_예외가_발생한다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExercises(999L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MEMBER_NOT_FOUND)); + } + + @Test + @DisplayName("대표 주소가 없으면 MAIN_ADDRESS_NULL 예외가 발생한다") + void 대표_주소가_없으면_예외가_발생한다() { + // given - addresses 비어 있는 member + Member memberWithoutAddr = MemberFixture.createMember("주소없는회원", Gender.MALE, Level.A, 2001L, LocalDate.of(1995, 1, 1)); + ReflectionTestUtils.setField(memberWithoutAddr, "id", 2L); + ReflectionTestUtils.setField(memberWithoutAddr, "addresses", new ArrayList<>()); + + given(memberRepository.findMemberWithAddresses(2L)) + .willReturn(Optional.of(memberWithoutAddr)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExercises(2L)) + .isInstanceOf(ExerciseException.class) + .satisfies(e -> assertThat(((ExerciseException) e).getCode()) + .isEqualTo(ExerciseErrorCode.MAIN_ADDRESS_NULL)); + } + } + } +} diff --git a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java index 7e761d036..a0be9546a 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java @@ -14,13 +14,18 @@ public static ExerciseAddr createExerciseAddr() { } public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr) { + return createExerciseAddr(buildingName, streetAddr, 37.5, 127.0); + } + + public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr, + double latitude, double longitude) { return ExerciseAddr.builder() .addr1("서울특별시") .addr2("강남구") .streetAddr(streetAddr) .buildingName(buildingName) - .latitude(37.5) - .longitude(127.0) + .latitude(latitude) + .longitude(longitude) .build(); } @@ -64,6 +69,21 @@ public static Exercise createExerciseWithAddr(Party party, LocalDate date, int m .build(); } + public static Exercise createRecommendableExercise(Party party, LocalDate date, + double latitude, double longitude, + String buildingName) { + return Exercise.builder() + .party(party) + .date(date) + .startTime(LocalTime.of(10, 0)) + .endTime(LocalTime.of(12, 0)) + .maxCapacity(10) + .partyGuestAccept(true) + .outsideGuestAccept(true) + .exerciseAddr(createExerciseAddr(buildingName, "테헤란로 1", latitude, longitude)) + .build(); + } + public static Exercise createExerciseForEdit(Party party, LocalDate date) { return Exercise.builder() .party(party) From 80f3cd74f2293e2a0ec0ef481caa38593c8eda30 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 28 Mar 2026 01:24:22 +0900 Subject: [PATCH 13/18] =?UTF-8?q?test:=20ExerciseFixture=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=98=A4=EB=B2=84=EB=A1=9C=EB=94=A9=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/support/fixture/ExerciseFixture.java | 79 +++++++------------ 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java index a0be9546a..1780fcc17 100644 --- a/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java +++ b/src/test/java/umc/cockple/demo/support/fixture/ExerciseFixture.java @@ -9,14 +9,6 @@ public class ExerciseFixture { - public static ExerciseAddr createExerciseAddr() { - return createExerciseAddr("테스트 체육관", "서울특별시 강남구 테헤란로 1"); - } - - public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr) { - return createExerciseAddr(buildingName, streetAddr, 37.5, 127.0); - } - public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr, double latitude, double longitude) { return ExerciseAddr.builder() @@ -29,72 +21,59 @@ public static ExerciseAddr createExerciseAddr(String buildingName, String street .build(); } - public static Exercise createExercise(Party party, LocalDate date) { - return Exercise.builder() - .party(party) - .date(date) - .startTime(LocalTime.of(10, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .build(); + public static ExerciseAddr createExerciseAddr() { + return createExerciseAddr("테스트 체육관", "서울특별시 강남구 테헤란로 1"); } - public static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, - boolean partyGuestAccept, boolean outsideGuestAccept) { + public static ExerciseAddr createExerciseAddr(String buildingName, String streetAddr) { + return createExerciseAddr(buildingName, streetAddr, 37.5, 127.0); + } + + private static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, + int maxCapacity, boolean partyGuestAccept, + boolean outsideGuestAccept, ExerciseAddr exerciseAddr, + String notice) { return Exercise.builder() .party(party) .date(date) .startTime(LocalTime.of(10, 0)) .endTime(endTime) - .maxCapacity(10) + .maxCapacity(maxCapacity) .partyGuestAccept(partyGuestAccept) .outsideGuestAccept(outsideGuestAccept) + .exerciseAddr(exerciseAddr) + .notice(notice) .build(); } + public static Exercise createExercise(Party party, LocalDate date) { + return createExercise(party, date, null, true, false); + } + + public static Exercise createExercise(Party party, LocalDate date, LocalTime endTime, + boolean partyGuestAccept, boolean outsideGuestAccept) { + return createExercise(party, date, endTime, 10, partyGuestAccept, outsideGuestAccept, + null, null); + } + public static Exercise createExerciseWithAddr(Party party, LocalDate date) { return createExerciseWithAddr(party, date, 10); } public static Exercise createExerciseWithAddr(Party party, LocalDate date, int maxCapacity) { - return Exercise.builder() - .party(party) - .date(date) - .startTime(LocalTime.of(10, 0)) - .maxCapacity(maxCapacity) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .exerciseAddr(createExerciseAddr()) - .build(); + return createExercise(party, date, null, maxCapacity, true, false, + createExerciseAddr(), null); } public static Exercise createRecommendableExercise(Party party, LocalDate date, double latitude, double longitude, String buildingName) { - return Exercise.builder() - .party(party) - .date(date) - .startTime(LocalTime.of(10, 0)) - .endTime(LocalTime.of(12, 0)) - .maxCapacity(10) - .partyGuestAccept(true) - .outsideGuestAccept(true) - .exerciseAddr(createExerciseAddr(buildingName, "테헤란로 1", latitude, longitude)) - .build(); + return createExercise(party, date, LocalTime.of(12, 0), 10, true, true, + createExerciseAddr(buildingName, "테헤란로 1", latitude, longitude), null); } public static Exercise createExerciseForEdit(Party party, LocalDate date) { - return Exercise.builder() - .party(party) - .date(date) - .startTime(LocalTime.of(10, 0)) - .endTime(LocalTime.of(12, 30)) - .maxCapacity(18) - .partyGuestAccept(true) - .outsideGuestAccept(false) - .notice("수정 공지사항") - .exerciseAddr(createExerciseAddr()) - .build(); + return createExercise(party, date, LocalTime.of(12, 30), 18, true, false, + createExerciseAddr(), "수정 공지사항"); } } From 78ebb30b6b981fefa049738d74751dfb218a9e55 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 28 Mar 2026 02:36:00 +0900 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20=EB=AA=A8?= =?UTF-8?q?=EC=9E=84=20=EC=9A=B4=EB=8F=99=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20=EB=82=A0=EC=A7=9C=EA=B0=80=20?= =?UTF-8?q?=EA=B0=99=EC=95=84=20=EA=B9=A8=EC=A7=80=EB=8D=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/ExerciseQueryIntegrationTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index c4d03f11c..cb39563a7 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -5,6 +5,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; +import umc.cockple.demo.domain.bookmark.domain.ExerciseBookmark; +import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; @@ -30,6 +32,8 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import javax.sql.DataSource; @@ -49,6 +53,7 @@ class ExerciseQueryIntegrationTest extends IntegrationTestBase { @Autowired ExerciseRepository exerciseRepository; @Autowired MemberExerciseRepository memberExerciseRepository; @Autowired GuestRepository guestRepository; + @Autowired ExerciseBookmarkRepository exerciseBookmarkRepository; @Autowired DataSource dataSource; private Member manager; @@ -75,6 +80,7 @@ void setUp() { @AfterEach void tearDown() { guestRepository.deleteAll(); + exerciseBookmarkRepository.deleteAll(); memberExerciseRepository.deleteAll(); exerciseRepository.deleteAll(); memberPartyRepository.deleteAll(); @@ -551,7 +557,7 @@ class Success { @DisplayName("시작일과_종료일이_없으면_기본_기간이_적용된다") void 시작일과_종료일이_없으면_기본_기간이_적용된다() throws Exception { LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); - LocalDate defaultExerciseDate = expectedStart.plusDays(8); + LocalDate defaultExerciseDate = expectedStart.plusDays(9); int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); From 1657d738fcb0eba212bbae9b4fa1970861913408 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 28 Mar 2026 02:37:29 +0900 Subject: [PATCH 15/18] =?UTF-8?q?test:=20=EB=82=B4=20=EC=B0=B8=EC=97=AC=20?= =?UTF-8?q?=EC=9A=B4=EB=8F=99=20=EC=A1=B0=ED=9A=8C=20API=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 --- .../ExerciseQueryIntegrationTest.java | 222 +++++++++++++ .../service/ExerciseQueryServiceTest.java | 311 ++++++++++++++++++ 2 files changed, 533 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index cb39563a7..217597c80 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -907,4 +907,226 @@ class Failure { } } + @Nested + @DisplayName("GET /api/exercises/my - 내 참여 운동 조회") + class GetMyExercises { + + private final List upcomingExercises = new ArrayList<>(); + private final List completedExercises = new ArrayList<>(); + + @BeforeEach + void setUp() { + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + partyRepository.save(party); + + for (int day = 1; day <= 10; day++) { + Exercise exercise = saveParticipatedExercise(LocalDate.of(2099, 1, day), LocalTime.of(10, 0), 10, true); + upcomingExercises.add(exercise); + } + + Exercise featuredUpcomingExercise = upcomingExercises.get(9); + ReflectionTestUtils.setField(featuredUpcomingExercise, "startTime", LocalTime.of(7, 30)); + ReflectionTestUtils.setField(featuredUpcomingExercise, "endTime", LocalTime.of(9, 0)); + ReflectionTestUtils.setField(featuredUpcomingExercise, "maxCapacity", 20); + ReflectionTestUtils.setField(featuredUpcomingExercise, "partyGuestAccept", false); + featuredUpcomingExercise = exerciseRepository.save(featuredUpcomingExercise); + upcomingExercises.set(9, featuredUpcomingExercise); + + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, featuredUpcomingExercise)); + guestRepository.save(GuestFixture.createGuest(featuredUpcomingExercise, manager.getId())); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(normalMember) + .exercise(featuredUpcomingExercise) + .build()); + + for (int day = 1; day <= 8; day++) { + Exercise exercise = saveParticipatedExercise(LocalDate.of(2024, 1, day), LocalTime.of(10, 0), 10, true); + completedExercises.add(exercise); + } + + exerciseRepository.save(ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 2, 1))); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("파라미터 없이 호출하면 ALL 최신순 기본값과 15개 페이징이 적용된다") + void 파라미터_없이_호출하면_ALL_최신순_기본값과_15개_페이징이_적용된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.exercises.length()").value(15)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(9).getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(true)) + .andExpect(jsonPath("$.data.exercises[0].date").value("2099-01-10")) + .andExpect(jsonPath("$.data.exercises[0].dayOfWeek").value("SATURDAY")) + .andExpect(jsonPath("$.data.exercises[0].buildingName").value("테스트 체육관")) + .andExpect(jsonPath("$.data.exercises[0].startTime").value("07:30:00")) + .andExpect(jsonPath("$.data.exercises[0].endTime").value("09:00:00")) + .andExpect(jsonPath("$.data.exercises[0].femaleLevel[0]").value("B조")) + .andExpect(jsonPath("$.data.exercises[0].maleLevel[0]").value("A조")) + .andExpect(jsonPath("$.data.exercises[0].currentParticipants").value(3)) + .andExpect(jsonPath("$.data.exercises[0].maxCapacity").value(20)) + .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(false)) + .andExpect(jsonPath("$.data.exercises[0].partyGuestInviteAccept").value(false)) + .andExpect(jsonPath("$.data.exercises[14].exerciseId").value(completedExercises.get(3).getId())); + } + + @Test + @DisplayName("두 번째 페이지를 조회하면 남은 3개 운동과 hasNext false를 반환한다") + void 두_번째_페이지를_조회하면_남은_3개_운동과_hasNext_false를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("page", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises.length()").value(3)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(2).getId())) + .andExpect(jsonPath("$.data.exercises[2].exerciseId").value(completedExercises.get(0).getId())); + } + + @Test + @DisplayName("UPCOMING 필터는 예정 운동만 최신순 기본정렬로 반환한다") + void UPCOMING_필터는_예정_운동만_최신순_기본정렬로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "UPCOMING")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(10)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(0).getId())) + .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(false)) + .andExpect(jsonPath("$.data.exercises[9].exerciseId").value(upcomingExercises.get(9).getId())) + .andExpect(jsonPath("$.data.exercises[9].date").value("2099-01-10")); + } + + @Test + @DisplayName("UPCOMING 필터에 OLDEST 정렬을 주면 반대 순서로 반환한다") + void UPCOMING_필터에_OLDEST_정렬을_주면_반대_순서로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "UPCOMING") + .param("orderType", "OLDEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(10)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(upcomingExercises.get(9).getId())) + .andExpect(jsonPath("$.data.exercises[9].exerciseId").value(upcomingExercises.get(0).getId())); + } + + @Test + @DisplayName("COMPLETED 필터는 완료 운동만 최신순 기본정렬로 반환한다") + void COMPLETED_필터는_완료_운동만_최신순_기본정렬로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "COMPLETED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(8)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(7).getId())) + .andExpect(jsonPath("$.data.exercises[0].isCompleted").value(true)) + .andExpect(jsonPath("$.data.exercises[7].exerciseId").value(completedExercises.get(0).getId())); + } + + @Test + @DisplayName("COMPLETED 필터에 OLDEST 정렬을 주면 반대 순서로 반환한다") + void COMPLETED_필터에_OLDEST_정렬을_주면_반대_순서로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "COMPLETED") + .param("orderType", "OLDEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(8)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(0).getId())) + .andExpect(jsonPath("$.data.exercises[7].exerciseId").value(completedExercises.get(7).getId())); + } + + @Test + @DisplayName("ALL 필터에 OLDEST 정렬을 주면 가장 오래된 완료 운동부터 반환한다") + void ALL_필터에_OLDEST_정렬을_주면_가장_오래된_완료_운동부터_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("orderType", "OLDEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(completedExercises.get(0).getId())) + .andExpect(jsonPath("$.data.exercises[14].exerciseId").value(upcomingExercises.get(6).getId())); + } + + @Test + @DisplayName("참여한 운동이 없으면 빈 응답을 반환한다") + void 참여한_운동이_없으면_빈_응답을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(outsider.getId(), outsider.getNickname()); + + mockMvc.perform(get("/api/exercises/my")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/my")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("잘못된 필터 타입이면 400을 반환한다") + void 잘못된_필터_타입이면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("filterType", "INVALID")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("잘못된 정렬 타입이면 400을 반환한다") + void 잘못된_정렬_타입이면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/exercises/my") + .param("orderType", "INVALID")) + .andExpect(status().isBadRequest()); + } + } + + private Exercise saveParticipatedExercise(LocalDate date, LocalTime startTime, + int maxCapacity, boolean partyGuestAccept) { + Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, date, maxCapacity); + ReflectionTestUtils.setField(exercise, "startTime", startTime); + ReflectionTestUtils.setField(exercise, "partyGuestAccept", partyGuestAccept); + + Exercise savedExercise = exerciseRepository.save(exercise); + memberExerciseRepository.save(MemberFixture.createMemberExercise(normalMember, savedExercise)); + return savedExercise; + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index d2e63962b..141790621 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -8,6 +8,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository; import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; @@ -17,9 +22,12 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.dto.MyPartyExerciseDTO; import umc.cockple.demo.domain.exercise.dto.PartyExerciseCalendarDTO; +import umc.cockple.demo.domain.exercise.enums.MyExerciseFilterType; +import umc.cockple.demo.domain.exercise.enums.MyExerciseOrderType; import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; @@ -1403,4 +1411,307 @@ class Failure { } } } + + @Nested + @DisplayName("getMyExercises") + class GetMyExercises { + + private Member myExerciseMember; + private Exercise completedExercise; + private Exercise upcomingExercise; + private Exercise futureLatestExercise; + private Pageable firstPage; + + @BeforeEach + void setUp() { + myExerciseMember = MemberFixture.createMember("내참여운동멤버", Gender.MALE, Level.B, 7001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(myExerciseMember, "id", 7L); + + party.addLevel(Gender.FEMALE, Level.B); + party.addLevel(Gender.MALE, Level.A); + + completedExercise = createMyExercise(701L, LocalDate.of(2024, 1, 5), + LocalTime.of(9, 0), LocalTime.of(11, 0), 18, false); + upcomingExercise = createMyExercise(702L, LocalDate.of(2099, 1, 3), + LocalTime.of(18, 0), null, 12, true); + futureLatestExercise = createMyExercise(703L, LocalDate.of(2099, 1, 10), + LocalTime.of(7, 30), LocalTime.of(9, 0), 20, true); + firstPage = PageRequest.of(0, 2); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("ALL 최신순은 전체 운동 리포지토리를 날짜 내림차순으로 호출한다") + void ALL_최신순은_전체_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); + verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); + } + + @Test + @DisplayName("UPCOMING 최신순은 예정 운동 리포지토리를 날짜 오름차순으로 호출한다") + void UPCOMING_최신순은_예정_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.LATEST, firstPage); + + // then + verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); + verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + verify(exerciseRepository, never()).findMyCompletedExercisesWithPaging(any(), any()); + } + + @Test + @DisplayName("COMPLETED 최신순은 완료 운동 리포지토리를 날짜 내림차순으로 호출한다") + void COMPLETED_최신순은_완료_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.LATEST, firstPage); + + // then + verify(exerciseRepository, never()).findMyExercisesWithPaging(any(), any()); + verify(exerciseRepository, never()).findMyUpcomingExercisesWithPaging(any(), any()); + verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("ALL 오래된순은 전체 운동 리포지토리를 날짜 오름차순으로 호출한다") + void ALL_오래된순은_전체_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("UPCOMING 오래된순은 예정 운동 리포지토리를 날짜 내림차순으로 호출한다") + void UPCOMING_오래된순은_예정_운동_리포지토리를_날짜_내림차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.DESC, Sort.Direction.DESC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.UPCOMING, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyUpcomingExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("COMPLETED 오래된순은 완료 운동 리포지토리를 날짜 오름차순으로 호출한다") + void COMPLETED_오래된순은_완료_운동_리포지토리를_날짜_오름차순으로_호출한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), argThat( + pageable -> matchesSort(pageable, Sort.Direction.ASC, Sort.Direction.ASC)))) + .willReturn(emptySlice(firstPage)); + + // when + exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.COMPLETED, MyExerciseOrderType.OLDEST, firstPage); + + // then + verify(exerciseRepository).findMyCompletedExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class)); + } + + @Test + @DisplayName("조회된 운동이 없으면 빈 응답을 반환한다") + void 조회된_운동이_없으면_빈_응답을_반환한다() { + // given + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(emptySlice(firstPage)); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isZero(); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository, never()).findExerciseParticipantCountsByExerciseIds(any()); + verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); + } + + @Test + @DisplayName("조회 결과를 DTO 필드와 hasNext true로 매핑한다") + void 조회_결과를_DTO_필드와_hasNext_true로_매핑한다() { + // given + Slice exerciseSlice = sliceOf(List.of(futureLatestExercise, completedExercise), true, firstPage); + + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(exerciseSlice); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(futureLatestExercise.getId(), completedExercise.getId()))) + .willReturn(List.of( + new Object[]{futureLatestExercise.getId(), 3}, + new Object[]{completedExercise.getId(), 1} + )); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + myExerciseMember.getId(), List.of(futureLatestExercise.getId(), completedExercise.getId()))) + .willReturn(List.of(futureLatestExercise.getId())); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.exercises()) + .extracting( + MyExerciseListDTO.ExerciseItem::exerciseId, + MyExerciseListDTO.ExerciseItem::partyId, + MyExerciseListDTO.ExerciseItem::partyName, + MyExerciseListDTO.ExerciseItem::isBookmarked, + MyExerciseListDTO.ExerciseItem::date, + MyExerciseListDTO.ExerciseItem::dayOfWeek, + MyExerciseListDTO.ExerciseItem::buildingName, + MyExerciseListDTO.ExerciseItem::startTime, + MyExerciseListDTO.ExerciseItem::endTime, + MyExerciseListDTO.ExerciseItem::currentParticipants, + MyExerciseListDTO.ExerciseItem::maxCapacity, + MyExerciseListDTO.ExerciseItem::isCompleted, + MyExerciseListDTO.ExerciseItem::partyGuestInviteAccept + ) + .containsExactly( + tuple(703L, 10L, "테스트 모임", true, + LocalDate.of(2099, 1, 10), "SATURDAY", "테스트 체육관", + LocalTime.of(7, 30), LocalTime.of(9, 0), 3, 20, false, true), + tuple(701L, 10L, "테스트 모임", false, + LocalDate.of(2024, 1, 5), "FRIDAY", "테스트 체육관", + LocalTime.of(9, 0), LocalTime.of(11, 0), 1, 18, true, false) + ); + } + + @Test + @DisplayName("조회 결과를 hasNext false로 매핑한다") + void 조회_결과를_hasNext_false로_매핑한다() { + // given + Pageable secondPage = PageRequest.of(1, 1); + Slice exerciseSlice = sliceOf(List.of(upcomingExercise), false, secondPage); + + given(memberRepository.findById(myExerciseMember.getId())) + .willReturn(Optional.of(myExerciseMember)); + given(exerciseRepository.findMyExercisesWithPaging(eq(myExerciseMember.getId()), any(Pageable.class))) + .willReturn(exerciseSlice); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds(List.of(upcomingExercise.getId()))) + .willReturn(List.of()); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + myExerciseMember.getId(), List.of(upcomingExercise.getId()))) + .willReturn(List.of()); + + // when + MyExerciseListDTO.Response response = exerciseQueryService.getMyExercises( + myExerciseMember.getId(), MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, secondPage); + + // then + assertThat(response.totalCount()).isEqualTo(1); + assertThat(response.hasNext()).isFalse(); + assertThat(response.exercises().get(0).exerciseId()).isEqualTo(upcomingExercise.getId()); + assertThat(response.exercises().get(0).isCompleted()).isFalse(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getMyExercises( + 999L, MyExerciseFilterType.ALL, MyExerciseOrderType.LATEST, firstPage)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + + private Exercise createMyExercise(long id, LocalDate date, LocalTime startTime, + LocalTime endTime, int maxCapacity, boolean partyGuestAccept) { + Exercise createdExercise = ExerciseFixture.createExerciseWithAddr(party, date, maxCapacity); + ReflectionTestUtils.setField(createdExercise, "id", id); + ReflectionTestUtils.setField(createdExercise, "startTime", startTime); + ReflectionTestUtils.setField(createdExercise, "endTime", endTime); + ReflectionTestUtils.setField(createdExercise, "partyGuestAccept", partyGuestAccept); + return createdExercise; + } + + private Slice emptySlice(Pageable pageable) { + return new SliceImpl<>(List.of(), pageable, false); + } + + private Slice sliceOf(List exercises, boolean hasNext, Pageable pageable) { + return new SliceImpl<>(exercises, pageable, hasNext); + } + + private boolean matchesSort(Pageable pageable, Sort.Direction dateDirection, Sort.Direction timeDirection) { + if (pageable.getPageNumber() != firstPage.getPageNumber() || pageable.getPageSize() != firstPage.getPageSize()) { + return false; + } + + List orders = pageable.getSort().stream().toList(); + return orders.size() == 2 + && orders.get(0).getProperty().equals("date") + && orders.get(0).getDirection() == dateDirection + && orders.get(1).getProperty().equals("startTime") + && orders.get(1).getDirection() == timeDirection; + } + } } From eda73cefce6c68f044e84fbc7efda58939458a8d Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 28 Mar 2026 03:17:46 +0900 Subject: [PATCH 16/18] =?UTF-8?q?test:=20=EA=B1=B4=EB=AC=BC=20=EC=9A=B4?= =?UTF-8?q?=EB=8F=99=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=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 --- .../ExerciseQueryIntegrationTest.java | 134 ++++++++++++++++++ .../service/ExerciseQueryServiceTest.java | 120 ++++++++++++++++ 2 files changed, 254 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index 217597c80..abd83f7ad 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -1129,4 +1129,138 @@ private Exercise saveParticipatedExercise(LocalDate date, LocalTime startTime, } } + @Nested + @DisplayName("GET /api/buildings/exercises/{date} - 건물 운동 상세 조회") + class GetBuildingExerciseDetails { + + private final LocalDate targetDate = LocalDate.of(2026, 5, 10); + private final String targetBuildingName = "콕플 타워"; + private final String targetStreetAddr = "서울특별시 강남구 테헤란로 10"; + private Exercise morningExercise; + private Exercise eveningExercise; + + @BeforeEach + void setUp() { + eveningExercise = saveBuildingExercise(targetBuildingName, targetStreetAddr, + targetDate, LocalTime.of(19, 0), LocalTime.of(21, 0)); + morningExercise = saveBuildingExercise(targetBuildingName, targetStreetAddr, + targetDate, LocalTime.of(9, 0), LocalTime.of(11, 0)); + + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(normalMember) + .exercise(eveningExercise) + .build()); + + saveBuildingExercise("다른 건물", targetStreetAddr, + targetDate, LocalTime.of(13, 0), LocalTime.of(15, 0)); + saveBuildingExercise(targetBuildingName, "서울특별시 강남구 테헤란로 99", + targetDate, LocalTime.of(16, 0), LocalTime.of(18, 0)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("같은 건물과 주소의 운동만 시작시간 오름차순으로 반환한다") + void 같은_건물과_주소의_운동만_시작시간_오름차순으로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", targetBuildingName) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("2026-05-10")) + .andExpect(jsonPath("$.data.dayOfWeek").value("SUNDAY")) + .andExpect(jsonPath("$.data.buildingName").value(targetBuildingName)) + .andExpect(jsonPath("$.data.exercises.length()").value(2)) + .andExpect(jsonPath("$.data.exercises[0].exerciseId").value(morningExercise.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyId").value(party.getId())) + .andExpect(jsonPath("$.data.exercises[0].partyName").value("테스트 모임")) + .andExpect(jsonPath("$.data.exercises[0].profileImageUrl").isEmpty()) + .andExpect(jsonPath("$.data.exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.exercises[0].startTime").value("09:00:00")) + .andExpect(jsonPath("$.data.exercises[0].endTime").value("11:00:00")) + .andExpect(jsonPath("$.data.exercises[1].exerciseId").value(eveningExercise.getId())) + .andExpect(jsonPath("$.data.exercises[1].isBookmarked").value(true)) + .andExpect(jsonPath("$.data.exercises[1].startTime").value("19:00:00")) + .andExpect(jsonPath("$.data.exercises[1].endTime").value("21:00:00")); + } + + @Test + @DisplayName("해당 건물 운동이 없으면 메타데이터가 포함된 빈 응답을 반환한다") + void 해당_건물_운동이_없으면_메타데이터가_포함된_빈_응답을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", "없는 건물") + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("2026-05-10")) + .andExpect(jsonPath("$.data.dayOfWeek").value("SUNDAY")) + .andExpect(jsonPath("$.data.buildingName").value("없는 건물")) + .andExpect(jsonPath("$.data.exercises").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", targetBuildingName) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("buildingName이 없으면 400을 반환한다") + void buildingName이_없으면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("streetAddr이 없으면 400을 반환한다") + void streetAddr이_없으면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", targetDate) + .param("buildingName", targetBuildingName)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 400을 반환한다") + void 날짜_형식이_잘못되면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/exercises/{date}", "invalid-date") + .param("buildingName", targetBuildingName) + .param("streetAddr", targetStreetAddr)) + .andExpect(status().isBadRequest()); + } + } + + private Exercise saveBuildingExercise(String buildingName, String streetAddr, + LocalDate date, LocalTime startTime, LocalTime endTime) { + Exercise buildingExercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); + ReflectionTestUtils.setField(buildingExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr)); + ReflectionTestUtils.setField(buildingExercise, "startTime", startTime); + ReflectionTestUtils.setField(buildingExercise, "endTime", endTime); + return exerciseRepository.save(buildingExercise); + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 141790621..626f75e50 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -18,6 +18,7 @@ import umc.cockple.demo.domain.exercise.converter.ExerciseConverter; import umc.cockple.demo.domain.exercise.domain.Exercise; import umc.cockple.demo.domain.exercise.domain.Guest; +import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; @@ -1714,4 +1715,123 @@ private boolean matchesSort(Pageable pageable, Sort.Direction dateDirection, Sor && orders.get(1).getDirection() == timeDirection; } } + + @Nested + @DisplayName("getBuildingExerciseDetails") + class GetBuildingExerciseDetails { + + private Member buildingMember; + private LocalDate targetDate; + private String buildingName; + private String streetAddr; + + @BeforeEach + void setUp() { + buildingMember = MemberFixture.createMember("건물상세멤버", Gender.FEMALE, Level.B, 8001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(buildingMember, "id", 8L); + + targetDate = LocalDate.of(2026, 5, 10); + buildingName = "콕플 타워"; + streetAddr = "서울특별시 강남구 테헤란로 10"; + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("해당 건물 운동이 없으면 메타데이터가 포함된 빈 응답을 반환한다") + void 해당_건물_운동이_없으면_메타데이터가_포함된_빈_응답을_반환한다() { + // given + given(memberRepository.findById(buildingMember.getId())) + .willReturn(Optional.of(buildingMember)); + given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) + .willReturn(List.of()); + + // when + ExerciseBuildingDetailDTO.Response response = exerciseQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, buildingMember.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); + assertThat(response.buildingName()).isEqualTo(buildingName); + assertThat(response.exercises()).isEmpty(); + verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); + verify(exerciseBookmarkRepository, never()).findAllExerciseIdsByMemberIdAndExerciseIds(any(), any()); + } + + @Test + @DisplayName("운동 목록을 순서와 북마크 상태를 유지해 DTO로 반환한다") + void 운동_목록을_순서와_북마크_상태를_유지해_DTO로_반환한다() { + // given + Exercise morningExercise = createBuildingExercise(801L, LocalTime.of(9, 0), LocalTime.of(11, 0)); + Exercise eveningExercise = createBuildingExercise(802L, LocalTime.of(19, 0), LocalTime.of(21, 0)); + + given(memberRepository.findById(buildingMember.getId())) + .willReturn(Optional.of(buildingMember)); + given(exerciseRepository.findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate)) + .willReturn(List.of(morningExercise, eveningExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId()))) + .willReturn(List.of(eveningExercise.getId())); + + // when + ExerciseBuildingDetailDTO.Response response = exerciseQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, buildingMember.getId()); + + // then + assertThat(response.date()).isEqualTo(targetDate); + assertThat(response.dayOfWeek()).isEqualTo("SUNDAY"); + assertThat(response.buildingName()).isEqualTo(buildingName); + assertThat(response.exercises()) + .extracting( + ExerciseBuildingDetailDTO.ExerciseItem::exerciseId, + ExerciseBuildingDetailDTO.ExerciseItem::partyId, + ExerciseBuildingDetailDTO.ExerciseItem::partyName, + ExerciseBuildingDetailDTO.ExerciseItem::profileImageUrl, + ExerciseBuildingDetailDTO.ExerciseItem::isBookmarked, + ExerciseBuildingDetailDTO.ExerciseItem::startTime, + ExerciseBuildingDetailDTO.ExerciseItem::endTime + ) + .containsExactly( + tuple(801L, 10L, "테스트 모임", null, false, LocalTime.of(9, 0), LocalTime.of(11, 0)), + tuple(802L, 10L, "테스트 모임", null, true, LocalTime.of(19, 0), LocalTime.of(21, 0)) + ); + verify(exerciseRepository).findExercisesByBuildingAndDate(buildingName, streetAddr, targetDate); + verify(exerciseBookmarkRepository).findAllExerciseIdsByMemberIdAndExerciseIds( + buildingMember.getId(), List.of(morningExercise.getId(), eveningExercise.getId())); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getBuildingExerciseDetails( + buildingName, streetAddr, targetDate, 999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + } + + private Exercise createBuildingExercise(long id, LocalTime startTime, LocalTime endTime) { + Exercise buildingExercise = ExerciseFixture.createExerciseWithAddr(party, targetDate, 12); + ReflectionTestUtils.setField(buildingExercise, "id", id); + ReflectionTestUtils.setField(buildingExercise, "startTime", startTime); + ReflectionTestUtils.setField(buildingExercise, "endTime", endTime); + ReflectionTestUtils.setField(buildingExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr)); + return buildingExercise; + } + } } From 080385ef0f1df72ef48ce70f3ba5a6f141451cd1 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 28 Mar 2026 03:54:49 +0900 Subject: [PATCH 17/18] =?UTF-8?q?test:=20=EC=9B=94=EA=B0=84=20=EC=9A=B4?= =?UTF-8?q?=EB=8F=99=20=EA=B1=B4=EB=AC=BC=20=EC=A7=80=EB=8F=84=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20API=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 --- .../ExerciseQueryIntegrationTest.java | 204 +++++++++++++++ .../service/ExerciseQueryServiceTest.java | 232 ++++++++++++++++++ 2 files changed, 436 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index abd83f7ad..cf0ba1305 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -12,6 +12,8 @@ import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.domain.MemberAddr; +import umc.cockple.demo.domain.member.repository.MemberAddrRepository; import umc.cockple.demo.domain.member.repository.MemberExerciseRepository; import umc.cockple.demo.domain.member.repository.MemberPartyRepository; import umc.cockple.demo.domain.member.repository.MemberRepository; @@ -38,6 +40,7 @@ import javax.sql.DataSource; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -47,6 +50,7 @@ class ExerciseQueryIntegrationTest extends IntegrationTestBase { @Autowired MockMvc mockMvc; @Autowired MemberRepository memberRepository; + @Autowired MemberAddrRepository memberAddrRepository; @Autowired PartyRepository partyRepository; @Autowired PartyAddrRepository partyAddrRepository; @Autowired MemberPartyRepository memberPartyRepository; @@ -86,6 +90,7 @@ void tearDown() { memberPartyRepository.deleteAll(); partyRepository.deleteAll(); partyAddrRepository.deleteAll(); + memberAddrRepository.deleteAll(); memberRepository.deleteAll(); SecurityContextHelper.clearAuthentication(); } @@ -1263,4 +1268,203 @@ private Exercise saveBuildingExercise(String buildingName, String streetAddr, } } + @Nested + @DisplayName("GET /api/buildings/map/monthly - 월간 운동 건물 지도 데이터 조회") + class GetMonthlyExerciseBuildings { + + private final LocalDate targetDate = LocalDate.of(2026, 4, 15); + private Member memberWithoutMainAddr; + + @BeforeEach + void setUp() { + saveMemberAddr(normalMember, "서울특별시", "강남구", "역삼동", + "서울특별시 강남구 테헤란로 1", "대표주소", 37.5, 127.0, true); + + memberWithoutMainAddr = memberRepository.save( + MemberFixture.createMember("대표주소없음", Gender.FEMALE, Level.B, 1010L, LocalDate.of(2000, 1, 1))); + saveMemberAddr(memberWithoutMainAddr, "서울특별시", "송파구", "잠실동", + "서울특별시 송파구 올림픽로 1", "보조주소", 37.514, 127.102, false); + + saveMapExercise(LocalDate.of(2026, 4, 3), "A빌딩", "서울특별시 강남구 테헤란로 10", + 37.5005, 127.0005, LocalTime.of(9, 0)); + saveMapExercise(LocalDate.of(2026, 4, 3), "A빌딩", "서울특별시 강남구 테헤란로 10", + 37.5005, 127.0005, LocalTime.of(19, 0)); + saveMapExercise(LocalDate.of(2026, 4, 3), "B빌딩", "서울특별시 강남구 테헤란로 20", + 37.501, 127.001, LocalTime.of(13, 0)); + saveMapExercise(LocalDate.of(2026, 4, 4), "A빌딩", "서울특별시 강남구 테헤란로 10", + 37.5005, 127.0005, LocalTime.of(10, 0)); + saveMapExercise(LocalDate.of(2026, 4, 5), "반경밖빌딩", "부산광역시 해운대구 센텀로 1", + 35.17, 129.13, LocalTime.of(12, 0)); + saveMapExercise(LocalDate.of(2026, 4, 6), "부산빌딩", "부산광역시 해운대구 센텀로 2", + 35.1705, 129.1305, LocalTime.of(14, 0)); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("기본 요청은 현재 월과 대표주소 중심 좌표를 사용한다") + void 기본_요청은_현재_월과_대표주소_중심_좌표를_사용한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.year").value(LocalDate.now().getYear())) + .andExpect(jsonPath("$.data.month").value(LocalDate.now().getMonthValue())) + .andExpect(jsonPath("$.data.centerLatitude").value(37.5)) + .andExpect(jsonPath("$.data.centerLongitude").value(127.0)) + .andExpect(jsonPath("$.data.radiusKm").value(3.0)) + .andExpect(jsonPath("$.data.buildings").isMap()); + } + + @Test + @DisplayName("명시 날짜와 좌표로 조회하면 날짜별 건물 지도를 dedupe하여 반환한다") + void 명시_날짜와_좌표로_조회하면_날짜별_건물_지도를_dedupe하여_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "37.5") + .param("longitude", "127.0") + .param("radiusKm", "3.9")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.year").value(2026)) + .andExpect(jsonPath("$.data.month").value(4)) + .andExpect(jsonPath("$.data.centerLatitude").value(37.5)) + .andExpect(jsonPath("$.data.centerLongitude").value(127.0)) + .andExpect(jsonPath("$.data.radiusKm").value(3.9)) + .andExpect(jsonPath("$.data.buildings['2026-04-03'].length()").value(2)) + .andExpect(jsonPath("$.data.buildings['2026-04-03'][*].buildingName", containsInAnyOrder("A빌딩", "B빌딩"))) + .andExpect(jsonPath("$.data.buildings['2026-04-04'].length()").value(1)) + .andExpect(jsonPath("$.data.buildings['2026-04-04'][0].buildingName").value("A빌딩")) + .andExpect(jsonPath("$.data.buildings['2026-04-05']").doesNotExist()); + } + + @Test + @DisplayName("명시 좌표는 대표주소 대신 응답 중심 좌표로 반영된다") + void 명시_좌표는_대표주소_대신_응답_중심_좌표로_반영된다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "35.17") + .param("longitude", "129.13") + .param("radiusKm", "5.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.centerLatitude").value(35.17)) + .andExpect(jsonPath("$.data.centerLongitude").value(129.13)) + .andExpect(jsonPath("$.data.radiusKm").value(5.0)) + .andExpect(jsonPath("$.data.buildings['2026-04-06'][0].buildingName").value("부산빌딩")); + } + + @Test + @DisplayName("반경 내 운동이 없으면 빈 buildings를 반환한다") + void 반경_내_운동이_없으면_빈_buildings를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "36.0") + .param("longitude", "128.0") + .param("radiusKm", "1.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.year").value(2026)) + .andExpect(jsonPath("$.data.month").value(4)) + .andExpect(jsonPath("$.data.buildings").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("대표주소가 없으면 에러를 반환한다") + void 대표주소가_없으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + + @Test + @DisplayName("대표주소가 없으면 명시 좌표가 있어도 에러를 반환한다") + void 대표주소가_없으면_명시_좌표가_있어도_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "37.5") + .param("longitude", "127.0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + + @Test + @DisplayName("위도만 주면 에러를 반환한다") + void 위도만_주면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", targetDate.toString()) + .param("latitude", "37.5")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.INCOMPLETE_LOCATION_INFO.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.INCOMPLETE_LOCATION_INFO.getMessage())); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 400을 반환한다") + void 날짜_형식이_잘못되면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(normalMember.getId(), normalMember.getNickname()); + + mockMvc.perform(get("/api/buildings/map/monthly") + .param("date", "invalid-date")) + .andExpect(status().isBadRequest()); + } + } + + private MemberAddr saveMemberAddr(Member member, String addr1, String addr2, String addr3, + String streetAddr, String buildingName, + double latitude, double longitude, boolean isMain) { + return memberAddrRepository.save(MemberAddr.builder() + .member(member) + .addr1(addr1) + .addr2(addr2) + .addr3(addr3) + .streetAddr(streetAddr) + .buildingName(buildingName) + .latitude(latitude) + .longitude(longitude) + .isMain(isMain) + .build()); + } + + private Exercise saveMapExercise(LocalDate date, String buildingName, String streetAddr, + double latitude, double longitude, LocalTime startTime) { + Exercise exercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); + ReflectionTestUtils.setField(exercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr, latitude, longitude)); + ReflectionTestUtils.setField(exercise, "startTime", startTime); + return exerciseRepository.save(exercise); + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index 626f75e50..da2044244 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -21,6 +21,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseBuildingDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; @@ -32,6 +33,7 @@ import umc.cockple.demo.domain.exercise.enums.MyPartyExerciseOrderType; import umc.cockple.demo.domain.exercise.exception.ExerciseErrorCode; import umc.cockple.demo.domain.exercise.exception.ExerciseException; +import umc.cockple.demo.domain.member.domain.MemberAddr; import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; import umc.cockple.demo.domain.exercise.repository.GuestRepository; import umc.cockple.demo.domain.file.service.FileService; @@ -58,6 +60,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.YearMonth; import java.util.Collections; import java.util.List; import java.util.Map; @@ -1834,4 +1837,233 @@ private Exercise createBuildingExercise(long id, LocalTime startTime, LocalTime return buildingExercise; } } + + @Nested + @DisplayName("getExerciseMapCalendarSummary") + class GetExerciseMapCalendarSummary { + + private Member mapMember; + private Member memberWithoutMainAddr; + private MemberAddr mainAddr; + private Double radiusKm; + + @BeforeEach + void setUp() { + mapMember = MemberFixture.createMember("지도멤버", Gender.MALE, Level.B, 9001L, + LocalDate.of(2000, 1, 1)); + ReflectionTestUtils.setField(mapMember, "id", 9L); + + mainAddr = MemberAddr.builder() + .member(mapMember) + .addr1("서울특별시") + .addr2("강남구") + .addr3("역삼동") + .streetAddr("서울특별시 강남구 테헤란로 1") + .buildingName("대표주소") + .latitude(37.501) + .longitude(127.039) + .isMain(true) + .build(); + ReflectionTestUtils.setField(mapMember, "addresses", List.of(mainAddr)); + + memberWithoutMainAddr = MemberFixture.createMember("대표주소없음", Gender.FEMALE, Level.C, 9002L, + LocalDate.of(2001, 1, 1)); + ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 10L); + MemberAddr subAddr = MemberAddr.builder() + .member(memberWithoutMainAddr) + .addr1("서울특별시") + .addr2("송파구") + .addr3("잠실동") + .streetAddr("서울특별시 송파구 올림픽로 1") + .buildingName("서브주소") + .latitude(37.514) + .longitude(127.102) + .isMain(false) + .build(); + ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(subAddr)); + + radiusKm = 3.9; + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("date가 null이면 현재 월 범위와 대표주소 좌표로 조회한다") + void date가_null이면_현재_월_범위와_대표주소_좌표로_조회한다() { + // given + YearMonth currentMonth = YearMonth.now(); + LocalDate monthStart = currentMonth.atDay(1); + LocalDate monthEnd = currentMonth.atEndOfMonth(); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius( + eq(monthStart), eq(monthEnd), eq(mainAddr.getLatitude()), eq(mainAddr.getLongitude()), eq(3))) + .willReturn(List.of()); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( + null, null, null, radiusKm, mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(currentMonth.getYear()); + assertThat(response.month()).isEqualTo(currentMonth.getMonthValue()); + assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); + assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings()).isEmpty(); + } + + @Test + @DisplayName("명시 좌표가 있으면 대표주소 대신 해당 좌표와 절삭 반경으로 조회한다") + void 명시_좌표가_있으면_대표주소_대신_해당_좌표와_절삭_반경으로_조회한다() { + // given + LocalDate targetDate = LocalDate.of(2026, 4, 15); + LocalDate monthStart = LocalDate.of(2026, 4, 1); + LocalDate monthEnd = LocalDate.of(2026, 4, 30); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius( + eq(monthStart), eq(monthEnd), eq(37.55), eq(127.11), eq(3))) + .willReturn(List.of()); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( + targetDate, 37.55, 127.11, radiusKm, mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(4); + assertThat(response.centerLatitude()).isEqualTo(37.55); + assertThat(response.centerLongitude()).isEqualTo(127.11); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings()).isEmpty(); + } + + @Test + @DisplayName("운동을 날짜별과 건물별로 그룹화해 응답을 만든다") + void 운동을_날짜별과_건물별로_그룹화해_응답을_만든다() { + // given + LocalDate targetDate = LocalDate.of(2026, 4, 15); + Exercise dayOneMorning = createMapExercise(901L, LocalDate.of(2026, 4, 3), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(9, 0)); + Exercise dayOneEveningSameBuilding = createMapExercise(902L, LocalDate.of(2026, 4, 3), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(19, 0)); + Exercise dayOneOtherBuilding = createMapExercise(903L, LocalDate.of(2026, 4, 3), + "B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042, LocalTime.of(13, 0)); + Exercise dayTwoBuilding = createMapExercise(904L, LocalDate.of(2026, 4, 4), + "A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041, LocalTime.of(10, 0)); + + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + given(exerciseRepository.findExercisesByMonthAndRadius(any(), any(), any(), any(), any())) + .willReturn(List.of(dayOneMorning, dayOneEveningSameBuilding, dayOneOtherBuilding, dayTwoBuilding)); + + // when + ExerciseMapBuildingsDTO.Response response = exerciseQueryService.getExerciseMapCalendarSummary( + targetDate, null, null, radiusKm, mapMember.getId()); + + // then + assertThat(response.year()).isEqualTo(2026); + assertThat(response.month()).isEqualTo(4); + assertThat(response.centerLatitude()).isEqualTo(mainAddr.getLatitude()); + assertThat(response.centerLongitude()).isEqualTo(mainAddr.getLongitude()); + assertThat(response.radiusKm()).isEqualTo(radiusKm); + assertThat(response.buildings().keySet()) + .containsExactly(LocalDate.of(2026, 4, 3), LocalDate.of(2026, 4, 4)); + assertThat(response.buildings().get(LocalDate.of(2026, 4, 3))) + .extracting( + ExerciseMapBuildingsDTO.BuildingInfo::buildingName, + ExerciseMapBuildingsDTO.BuildingInfo::streetAddr, + ExerciseMapBuildingsDTO.BuildingInfo::latitude, + ExerciseMapBuildingsDTO.BuildingInfo::longitude + ) + .containsExactlyInAnyOrder( + tuple("A빌딩", "서울특별시 강남구 테헤란로 10", 37.501, 127.041), + tuple("B빌딩", "서울특별시 강남구 테헤란로 20", 37.502, 127.042) + ); + assertThat(response.buildings().get(LocalDate.of(2026, 4, 4))) + .extracting( + ExerciseMapBuildingsDTO.BuildingInfo::buildingName, + ExerciseMapBuildingsDTO.BuildingInfo::streetAddr + ) + .containsExactly(tuple("A빌딩", "서울특별시 강남구 테헤란로 10")); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), null, null, radiusKm, 999L)) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("대표주소가 없으면 예외를 던진다") + void 대표주소가_없으면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), null, null, radiusKm, memberWithoutMainAddr.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); + } + + @Test + @DisplayName("대표주소가 없으면 명시 좌표가 있어도 예외를 던진다") + void 대표주소가_없으면_명시_좌표가_있어도_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), 37.5, 127.0, radiusKm, memberWithoutMainAddr.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); + } + + @Test + @DisplayName("위도와 경도 중 하나만 주면 예외를 던진다") + void 위도와_경도_중_하나만_주면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(mapMember.getId())) + .willReturn(Optional.of(mapMember)); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getExerciseMapCalendarSummary( + LocalDate.of(2026, 4, 1), 37.5, null, radiusKm, mapMember.getId())) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.INCOMPLETE_LOCATION_INFO); + } + } + + private Exercise createMapExercise(long id, LocalDate date, String buildingName, + String streetAddr, double latitude, double longitude, + LocalTime startTime) { + Exercise mapExercise = ExerciseFixture.createExerciseWithAddr(party, date, 12); + ReflectionTestUtils.setField(mapExercise, "id", id); + ReflectionTestUtils.setField(mapExercise, "startTime", startTime); + ReflectionTestUtils.setField(mapExercise, "exerciseAddr", + ExerciseFixture.createExerciseAddr(buildingName, streetAddr, latitude, longitude)); + return mapExercise; + } + } } From 0ad152ac45100372e104211d9f59de9f59dc7772 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 28 Mar 2026 04:42:30 +0900 Subject: [PATCH 18/18] =?UTF-8?q?test:=20=EC=B6=94=EC=B2=9C=20=EC=9A=B4?= =?UTF-8?q?=EB=8F=99=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=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 --- .../ExerciseQueryIntegrationTest.java | 229 ++++++++++++++ .../service/ExerciseQueryServiceTest.java | 287 ++++++++++++++++++ 2 files changed, 516 insertions(+) diff --git a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java index cf0ba1305..6e8b1b8e7 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/integration/ExerciseQueryIntegrationTest.java @@ -19,6 +19,8 @@ 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.ActivityTime; +import umc.cockple.demo.domain.party.enums.ParticipationType; import umc.cockple.demo.domain.party.repository.PartyAddrRepository; import umc.cockple.demo.domain.party.repository.PartyRepository; import umc.cockple.demo.global.enums.Gender; @@ -29,6 +31,7 @@ import umc.cockple.demo.support.SecurityContextHelper; import umc.cockple.demo.support.fixture.ExerciseFixture; import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; @@ -1467,4 +1470,230 @@ private Exercise saveMapExercise(LocalDate date, String buildingName, String str } } + @Nested + @DisplayName("GET /api/exercises/recommendations/calendar - 사용자 추천 운동 캘린더 조회") + class GetRecommendedExerciseCalendar { + + private Member recommendationMember; + private Member memberWithoutMainAddr; + private Party filteredParty; + private LocalDate startDate; + private LocalDate endDate; + private Exercise filteredEarlyExercise; + private Exercise filteredPopularExercise; + + @BeforeEach + void setUp() { + recommendationMember = memberRepository.save( + MemberFixture.createMember("추천캘린더회원", Gender.MALE, Level.A, 1201L, LocalDate.of(1995, 6, 15))); + memberAddrRepository.save(MemberAddrFixture.createMainAddr(recommendationMember)); + + memberWithoutMainAddr = memberRepository.save( + MemberFixture.createMember("주소없는추천회원", Gender.MALE, Level.A, 1202L, LocalDate.of(1995, 6, 15))); + + party.addLevel(Gender.MALE, Level.A); + partyRepository.save(party); + + PartyAddr filteredAddr = partyAddrRepository.save(PartyFixture.createPartyAddr("서울특별시", "송파구")); + filteredParty = PartyFixture.createParty("필터 모임", manager.getId(), filteredAddr); + ReflectionTestUtils.setField(filteredParty, "partyType", ParticipationType.SINGLE); + ReflectionTestUtils.setField(filteredParty, "activityTime", ActivityTime.AFTERNOON); + filteredParty = partyRepository.save(filteredParty); + filteredParty.addLevel(Gender.MALE, Level.B); + filteredParty = partyRepository.save(filteredParty); + memberPartyRepository.save(MemberFixture.createMemberParty(filteredParty, manager, Role.party_MANAGER)); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 4, 5); + + filteredEarlyExercise = saveRecommendableExercise(filteredParty, LocalDate.of(2026, 3, 25), + 37.51, 127.01, "필터 이른 체육관", LocalTime.of(9, 0), LocalTime.of(11, 0)); + filteredPopularExercise = saveRecommendableExercise(filteredParty, LocalDate.of(2026, 3, 25), + 37.52, 127.02, "필터 인기 체육관", LocalTime.of(18, 0), LocalTime.of(20, 0)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(manager, filteredPopularExercise)); + memberExerciseRepository.save(MemberFixture.createMemberExercise(subManager, filteredPopularExercise)); + exerciseBookmarkRepository.save(ExerciseBookmark.builder() + .member(recommendationMember) + .exercise(filteredPopularExercise) + .build()); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("기본 요청은 기본 기간의 콕플 추천 캘린더를 거리순으로 반환한다") + void 기본_요청은_기본_기간의_콕플_추천_캘린더를_거리순으로_반환한다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise nearExercise = saveRecommendableExercise(party, defaultExerciseDate, + 37.5, 127.0, "가까운 체육관", LocalTime.of(11, 0), LocalTime.of(13, 0)); + Exercise farExercise = saveRecommendableExercise(party, defaultExerciseDate, + 35.1, 129.1, "먼 체육관", LocalTime.of(9, 0), LocalTime.of(11, 0)); + + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].date").value(defaultExerciseDate.toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(nearExercise.getId())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].buildingName").value("가까운 체육관")) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].distance").value(0.0)) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[1].exerciseId").value(farExercise.getId())); + } + + @Test + @DisplayName("필터 추천 최신순은 필터 조건에 맞는 운동만 시간순으로 반환한다") + void 필터_추천_최신순은_필터_조건에_맞는_운동만_시간순으로_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("isCockpleRecommend", "false") + .param("levels", "B") + .param("participationTypes", "SINGLE") + .param("activityTimes", "AFTERNOON") + .param("sortType", "LATEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.endDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(filteredEarlyExercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyId").value(filteredParty.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].partyName").value("필터 모임")) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(false)) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].distance").value(nullValue())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[1].exerciseId").value(filteredPopularExercise.getId())); + } + + @Test + @DisplayName("필터 추천 인기순은 참가자 수가 많은 운동을 먼저 반환한다") + void 필터_추천_인기순은_참가자_수가_많은_운동을_먼저_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString()) + .param("isCockpleRecommend", "false") + .param("levels", "B") + .param("participationTypes", "SINGLE") + .param("activityTimes", "AFTERNOON") + .param("sortType", "POPULARITY")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].exerciseId").value(filteredPopularExercise.getId())) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[0].isBookmarked").value(true)) + .andExpect(jsonPath("$.data.weeks[0].days[2].exercises[1].exerciseId").value(filteredEarlyExercise.getId())); + } + + @Test + @DisplayName("추천 운동이 없으면 기간 메타데이터와 빈 일자별 캘린더를 반환한다") + void 추천_운동이_없으면_기간_메타데이터와_빈_일자별_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "2030-01-05") + .param("endDate", "2030-01-11")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2030-01-05")) + .andExpect(jsonPath("$.data.endDate").value("2030-01-11")) + .andExpect(jsonPath("$.data.weeks[0].days[0].date").value("2029-12-31")) + .andExpect(jsonPath("$.data.weeks[0].days[5].date").value("2030-01-05")) + .andExpect(jsonPath("$.data.weeks[0].days[5].exercises").isEmpty()); + } + + @Test + @DisplayName("startDate만 주어져도 기본 기간이 적용된다") + void startDate만_주어져도_기본_기간이_적용된다() throws Exception { + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate defaultExerciseDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, defaultExerciseDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(defaultExerciseDate); + + Exercise defaultExercise = saveRecommendableExercise(party, defaultExerciseDate, + 37.5, 127.0, "기본기간 체육관", LocalTime.of(10, 0), LocalTime.of(12, 0)); + + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "2026-03-25")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value(expectedStart.toString())) + .andExpect(jsonPath("$.data.endDate").value(ExerciseCalendarTestHelper.expectedDefaultEndDate().toString())) + .andExpect(jsonPath("$.data.weeks[" + weekIndex + "].days[" + dayIndex + "].exercises[0].exerciseId").value(defaultExercise.getId())); + } + + @Test + @DisplayName("종료일이 시작일보다 이전이어도 빈 캘린더를 반환한다") + void 종료일이_시작일보다_이전이어도_빈_캘린더를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "2026-04-05") + .param("endDate", "2026-03-23")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.startDate").value("2026-04-05")) + .andExpect(jsonPath("$.data.endDate").value("2026-03-23")) + .andExpect(jsonPath("$.data.weeks").isEmpty()); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 에러를 반환한다") + void 존재하지_않는_멤버면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(999L, "없는멤버"); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("대표 주소가 없으면 에러를 반환한다") + void 대표_주소가_없으면_에러를_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(memberWithoutMainAddr.getId(), memberWithoutMainAddr.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", startDate.toString()) + .param("endDate", endDate.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getCode())) + .andExpect(jsonPath("$.message").value(ExerciseErrorCode.MAIN_ADDRESS_NULL.getMessage())); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 400을 반환한다") + void 날짜_형식이_잘못되면_400을_반환한다() throws Exception { + SecurityContextHelper.setAuthentication(recommendationMember.getId(), recommendationMember.getNickname()); + + mockMvc.perform(get("/api/exercises/recommendations/calendar") + .param("startDate", "invalid-date") + .param("endDate", endDate.toString())) + .andExpect(status().isBadRequest()); + } + } + + private Exercise saveRecommendableExercise(Party exerciseParty, LocalDate date, + double latitude, double longitude, + String buildingName, LocalTime startTime, LocalTime endTime) { + Exercise exercise = ExerciseFixture.createRecommendableExercise( + exerciseParty, date, latitude, longitude, buildingName); + ReflectionTestUtils.setField(exercise, "startTime", startTime); + ReflectionTestUtils.setField(exercise, "endTime", endTime); + return exerciseRepository.save(exercise); + } + } + } diff --git a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java index da2044244..117fd8d8f 100644 --- a/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/exercise/service/ExerciseQueryServiceTest.java @@ -22,6 +22,7 @@ import umc.cockple.demo.domain.exercise.dto.ExerciseDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseEditDetailDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMapBuildingsDTO; +import umc.cockple.demo.domain.exercise.dto.ExerciseRecommendationCalendarDTO; import umc.cockple.demo.domain.exercise.dto.ExerciseMyGuestListDTO; import umc.cockple.demo.domain.exercise.dto.MyExerciseCalendarDTO; import umc.cockple.demo.domain.exercise.dto.MyExerciseListDTO; @@ -44,6 +45,8 @@ 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.ParticipationType; import umc.cockple.demo.domain.party.enums.PartyStatus; import umc.cockple.demo.domain.party.exception.PartyErrorCode; import umc.cockple.demo.domain.party.exception.PartyException; @@ -54,6 +57,7 @@ import umc.cockple.demo.support.ExerciseCalendarTestHelper; import umc.cockple.demo.support.fixture.ExerciseFixture; import umc.cockple.demo.support.fixture.GuestFixture; +import umc.cockple.demo.support.fixture.MemberAddrFixture; import umc.cockple.demo.support.fixture.MemberFixture; import umc.cockple.demo.support.fixture.PartyFixture; @@ -70,6 +74,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -2066,4 +2071,286 @@ private Exercise createMapExercise(long id, LocalDate date, String buildingName, return mapExercise; } } + + @Nested + @DisplayName("getRecommendedExerciseCalendar") + class GetRecommendedExerciseCalendar { + + private Member recommendationMember; + private Member memberWithoutMainAddr; + private MemberAddr mainAddr; + private Party filteredParty; + private LocalDate startDate; + private LocalDate endDate; + + @BeforeEach + void setUp() { + recommendationMember = MemberFixture.createMember("추천캘린더회원", Gender.MALE, Level.A, 11001L, + LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(recommendationMember, "id", 11L); + mainAddr = MemberAddrFixture.createMainAddr(recommendationMember); + ReflectionTestUtils.setField(recommendationMember, "addresses", List.of(mainAddr)); + + memberWithoutMainAddr = MemberFixture.createMember("주소없는추천회원", Gender.MALE, Level.A, 11002L, + LocalDate.of(1995, 6, 15)); + ReflectionTestUtils.setField(memberWithoutMainAddr, "id", 12L); + ReflectionTestUtils.setField(memberWithoutMainAddr, "addresses", List.of(MemberAddrFixture.createSubAddr(memberWithoutMainAddr))); + + party.addLevel(Gender.MALE, Level.A); + + filteredParty = PartyFixture.createParty("필터 모임", manager.getId(), + PartyFixture.createPartyAddr("서울특별시", "강남구")); + ReflectionTestUtils.setField(filteredParty, "id", 20L); + ReflectionTestUtils.setField(filteredParty, "partyType", ParticipationType.SINGLE); + ReflectionTestUtils.setField(filteredParty, "activityTime", ActivityTime.AFTERNOON); + filteredParty.addLevel(Gender.MALE, Level.B); + + startDate = LocalDate.of(2026, 3, 23); + endDate = LocalDate.of(2026, 4, 5); + } + + @Nested + @DisplayName("성공 케이스") + class Success { + + @Test + @DisplayName("콕플 추천 기본 기간은 기본 범위를 사용하고 거리순으로 정렬한다") + void 콕플_추천_기본_기간은_기본_범위를_사용하고_거리순으로_정렬한다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + LocalDate targetDate = expectedStart.plusDays(9); + int weekIndex = ExerciseCalendarTestHelper.weekIndexFor(expectedStart, targetDate); + int dayIndex = ExerciseCalendarTestHelper.dayIndexFor(targetDate); + + Exercise nearExercise = createRecommendationExercise(party, 1001L, targetDate, + LocalTime.of(11, 0), LocalTime.of(13, 0), 37.5, 127.0, "가까운 체육관"); + Exercise farExercise = createRecommendationExercise(party, 1002L, targetDate, + LocalTime.of(9, 0), LocalTime.of(11, 0), 35.1, 129.1, "먼 체육관"); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) + .willReturn(List.of(farExercise, nearExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + recommendationMember.getId(), List.of(farExercise.getId(), nearExercise.getId()))) + .willReturn(List.of(nearExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(farExercise.getId(), nearExercise.getId()))) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), null, null, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).hasSize(5); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises()) + .extracting( + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::startTime, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::endTime, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked + ) + .containsExactly( + tuple(nearExercise.getId(), party.getId(), "테스트 모임", "가까운 체육관", + LocalTime.of(11, 0), LocalTime.of(13, 0), true), + tuple(farExercise.getId(), party.getId(), "테스트 모임", "먼 체육관", + LocalTime.of(9, 0), LocalTime.of(11, 0), false) + ); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(0).distance()).isZero(); + assertThat(response.weeks().get(weekIndex).days().get(dayIndex).exercises().get(1).distance()).isGreaterThan(0.0); + verify(exerciseRepository).findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd); + verify(exerciseRepository, never()).findFilteredRecommendedExercisesForCalendar(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("필터 추천은 필터 리포지토리만 호출하고 인기순 정렬을 적용한다") + void 필터_추천은_필터_리포지토리만_호출하고_인기순_정렬을_적용한다() { + // given + Exercise popularExercise = createRecommendationExercise(filteredParty, 1101L, LocalDate.of(2026, 3, 25), + LocalTime.of(18, 0), LocalTime.of(20, 0), 37.52, 127.02, "인기 체육관"); + Exercise earlyExercise = createRecommendationExercise(filteredParty, 1102L, LocalDate.of(2026, 3, 25), + LocalTime.of(9, 0), LocalTime.of(11, 0), 37.53, 127.03, "이른 체육관"); + + ExerciseRecommendationCalendarDTO.FilterSortType filterSortType = ExerciseRecommendationCalendarDTO.FilterSortType.builder() + .addr1("서울특별시") + .addr2("강남구") + .levels(List.of(Level.B)) + .participationTypes(List.of(ParticipationType.SINGLE)) + .activityTimes(List.of(ActivityTime.AFTERNOON)) + .sortType(MyPartyExerciseOrderType.POPULARITY) + .build(); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findFilteredRecommendedExercisesForCalendar( + recommendationMember.getId(), 1995, filterSortType, startDate, endDate)) + .willReturn(List.of(earlyExercise, popularExercise)); + given(exerciseBookmarkRepository.findAllExerciseIdsByMemberIdAndExerciseIds( + recommendationMember.getId(), List.of(earlyExercise.getId(), popularExercise.getId()))) + .willReturn(List.of(popularExercise.getId())); + given(exerciseRepository.findExerciseParticipantCountsByExerciseIds( + List.of(earlyExercise.getId(), popularExercise.getId()))) + .willReturn(List.of( + new Object[]{popularExercise.getId(), 3}, + new Object[]{earlyExercise.getId(), 1} + )); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), startDate, endDate, false, filterSortType); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks().get(0).days().get(2).exercises()) + .extracting( + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::exerciseId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyId, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::partyName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::buildingName, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::isBookmarked, + ExerciseRecommendationCalendarDTO.ExerciseCalendarItem::distance + ) + .containsExactly( + tuple(popularExercise.getId(), filteredParty.getId(), "필터 모임", "인기 체육관", true, null), + tuple(earlyExercise.getId(), filteredParty.getId(), "필터 모임", "이른 체육관", false, null) + ); + verify(exerciseRepository, never()).findCockpleRecommendedExercisesByDateRange(any(), any(), any(), anyInt(), any(), any()); + verify(exerciseRepository).findFilteredRecommendedExercisesForCalendar( + recommendationMember.getId(), 1995, filterSortType, startDate, endDate); + } + + @Test + @DisplayName("추천 운동이 없으면 기간 메타데이터와 빈 일자별 캘린더를 반환한다") + void 추천_운동이_없으면_기간_메타데이터와_빈_일자별_캘린더를_반환한다() { + // given + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(startDate); + assertThat(response.endDate()).isEqualTo(endDate); + assertThat(response.weeks()).hasSize(2); + assertThat(response.weeks().get(0).days()).hasSize(7); + assertThat(response.weeks().get(0).days().get(0).exercises()).isEmpty(); + } + + @Test + @DisplayName("startDate만 주어져도 기본 기간이 적용된다") + void startDate만_주어져도_기본_기간이_적용된다() { + // given + LocalDate expectedStart = ExerciseCalendarTestHelper.expectedDefaultStartDate(); + LocalDate expectedEnd = ExerciseCalendarTestHelper.expectedDefaultEndDate(); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, expectedStart, expectedEnd)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), LocalDate.of(2026, 3, 25), null, true, + recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(expectedStart); + assertThat(response.endDate()).isEqualTo(expectedEnd); + assertThat(response.weeks()).hasSize(5); + } + + @Test + @DisplayName("종료일이 시작일보다 이전이어도 빈 캘린더를 반환한다") + void 종료일이_시작일보다_이전이어도_빈_캘린더를_반환한다() { + // given + LocalDate reversedStart = LocalDate.of(2026, 4, 5); + LocalDate reversedEnd = LocalDate.of(2026, 3, 23); + + given(memberRepository.findMemberWithAddresses(recommendationMember.getId())) + .willReturn(Optional.of(recommendationMember)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + recommendationMember.getId(), Gender.MALE, Level.A, 1995, reversedStart, reversedEnd)) + .willReturn(List.of()); + + // when + ExerciseRecommendationCalendarDTO.Response response = exerciseQueryService.getRecommendedExerciseCalendar( + recommendationMember.getId(), reversedStart, reversedEnd, true, + recommendationFilter(MyPartyExerciseOrderType.LATEST)); + + // then + assertThat(response.startDate()).isEqualTo(reversedStart); + assertThat(response.endDate()).isEqualTo(reversedEnd); + assertThat(response.weeks()).isEmpty(); + } + } + + @Nested + @DisplayName("실패 케이스") + class Failure { + + @Test + @DisplayName("존재하지 않는 멤버면 예외를 던진다") + void 존재하지_않는_멤버면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExerciseCalendar( + 999L, startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("대표주소가 없으면 예외를 던진다") + void 대표주소가_없으면_예외를_던진다() { + // given + given(memberRepository.findMemberWithAddresses(memberWithoutMainAddr.getId())) + .willReturn(Optional.of(memberWithoutMainAddr)); + given(exerciseRepository.findCockpleRecommendedExercisesByDateRange( + memberWithoutMainAddr.getId(), Gender.MALE, Level.A, 1995, startDate, endDate)) + .willReturn(List.of()); + + // when & then + assertThatThrownBy(() -> exerciseQueryService.getRecommendedExerciseCalendar( + memberWithoutMainAddr.getId(), startDate, endDate, true, recommendationFilter(MyPartyExerciseOrderType.LATEST))) + .isInstanceOf(ExerciseException.class) + .hasFieldOrPropertyWithValue("code", ExerciseErrorCode.MAIN_ADDRESS_NULL); + } + } + + private ExerciseRecommendationCalendarDTO.FilterSortType recommendationFilter(MyPartyExerciseOrderType sortType) { + return ExerciseRecommendationCalendarDTO.FilterSortType.builder() + .sortType(sortType) + .build(); + } + + private Exercise createRecommendationExercise(Party exerciseParty, long id, LocalDate date, + LocalTime startTime, LocalTime endTime, + double latitude, double longitude, String buildingName) { + Exercise recommendationExercise = ExerciseFixture.createRecommendableExercise( + exerciseParty, date, latitude, longitude, buildingName); + ReflectionTestUtils.setField(recommendationExercise, "id", id); + ReflectionTestUtils.setField(recommendationExercise, "startTime", startTime); + ReflectionTestUtils.setField(recommendationExercise, "endTime", endTime); + return recommendationExercise; + } + } }