Skip to content

Commit 5314569

Browse files
committed
feat(schedule): 하루 상세 일정 조회(전체/개별)
1 parent d134e80 commit 5314569

File tree

4 files changed

+302
-5
lines changed

4 files changed

+302
-5
lines changed

src/main/java/back/kalender/domain/schedule/controller/ScheduleController.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,66 @@ public ResponseEntity<MonthlySchedulesResponse> getSchedulesPerArtist(
137137
summary = "특정 날짜 상세 일정 조회",
138138
description = "캘린더에서 특정 날짜를 클릭했을 때, 해당 날짜의 상세 일정 목록을 팝업 형태로 제공합니다."
139139
)
140+
@ApiResponses({
141+
@ApiResponse(responseCode = "200", description = "조회 성공",
142+
content = @Content(schema = @Schema(implementation = DailySchedulesResponse.class),
143+
examples = @ExampleObject(value = """
144+
{
145+
"dailySchedules": [
146+
{
147+
"scheduleId": 120,
148+
"artistName": "NewJeans",
149+
"title": "뮤직뱅크 출연",
150+
"scheduleCategory": "BROADCAST",
151+
"scheduleTime": "2025-12-15T17:00:00",
152+
"performanceId": null,
153+
"link": null,
154+
"location": "KBS 신관 공개홀"
155+
},
156+
{
157+
"scheduleId": 121,
158+
"artistName": "BTS",
159+
"title": "팬사인회",
160+
"scheduleCategory": "FAN_SIGN",
161+
"scheduleTime": "2025-12-15T19:00:00",
162+
"performanceId": null,
163+
"link": "https://ticket.example.com/bts",
164+
"location": "코엑스"
165+
}
166+
]
167+
}
168+
"""))),
169+
@ApiResponse(responseCode = "400", description = "잘못된 요청 (날짜 형식 오류)",
170+
content = @Content(examples = @ExampleObject(value = """
171+
{
172+
"error": {
173+
"code": "002",
174+
"status": "400",
175+
"message": "유효하지 않은 입력 값입니다. (Date Format: yyyy-MM-dd)"
176+
}
177+
}
178+
"""))),
179+
@ApiResponse(responseCode = "403", description = "권한 없음 (팔로우하지 않음)",
180+
content = @Content(examples = @ExampleObject(value = """
181+
{
182+
"error": {
183+
"code": "2001",
184+
"status": "403",
185+
"message": "팔로우하지 않은 아티스트입니다."
186+
}
187+
}
188+
"""))),
189+
@ApiResponse(responseCode = "404", description = "스케쥴 조회 실패 (유저 정보 없음)",
190+
content = @Content(examples = @ExampleObject(value = """
191+
{
192+
"error": {
193+
"code": "1001",
194+
"status": "404",
195+
"message": "유저를 찾을 수 없습니다."
196+
}
197+
}
198+
""")))
199+
})
140200
@GetMapping("/daily")
141201
public ResponseEntity<DailySchedulesResponse> getDailySchedules(
142202
@RequestParam String date,

src/main/java/back/kalender/domain/schedule/repository/ScheduleRepository.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package back.kalender.domain.schedule.repository;
22

3+
import back.kalender.domain.schedule.dto.response.DailyScheduleItem;
34
import back.kalender.domain.schedule.dto.response.MonthlyScheduleItem;
45
import back.kalender.domain.schedule.entity.Schedule;
56
import org.springframework.data.jpa.repository.JpaRepository;
@@ -33,4 +34,27 @@ List<MonthlyScheduleItem> findMonthlySchedules(
3334
@Param("startDateTime") LocalDateTime startDateTime,
3435
@Param("endDateTime") LocalDateTime endDateTime
3536
);
37+
38+
@Query("""
39+
SELECT new back.kalender.domain.schedule.dto.response.DailyScheduleItem(
40+
s.id,
41+
a.name,
42+
s.title,
43+
s.scheduleCategory,
44+
s.scheduleTime,
45+
s.performanceId,
46+
s.link,
47+
s.location
48+
)
49+
FROM Schedule s
50+
JOIN ArtistTmp a ON s.artistId = a.id
51+
WHERE s.artistId IN :artistIds
52+
AND s.scheduleTime BETWEEN :startOfDay AND :endOfDay
53+
ORDER BY s.scheduleTime ASC
54+
""")
55+
List<DailyScheduleItem> findDailySchedules(
56+
@Param("artistIds") List<Long> artistIds,
57+
@Param("startOfDay") LocalDateTime startOfDay,
58+
@Param("endOfDay") LocalDateTime endOfDay
59+
);
3660
}

