diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt index 9f773813..feee4292 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceReader.kt @@ -2,6 +2,7 @@ package com.weeth.domain.attendance.domain.repository import com.weeth.domain.attendance.domain.entity.Attendance import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.session.domain.entity.Session import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice @@ -17,4 +18,11 @@ interface AttendanceReader { status: AttendanceStatus, pageable: Pageable, ): Slice + + fun findAllBySession(session: Session): List + + fun findBySessionAndUserId( + session: Session, + userId: Long, + ): Attendance? } diff --git a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt index 3e2754fa..7c1e017d 100644 --- a/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt +++ b/src/main/kotlin/com/weeth/domain/attendance/domain/repository/AttendanceRepository.kt @@ -39,7 +39,15 @@ interface AttendanceRepository : ): List @EntityGraph(attributePaths = ["clubMember", "clubMember.user"]) - fun findAllBySession(session: Session): List + override fun findAllBySession(session: Session): List + + @Query( + "SELECT a FROM Attendance a JOIN FETCH a.clubMember cm JOIN FETCH cm.user WHERE a.session = :session AND cm.user.id = :userId", + ) + override fun findBySessionAndUserId( + @Param("session") session: Session, + @Param("userId") userId: Long, + ): Attendance? @Query( """ diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt new file mode 100644 index 00000000..d57abd36 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/AttendeeResponse.kt @@ -0,0 +1,15 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.weeth.domain.club.domain.enums.MemberRole +import io.swagger.v3.oas.annotations.media.Schema + +data class AttendeeResponse( + @field:Schema(description = "이름", example = "홍길동") + val name: String, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String?, + @field:Schema(description = "권한", example = "USER") + val role: MemberRole, + @field:Schema(description = "프로필 이미지 URL") + val profileImageUrl: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt new file mode 100644 index 00000000..1e0df3aa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleAttendanceStatus.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.schedule.application.dto.response + +enum class ScheduleAttendanceStatus { + UPCOMING, // 출석 예정 + OPEN, // 지금 출석 가능 + COMPLETED, // 출석 완료 + ABSENT, // 결석 +} diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleDetailResponse.kt b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleDetailResponse.kt new file mode 100644 index 00000000..d4a820f9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/schedule/application/dto/response/ScheduleDetailResponse.kt @@ -0,0 +1,32 @@ +package com.weeth.domain.schedule.application.dto.response + +import com.weeth.domain.schedule.domain.enums.Type +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class ScheduleDetailResponse( + @field:Schema(description = "일정 ID", example = "1") + val id: Long, + @field:Schema(description = "일정 유형", example = "SESSION") + val type: Type, + @field:Schema(description = "제목", example = "1주차 정기모임") + val title: String, + @field:Schema(description = "설명") + val description: String?, + @field:Schema(description = "장소", example = "가천대 체육관") + val location: String?, + @field:Schema(description = "시작 시간") + val start: LocalDateTime, + @field:Schema(description = "종료 시간") + val end: LocalDateTime, + @field:Schema(description = "생성자 이름", example = "홍길동") + val creatorName: String?, + @field:Schema(description = "내 출석 상태 (SESSION만, EVENT는 null)") + val myAttendanceStatus: ScheduleAttendanceStatus?, + @field:Schema(description = "출석 완료 시간 (COMPLETED일 때만, 나머지는 null)") + val attendedAt: LocalDateTime?, + @field:Schema(description = "총 참석자 수 (SESSION만, EVENT는 null)") + val totalAttendees: Int?, + @field:Schema(description = "참석자 목록 (SESSION만, EVENT는 null)") + val attendees: List?, +) diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt index 6d8ad1d4..d006fb6a 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/mapper/ScheduleMapper.kt @@ -1,13 +1,21 @@ package com.weeth.domain.schedule.application.mapper +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.schedule.application.dto.response.AttendeeResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleAttendanceStatus +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.domain.entity.Event import com.weeth.domain.schedule.domain.enums.Type import com.weeth.domain.session.domain.entity.Session import org.springframework.stereotype.Component +import java.time.LocalDateTime @Component -class ScheduleMapper { +class ScheduleMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { fun toResponse(event: Event): ScheduleResponse = ScheduleResponse( id = event.id, @@ -29,4 +37,49 @@ class ScheduleMapper { location = session.location, cardinal = session.cardinal, ) + + fun toDetailResponse(event: Event): ScheduleDetailResponse = + ScheduleDetailResponse( + id = event.id, + type = Type.EVENT, + title = event.title, + description = event.content, + location = event.location, + start = event.start, + end = event.end, + creatorName = event.user?.name, + myAttendanceStatus = null, + attendedAt = null, + totalAttendees = null, + attendees = null, + ) + + fun toDetailResponse( + session: Session, + attendances: List, + myAttendanceStatus: ScheduleAttendanceStatus, + attendedAt: LocalDateTime?, + ): ScheduleDetailResponse = + ScheduleDetailResponse( + id = session.id, + type = Type.SESSION, + title = session.title, + description = session.content, + location = session.location, + start = session.start, + end = session.end, + creatorName = session.user?.name, + myAttendanceStatus = myAttendanceStatus, + attendedAt = attendedAt, + totalAttendees = attendances.size, + attendees = attendances.map { toAttendeeResponse(it) }, + ) + + private fun toAttendeeResponse(attendance: Attendance): AttendeeResponse = + AttendeeResponse( + name = attendance.clubMember.user.name, + department = attendance.clubMember.user.department, + role = attendance.clubMember.memberRole, + profileImageUrl = attendance.clubMember.profileImageStorageKey?.let { fileAccessUrlPort.resolve(it) }, + ) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt index 2725bd43..8cdcfec1 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryService.kt @@ -1,12 +1,21 @@ package com.weeth.domain.schedule.application.usecase.query +import com.weeth.domain.attendance.domain.entity.Attendance +import com.weeth.domain.attendance.domain.enums.AttendanceStatus +import com.weeth.domain.attendance.domain.repository.AttendanceReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import com.weeth.domain.schedule.application.dto.response.EventResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleAttendanceStatus +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.exception.EventNotFoundException import com.weeth.domain.schedule.application.mapper.EventMapper import com.weeth.domain.schedule.application.mapper.ScheduleMapper +import com.weeth.domain.schedule.domain.enums.Type import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.entity.Session +import com.weeth.domain.session.domain.enums.SessionStatus import com.weeth.domain.session.domain.repository.SessionReader import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -18,6 +27,7 @@ import java.time.LocalDateTime class GetScheduleQueryService( private val eventRepository: EventRepository, private val sessionReader: SessionReader, + private val attendanceReader: AttendanceReader, private val clubMemberPolicy: ClubMemberPolicy, private val scheduleMapper: ScheduleMapper, private val eventMapper: EventMapper, @@ -38,6 +48,7 @@ class GetScheduleQueryService( fun findMonthly( clubId: Long, userId: Long, + cardinal: Int, start: LocalDateTime, end: LocalDateTime, ): List { @@ -45,17 +56,74 @@ class GetScheduleQueryService( val events = eventRepository - .findByClubIdAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, end, start) + .findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) .map { scheduleMapper.toResponse(it) } val sessions = sessionReader - .findAllByClubIdAndStartBetween(clubId, start, end) + .findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) .map { scheduleMapper.toResponse(it) } return (events + sessions).sortedBy { it.start } } + fun findDetail( + clubId: Long, + userId: Long, + id: Long, + type: Type, + ): ScheduleDetailResponse { + clubMemberPolicy.getActiveMember(clubId, userId) + return when (type) { + Type.EVENT -> { + val event = eventRepository.findByIdOrNull(id) ?: throw EventNotFoundException() + if (event.club.id != clubId) throw EventNotFoundException() + scheduleMapper.toDetailResponse(event) + } + + Type.SESSION -> { + val session = sessionReader.getById(id) + if (session.club.id != clubId) throw SessionNotFoundException() + val allAttendances = attendanceReader.findAllBySession(session) + val myAttendance = attendanceReader.findBySessionAndUserId(session, userId) + val myStatus = deriveAttendanceStatus(myAttendance, session) + val attendedAt = myAttendance?.modifiedAt?.takeIf { myStatus == ScheduleAttendanceStatus.COMPLETED } + scheduleMapper.toDetailResponse(session, allAttendances, myStatus, attendedAt) + } + } + } + + private fun deriveAttendanceStatus( + attendance: Attendance?, + session: Session, + ): ScheduleAttendanceStatus { + val now = LocalDateTime.now() + if (attendance == null) { + return when { + now.isBefore(session.start.minusMinutes(10)) -> ScheduleAttendanceStatus.UPCOMING + session.status == SessionStatus.OPEN && session.isCheckInAllowed(now) -> ScheduleAttendanceStatus.OPEN + else -> ScheduleAttendanceStatus.ABSENT + } + } + return when (attendance.status) { + AttendanceStatus.ATTEND -> { + ScheduleAttendanceStatus.COMPLETED + } + + AttendanceStatus.ABSENT -> { + ScheduleAttendanceStatus.ABSENT + } + + AttendanceStatus.PENDING -> { + val isOpen = + session.status == SessionStatus.OPEN && + !now.isBefore(session.start) && + !now.isAfter(session.end) + if (isOpen) ScheduleAttendanceStatus.OPEN else ScheduleAttendanceStatus.UPCOMING + } + } + } + fun findYearly( clubId: Long, userId: Long, diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt index e7fe161f..3c90c460 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventReader.kt @@ -15,5 +15,12 @@ interface EventReader { end: LocalDateTime, ): List + fun findByClubIdAndCardinalAndDateRange( + clubId: Long, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + ): List + fun findAllByCardinal(cardinal: Int): List } diff --git a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt index f9ba7014..25de2eab 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/domain/repository/EventRepository.kt @@ -40,4 +40,22 @@ interface EventRepository : @Param("end") end: LocalDateTime, @Param("start") start: LocalDateTime, ): List + + @Query( + "SELECT e FROM Event e WHERE e.club.id = :clubId AND e.cardinal = :cardinal AND e.start <= :end AND e.end >= :start ORDER BY e.start ASC", + ) + fun findByClubIdAndCardinalAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc( + @Param("clubId") clubId: Long, + @Param("cardinal") cardinal: Int, + @Param("end") end: LocalDateTime, + @Param("start") start: LocalDateTime, + ): List + + override fun findByClubIdAndCardinalAndDateRange( + clubId: Long, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + ): List = + findByClubIdAndCardinalAndStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(clubId, cardinal, end, start) } diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt index 150939d4..d22c5a99 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleController.kt @@ -1,7 +1,9 @@ package com.weeth.domain.schedule.presentation +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse import com.weeth.domain.schedule.application.dto.response.ScheduleResponse import com.weeth.domain.schedule.application.usecase.query.GetScheduleQueryService +import com.weeth.domain.schedule.domain.enums.Type import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.common.response.CommonResponse import com.weeth.global.common.web.TsidParam @@ -11,6 +13,7 @@ import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -28,16 +31,32 @@ class ScheduleController( @TsidParam @TsidPathVariable clubId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, + @RequestParam cardinal: Int, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) start: LocalDateTime, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) end: LocalDateTime, ): CommonResponse> = CommonResponse.success( ScheduleResponseCode.SCHEDULE_MONTHLY_FIND_SUCCESS, - getScheduleQueryService.findMonthly(clubId, userId, start, end), + getScheduleQueryService.findMonthly(clubId, userId, cardinal, start, end), ) + @GetMapping("/{id}") + @Operation(summary = "일정 상세 조회") + fun findDetail( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + @PathVariable id: Long, + @RequestParam type: Type, + ): CommonResponse = + CommonResponse.success( + ScheduleResponseCode.SCHEDULE_DETAIL_FIND_SUCCESS, + getScheduleQueryService.findDetail(clubId, userId, id, type), + ) + + @Deprecated("사용하지 않는 API") @GetMapping("/yearly") - @Operation(summary = "연도별 일정 조회") + @Operation(summary = "연도별 일정 조회", deprecated = true) fun findByYearly( @TsidParam @TsidPathVariable clubId: Long, diff --git a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt index cb7d5c5f..8db77de8 100644 --- a/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/schedule/presentation/ScheduleResponseCode.kt @@ -14,4 +14,5 @@ enum class ScheduleResponseCode( EVENT_FIND_SUCCESS(10803, HttpStatus.OK, "일정이 성공적으로 조회되었습니다."), SCHEDULE_MONTHLY_FIND_SUCCESS(10804, HttpStatus.OK, "월별 일정이 성공적으로 조회되었습니다."), SCHEDULE_YEARLY_FIND_SUCCESS(10805, HttpStatus.OK, "연도별 일정이 성공적으로 조회되었습니다."), + SCHEDULE_DETAIL_FIND_SUCCESS(10806, HttpStatus.OK, "일정 상세가 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt index b270b389..c1197959 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionReader.kt @@ -28,6 +28,13 @@ interface SessionReader { end: LocalDateTime, ): List + fun findAllByClubIdAndCardinalAndStartBetween( + clubId: Long, + cardinal: Int, + start: LocalDateTime, + end: LocalDateTime, + ): List + fun findAllByClubIdAndCardinalIn( clubId: Long, cardinals: List, diff --git a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt index 22796d31..5cb5c582 100644 --- a/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/session/domain/repository/SessionRepository.kt @@ -67,6 +67,16 @@ interface SessionRepository : end: LocalDateTime, ): List = findByStartLessThanEqualAndEndGreaterThanEqualOrderByStartAsc(end, start) + @Query( + "SELECT s FROM Session s WHERE s.club.id = :clubId AND s.cardinal = :cardinal AND s.start <= :end AND s.end >= :start ORDER BY s.start ASC", + ) + override fun findAllByClubIdAndCardinalAndStartBetween( + @Param("clubId") clubId: Long, + @Param("cardinal") cardinal: Int, + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime, + ): List + override fun findAllByClubIdAndCardinalIn( clubId: Long, cardinals: List, diff --git a/src/test/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryServiceTest.kt new file mode 100644 index 00000000..b38cbed4 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/schedule/application/usecase/query/GetScheduleQueryServiceTest.kt @@ -0,0 +1,315 @@ +package com.weeth.domain.schedule.application.usecase.query + +import com.weeth.domain.attendance.domain.repository.AttendanceReader +import com.weeth.domain.attendance.fixture.AttendanceTestFixture +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.schedule.application.dto.response.ScheduleAttendanceStatus +import com.weeth.domain.schedule.application.dto.response.ScheduleDetailResponse +import com.weeth.domain.schedule.application.dto.response.ScheduleResponse +import com.weeth.domain.schedule.application.exception.EventNotFoundException +import com.weeth.domain.schedule.application.mapper.EventMapper +import com.weeth.domain.schedule.application.mapper.ScheduleMapper +import com.weeth.domain.schedule.domain.enums.Type +import com.weeth.domain.schedule.domain.repository.EventRepository +import com.weeth.domain.schedule.fixture.ScheduleTestFixture +import com.weeth.domain.session.application.exception.SessionNotFoundException +import com.weeth.domain.session.domain.enums.SessionStatus +import com.weeth.domain.session.domain.repository.SessionReader +import com.weeth.domain.session.fixture.SessionTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.repository.findByIdOrNull +import java.time.LocalDateTime + +class GetScheduleQueryServiceTest : + DescribeSpec({ + val eventRepository = mockk() + val sessionReader = mockk() + val attendanceReader = mockk() + val clubMemberPolicy = mockk(relaxed = true) + val scheduleMapper = mockk() + val eventMapper = mockk() + val queryService = + GetScheduleQueryService( + eventRepository, + sessionReader, + attendanceReader, + clubMemberPolicy, + scheduleMapper, + eventMapper, + ) + + val clubId = 1L + val userId = 10L + val cardinal = 7 + val start = LocalDateTime.of(2026, 12, 1, 0, 0) + val end = LocalDateTime.of(2026, 12, 31, 23, 59, 59) + + beforeTest { + clearMocks(eventRepository, sessionReader, attendanceReader, scheduleMapper) + } + + describe("findMonthly") { + it("이벤트와 세션을 시작 시간 순으로 합쳐서 반환한다") { + val event = + ScheduleTestFixture.createEvent( + id = 1L, + cardinal = cardinal, + start = LocalDateTime.of(2026, 12, 10, 10, 0), + end = LocalDateTime.of(2026, 12, 10, 12, 0), + ) + val session = + SessionTestFixture.createSession( + id = 2L, + cardinal = cardinal, + start = LocalDateTime.of(2026, 12, 5, 14, 0), + end = LocalDateTime.of(2026, 12, 5, 16, 0), + ) + val eventResponse = + ScheduleResponse( + id = 1L, + title = "Test Event", + start = event.start, + end = event.end, + type = Type.EVENT, + location = "Test Location", + cardinal = cardinal, + ) + val sessionResponse = + ScheduleResponse( + id = 2L, + title = "Test Session", + start = session.start, + end = session.end, + type = Type.SESSION, + location = "Test Location", + cardinal = cardinal, + ) + + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + listOf(event) + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + listOf(session) + every { scheduleMapper.toResponse(event) } returns eventResponse + every { scheduleMapper.toResponse(session) } returns sessionResponse + + // 세션(12/5)이 이벤트(12/10)보다 앞에 와야 함 + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe + listOf(sessionResponse, eventResponse) + } + + it("이벤트만 있으면 이벤트만 반환한다") { + val event = ScheduleTestFixture.createEvent(id = 1L, cardinal = cardinal) + val eventResponse = + ScheduleResponse( + id = 1L, + title = "Test Event", + start = event.start, + end = event.end, + type = Type.EVENT, + location = "Test Location", + cardinal = cardinal, + ) + + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + listOf(event) + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + emptyList() + every { scheduleMapper.toResponse(event) } returns eventResponse + + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe listOf(eventResponse) + } + + it("세션만 있으면 세션만 반환한다") { + val session = SessionTestFixture.createSession(id = 1L, cardinal = cardinal) + val sessionResponse = + ScheduleResponse( + id = 1L, + title = "Test Session", + start = session.start, + end = session.end, + type = Type.SESSION, + location = "Test Location", + cardinal = cardinal, + ) + + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + emptyList() + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + listOf(session) + every { scheduleMapper.toResponse(session) } returns sessionResponse + + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe listOf(sessionResponse) + } + + it("해당 기수의 일정이 없으면 빈 목록을 반환한다") { + every { eventRepository.findByClubIdAndCardinalAndDateRange(clubId, cardinal, start, end) } returns + emptyList() + every { sessionReader.findAllByClubIdAndCardinalAndStartBetween(clubId, cardinal, start, end) } returns + emptyList() + + queryService.findMonthly(clubId, userId, cardinal, start, end) shouldBe emptyList() + } + } + + describe("findDetail") { + val club = ClubTestFixture.createClub(id = clubId) + val member = ClubMemberTestFixture.createActiveMember(club = club) + + context("EVENT 타입일 때") { + it("이벤트 상세를 반환한다") { + val event = ScheduleTestFixture.createEvent(id = 1L, club = club) + val mockResponse = mockk() + + every { eventRepository.findByIdOrNull(1L) } returns event + every { scheduleMapper.toDetailResponse(event) } returns mockResponse + + queryService.findDetail(clubId, userId, 1L, Type.EVENT) shouldBe mockResponse + } + + it("이벤트가 없으면 EventNotFoundException을 던진다") { + every { eventRepository.findByIdOrNull(99L) } returns null + + shouldThrow { + queryService.findDetail(clubId, userId, 99L, Type.EVENT) + } + } + + it("이벤트가 다른 클럽 소속이면 EventNotFoundException을 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val event = ScheduleTestFixture.createEvent(id = 1L, club = otherClub) + + every { eventRepository.findByIdOrNull(1L) } returns event + + shouldThrow { + queryService.findDetail(clubId, userId, 1L, Type.EVENT) + } + } + } + + context("SESSION 타입일 때") { + it("출석 완료(COMPLETED)이면 attendedAt과 함께 매퍼를 호출한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + val attendance = AttendanceTestFixture.createAttendance(session, member) + attendance.attend() + val mockResponse = mockk() + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns + mockResponse + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) shouldBe mockResponse + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.COMPLETED, + attendance.modifiedAt, + ) + } + } + + it("결석(ABSENT)이면 attendedAt 없이 매퍼를 호출한다") { + val session = SessionTestFixture.createSession(id = 1L, club = club) + val attendance = AttendanceTestFixture.createAttendance(session, member) + attendance.absent() + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns mockk() + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.ABSENT, + null, + ) + } + } + + it("출석 예정(UPCOMING)이면 attendedAt 없이 매퍼를 호출한다") { + val session = + SessionTestFixture.createSession( + id = 1L, + club = club, + start = LocalDateTime.now().plusDays(1), + end = LocalDateTime.now().plusDays(1).plusHours(2), + ) + val attendance = AttendanceTestFixture.createAttendance(session, member) + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns mockk() + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.UPCOMING, + null, + ) + } + } + + it("현재 출석 가능(OPEN) 시간이면 OPEN 상태로 매퍼를 호출한다") { + val session = + SessionTestFixture.createSession( + id = 1L, + club = club, + start = LocalDateTime.now().minusHours(1), + end = LocalDateTime.now().plusHours(1), + status = SessionStatus.OPEN, + ) + val attendance = AttendanceTestFixture.createAttendance(session, member) + + every { sessionReader.getById(1L) } returns session + every { attendanceReader.findAllBySession(session) } returns listOf(attendance) + every { attendanceReader.findBySessionAndUserId(session, userId) } returns attendance + every { scheduleMapper.toDetailResponse(session, listOf(attendance), any(), any()) } returns mockk() + + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + verify { + scheduleMapper.toDetailResponse( + session, + listOf(attendance), + ScheduleAttendanceStatus.OPEN, + null, + ) + } + } + + it("세션이 없으면 SessionNotFoundException을 던진다") { + every { sessionReader.getById(99L) } throws SessionNotFoundException() + + shouldThrow { + queryService.findDetail(clubId, userId, 99L, Type.SESSION) + } + } + + it("세션이 다른 클럽 소속이면 SessionNotFoundException을 던진다") { + val otherClub = ClubTestFixture.createClub(id = 999L) + val session = SessionTestFixture.createSession(id = 1L, club = otherClub) + + every { sessionReader.getById(1L) } returns session + + shouldThrow { + queryService.findDetail(clubId, userId, 1L, Type.SESSION) + } + } + } + } + })