src/main/java/back/kalender/domain/schedule/service/ScheduleServiceImpl.java

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
import back.kalender.domain.artist.entity.ArtistFollowTmp;
44
import back.kalender.domain.artist.repository.ArtistFollowRepositoryTmp;
55
import back.kalender.domain.artist.repository.ArtistRepositoryTmp;
6-
import back.kalender.domain.schedule.dto.response.DailySchedulesResponse;
7-
import back.kalender.domain.schedule.dto.response.MonthlyScheduleItem;
8-
import back.kalender.domain.schedule.dto.response.MonthlySchedulesResponse;
9-
import back.kalender.domain.schedule.dto.response.UpcomingEventsResponse;
6+
import back.kalender.domain.schedule.dto.response.*;
107
import back.kalender.domain.schedule.repository.ScheduleRepository;
118
import back.kalender.global.exception.ErrorCode;
129
import back.kalender.global.exception.ServiceException;
@@ -15,6 +12,7 @@
1512
import org.springframework.stereotype.Service;
1613
import org.springframework.transaction.annotation.Transactional;
1714

15+
import java.time.LocalDate;
1816
import java.time.LocalDateTime;
1917
import java.time.LocalTime;
2018
import java.time.YearMonth;
@@ -126,8 +124,64 @@ public MonthlySchedulesResponse getSchedulesPerArtist(Long userId, Long artistId
126124
}
127125
}
128126

127+
@Override
129128
public DailySchedulesResponse getDailySchedules(Long userId, String date, Optional<Long> artistId) {
130-
return null;
129+
log.info("[Schedule] [GetDaily] 하루 상세 일정 조회 시작 - userId={}, date={}, specificArtist={}",
130+
userId, date, artistId.orElse(null));
131+
132+
LocalDateTime startOfDay;
133+
LocalDateTime endOfDay;
134+
135+
try {
136+
LocalDate targetDate = LocalDate.parse(date);
137+
startOfDay = targetDate.atStartOfDay();
138+
endOfDay = targetDate.atTime(LocalTime.MAX);
139+
140+
log.debug("[Schedule] [GetDaily] 조회 시간 범위 계산 - start={}, end={}", startOfDay, endOfDay);
141+
142+
} catch (DateTimeParseException e) {
143+
log.error("[Schedule] [GetDaily] 날짜 파싱 오류 - date={}", date, e);
144+
throw new ServiceException(ErrorCode.INVALID_INPUT_VALUE);
145+
}
146+
147+
List<Long> targetArtistIds;
148+
149+
if (artistId.isPresent()) {
150+
Long id = artistId.get();
151+
152+
boolean isFollowing = artistFollowRepository.existsByUserIdAndArtistId(userId, id);
153+
if (!isFollowing) {
154+
log.warn("[Schedule] [GetDaily] 팔로우 관계 없음 - userId={}, artistId={}", userId, id);
155+
throw new ServiceException(ErrorCode.ARTIST_NOT_FOLLOWED);
156+
}
157+
158+
targetArtistIds = List.of(id);
159+
log.debug("[Schedule] [GetDaily] 단일 아티스트 필터링 적용 - artistId={}", id);
160+
161+
} else {
162+
List<ArtistFollowTmp> follows = artistFollowRepository.findAllByUserId(userId);
163+
164+
if (follows.isEmpty()) {
165+
log.info("[Schedule] [GetDaily] 팔로우한 아티스트 없음 - 빈 리스트 반환");
166+
return new DailySchedulesResponse(Collections.emptyList());
167+
}
168+
169+
targetArtistIds = follows.stream()
170+
.map(follow -> follow.getArtist().getId())
171+
.toList();
172+
173+
log.debug("[Schedule] [GetDaily] 전체 팔로우 아티스트 적용 - count={}", targetArtistIds.size());
174+
}
175+
176+
List<DailyScheduleItem> items = scheduleRepository.findDailySchedules(
177+
targetArtistIds,
178+
startOfDay,
179+
endOfDay
180+
);
181+
182+
log.info("[Schedule] [GetDaily] 하루 상세 일정 조회 완료 - count={}", items.size());
183+
184+
return new DailySchedulesResponse(items);
131185
}
132186

133187
public UpcomingEventsResponse getUpcomingEvents(Long userId, Optional<Long> artistId, int limit) {

src/test/java/back/kalender/domain/schedule/service/ScheduleServiceImplTest.java

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
import back.kalender.domain.artist.entity.ArtistFollowTmp;
44
import back.kalender.domain.artist.entity.ArtistTmp;
55
import back.kalender.domain.artist.repository.ArtistFollowRepositoryTmp;
6+
import back.kalender.domain.schedule.dto.response.DailyScheduleItem;
7+
import back.kalender.domain.schedule.dto.response.DailySchedulesResponse;
68
import back.kalender.domain.schedule.dto.response.MonthlyScheduleItem;
79
import back.kalender.domain.schedule.dto.response.MonthlySchedulesResponse;
810
import back.kalender.domain.schedule.entity.ScheduleCategory;
911
import back.kalender.domain.schedule.repository.ScheduleRepository;
12+
import back.kalender.global.exception.ErrorCode;
13+
import back.kalender.global.exception.ServiceException;
1014
import org.junit.jupiter.api.DisplayName;
1115
import org.junit.jupiter.api.Test;
1216
import org.junit.jupiter.api.extension.ExtendWith;
@@ -21,9 +25,11 @@
2125
import java.util.Optional;
2226

2327
import static org.assertj.core.api.Assertions.assertThat;
28+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2429
import static org.mockito.ArgumentMatchers.any;
2530
import static org.mockito.ArgumentMatchers.eq;
2631
import static org.mockito.BDDMockito.given;
32+
import static org.mockito.Mockito.times;
2733
import static org.mockito.Mockito.verify;
2834

2935
@ExtendWith(MockitoExtension.class)
@@ -95,6 +101,20 @@ private MonthlyScheduleItem createDto(Long id, Long artistId, String artistName,
95101
);
96102
}
97103

104+
@Test
105+
@DisplayName("월별 전체 조회 - 실패 (유효하지 않은 월 입력)")
106+
void getFollowingSchedules_Fail_InvalidMonth() {
107+
Long userId = 1L;
108+
int year = 2025;
109+
int invalidMonth = 13;
110+
111+
assertThatThrownBy(() ->
112+
scheduleServiceImpl.getFollowingSchedules(userId, year, invalidMonth)
113+
)
114+
.isInstanceOf(ServiceException.class)
115+
.hasMessageContaining(ErrorCode.INVALID_INPUT_VALUE.getMessage());
116+
}
117+
98118
@Test
99119
@DisplayName("여러 아티스트 중 특정 아티스트(BTS)만 조회 시, 정확히 해당 ID로만 필터링하여 요청한다.")
100120
void getArtistSchedules_FilterVerification() {
@@ -127,4 +147,143 @@ void getArtistSchedules_FilterVerification() {
127147

128148
verify(artistFollowRepository).existsByUserIdAndArtistId(userId, btsId);
129149
}
150+
151+
@Test
152+
@DisplayName("월별 개별 조회 - 실패 (유효하지 않은 월 입력)")
153+
void getArtistSchedules_Fail_InvalidMonth() {
154+
Long userId = 1L;
155+
Long artistId = 1L;
156+
int year = 2025;
157+
int invalidMonth = 0; // 1~12 범위를 벗어남
158+
159+
assertThatThrownBy(() ->
160+
scheduleServiceImpl.getSchedulesPerArtist(userId, artistId, year, invalidMonth)
161+
)
162+
.isInstanceOf(ServiceException.class)
163+
.hasMessageContaining(ErrorCode.INVALID_INPUT_VALUE.getMessage());
164+
}
165+
166+
@Test
167+
@DisplayName("월별 개별 조회 - 실패 (팔로우하지 않은 아티스트 요청)")
168+
void getArtistSchedules_Fail_NotFollowed() {
169+
Long userId = 1L;
170+
Long notFollowedId = 99L;
171+
int year = 2025;
172+
int month = 11;
173+
174+
given(artistFollowRepository.existsByUserIdAndArtistId(userId, notFollowedId))
175+
.willReturn(false);
176+
177+
assertThatThrownBy(() ->
178+
scheduleServiceImpl.getSchedulesPerArtist(userId, notFollowedId, year, month)
179+
)
180+
.isInstanceOf(ServiceException.class)
181+
.hasMessageContaining(ErrorCode.ARTIST_NOT_FOLLOWED.getMessage());
182+
183+
verify(scheduleRepository, times(0)).findMonthlySchedules(any(), any(), any());
184+
}
185+
@Test
186+
@DisplayName("하루 상세 조회 - 성공 (전체 아티스트)")
187+
void getDailySchedules_Success_AllArtists() {
188+
Long userId = 1L;
189+
String dateStr = "2025-12-15";
190+
191+
ArtistTmp bts = new ArtistTmp("BTS", "img");
192+
ReflectionTestUtils.setField(bts, "id", 1L);
193+
ArtistFollowTmp f1 = new ArtistFollowTmp(userId, bts);
194+
195+
ArtistTmp bp = new ArtistTmp("BP", "img");
196+
ReflectionTestUtils.setField(bp, "id", 2L);
197+
ArtistFollowTmp f2 = new ArtistFollowTmp(userId, bp);
198+
199+
given(artistFollowRepository.findAllByUserId(userId))
200+
.willReturn(List.of(f1, f2));
201+
202+
List<DailyScheduleItem> dbResult = List.of(
203+
createDailyDto(100L, "BTS", "콘서트"),
204+
createDailyDto(101L, "BP", "방송")
205+
);
206+
207+
given(scheduleRepository.findDailySchedules(any(), any(), any()))
208+
.willReturn(dbResult);
209+
210+
DailySchedulesResponse response = scheduleServiceImpl.getDailySchedules(userId, dateStr, Optional.empty());
211+
212+
assertThat(response.dailySchedules()).hasSize(2);
213+
214+
verify(scheduleRepository).findDailySchedules(
215+
eq(List.of(1L, 2L)),
216+
any(),
217+
any()
218+
);
219+
}
220+
221+
@Test
222+
@DisplayName("하루 상세 조회 - 성공 (특정 아티스트 필터링)")
223+
void getDailySchedules_Success_SpecificArtist() {
224+
Long userId = 1L;
225+
Long targetId = 1L; // BTS
226+
String dateStr = "2025-12-15";
227+
228+
given(artistFollowRepository.existsByUserIdAndArtistId(userId, targetId))
229+
.willReturn(true);
230+
231+
List<DailyScheduleItem> dbResult = List.of(
232+
createDailyDto(100L, "BTS", "콘서트")
233+
);
234+
235+
given(scheduleRepository.findDailySchedules(any(), any(), any()))
236+
.willReturn(dbResult);
237+
238+
DailySchedulesResponse response = scheduleServiceImpl.getDailySchedules(userId, dateStr, Optional.of(targetId));
239+
240+
assertThat(response.dailySchedules()).hasSize(1);
241+
assertThat(response.dailySchedules().get(0).artistName()).isEqualTo("BTS");
242+
243+
verify(scheduleRepository).findDailySchedules(
244+
eq(List.of(targetId)),
245+
any(),
246+
any()
247+
);
248+
verify(artistFollowRepository, times(0)).findAllByUserId(any());
249+
}
250+
251+
@Test
252+
@DisplayName("하루 상세 조회 - 실패 (날짜 포맷 오류)")
253+
void getDailySchedules_Fail_InvalidDate() {
254+
Long userId = 1L;
255+
String invalidDate = "2025/12/15";
256+
257+
assertThatThrownBy(() ->
258+
scheduleServiceImpl.getDailySchedules(userId, invalidDate, Optional.empty())
259+
)
260+
.isInstanceOf(ServiceException.class)
261+
.hasMessageContaining(ErrorCode.INVALID_INPUT_VALUE.getMessage());
262+
}
263+
264+
@Test
265+
@DisplayName("하루 상세 조회 - 실패 (팔로우하지 않은 아티스트 요청)")
266+
void getDailySchedules_Fail_NotFollowed() {
267+
Long userId = 1L;
268+
Long notFollowedId = 99L;
269+
String dateStr = "2025-12-15";
270+
271+
given(artistFollowRepository.existsByUserIdAndArtistId(userId, notFollowedId))
272+
.willReturn(false);
273+
274+
assertThatThrownBy(() ->
275+
scheduleServiceImpl.getDailySchedules(userId, dateStr, Optional.of(notFollowedId))
276+
)
277+
.isInstanceOf(ServiceException.class)
278+
.hasMessageContaining(ErrorCode.ARTIST_NOT_FOLLOWED.getMessage());
279+
}
280+
281+
private DailyScheduleItem createDailyDto(Long id, String artistName, String title) {
282+
return new DailyScheduleItem(
283+
id, artistName, title,
284+
ScheduleCategory.CONCERT,
285+
LocalDateTime.now(),
286+
null, null, "장소"
287+
);
288+
}
130289
}

0 commit comments

Comments
 (0)