diff --git a/src/main/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverter.java b/src/main/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverter.java index 7677ac4a0..591e1ca03 100644 --- a/src/main/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverter.java +++ b/src/main/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverter.java @@ -43,19 +43,63 @@ public static Optional convertToAcademicEvent(IcsEvent icsEvent) // 2. summary 전처리 (괄호 안 날짜/시간 제거 등) String summary = AcademicEventSummaryNormalizer.normalize(rawSummary); - LocalDateTime startTime = StringToDateTimeConverter.convert(icsEvent.dtstart()); - LocalDateTime endTime = StringToDateTimeConverter.convert(icsEvent.dtend()); - - AcademicEventCategory category = AcademicEventCategorizer.categorize(summary); - Transparent transparent = convertToTransparent(icsEvent.transp()); - Integer sequence = convertToSequence(icsEvent.sequence()); - - boolean notifyEnabled = AcademicEventNotificationClassifier.proceed(transparent, summary); + // 3. 종일 일정이 이틀에 걸쳐서 표시되지 않도록 수정 try { + if (icsEvent.dtstart() == null || icsEvent.dtstart().isBlank()) { + log.warn("DTSTART가 존재하지 않습니다. (uid={}, summary={})", uid, summary); + return Optional.empty(); + } + + boolean isAllDayEvent = isAllDayEvent(icsEvent.dtstart()); // 종일 일정을 판단하기 위한 boolean 변수 + + if (icsEvent.dtend() != null + && !icsEvent.dtend().isBlank() + && isAllDayEvent != isAllDayEvent(icsEvent.dtend())) { + + log.warn( + "DTSTART와 DTEND의 형식이 다릅니다. (uid={}, dtstart={}, dtend={})", + uid, + icsEvent.dtstart(), + icsEvent.dtend() + ); + return Optional.empty(); + } + + LocalDateTime startTime = StringToDateTimeConverter.convert(icsEvent.dtstart()); + LocalDateTime endTime; + + if (icsEvent.dtend() == null || icsEvent.dtend().isBlank()) { // DTEND가 존재하지 않을 경우 + if (isAllDayEvent) { + endTime = startTime.plusDays(1).minusSeconds(1); + } else { + endTime = startTime; + } + } else { // DTEND가 정상적으로 존재할 경우 + endTime = StringToDateTimeConverter.convert(icsEvent.dtend()); + + if (isAllDayEvent) { + endTime = adjustAllDayEndTime(endTime); + } + } + + if (endTime.isBefore(startTime)) { + log.warn("DTEND가 DTSTART보다 이전입니다. (uid={}, dtstart={}, dtend={})", + uid, icsEvent.dtstart(), icsEvent.dtend()); + return Optional.empty(); + } + + AcademicEventCategory category = AcademicEventCategorizer.categorize(summary); + Transparent transparent = convertToTransparent(icsEvent.transp()); + Integer sequence = convertToSequence(icsEvent.sequence()); + + boolean notifyEnabled = + AcademicEventNotificationClassifier.proceed(transparent, summary); + return Optional.of( AcademicEvent.from(uid, summary, description, category, - transparent, sequence, notifyEnabled, startTime, endTime) + transparent, sequence, notifyEnabled, startTime, endTime + ) ); } catch (Exception e) { log.warn("ICS event 변환에 실패했습니다.(uid={}, summary={}): {}", uid, summary, e.toString()); @@ -106,4 +150,18 @@ private static boolean isHolidayEvent(String summary) { return HOLIDAY_PATTERN.matcher(summary).matches(); } + /** + * 종일 일정인지 판별하는 메서드 + */ + private static boolean isAllDayEvent(String dateTime) { + return dateTime != null && dateTime.matches("^\\d{8}$"); + } + + /** + * 종일 일정일 때 endTime의 날짜를 1초 당기는 메서드 + */ + private static LocalDateTime adjustAllDayEndTime(LocalDateTime endTime) { + return endTime.minusSeconds(1); + } + } \ No newline at end of file diff --git a/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverterTest.java b/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverterTest.java index a4c935815..ba7f071df 100644 --- a/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverterTest.java +++ b/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventConverterTest.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -60,7 +61,7 @@ void convert_ics_event_to_academic_event() { //then assertEventFields(academicEvent, "하계방학", 0, Transparent.TRANSPARENT, false, LocalDateTime.of(2024, 6, 22, 0, 0), - LocalDateTime.of(2024, 9, 2, 0, 0)); + LocalDateTime.of(2024, 9, 1, 23, 59, 59)); } @DisplayName("다건 학사 이벤트 변환을 수행할 수 있다.") @@ -72,11 +73,11 @@ void convert_multi_ics_event_to_academic_event() { //then assertEventFields(academicEvents.get(0), "하계방학", 0, Transparent.TRANSPARENT, false, LocalDateTime.of(2024, 6, 22, 0, 0), - LocalDateTime.of(2024, 9, 2, 0, 0)); + LocalDateTime.of(2024, 9, 1, 23, 59, 59)); assertEventFields(academicEvents.get(1), "폐강교과목 공지(1차)", 0, Transparent.TRANSPARENT, false, LocalDateTime.of(2024, 9, 2, 0, 0), - LocalDateTime.of(2024, 9, 3, 0, 0)); + LocalDateTime.of(2024, 9, 2, 23, 59, 59)); } @DisplayName("공휴일 이벤트는 변환에서 제외") @@ -214,4 +215,208 @@ private void assertEventFields(AcademicEvent academicEvent, String summary, Inte () -> assertThat(academicEvent.getEndTime()).isEqualTo(endDate) ); } + + @Test + @DisplayName("DTEND가 없는 종일 일정은 해당 날짜의 23시 59분 59초로 종료 시간을 설정한다") + void convert_all_day_event_without_dtend() { + // given + IcsEvent icsEvent = IcsEvent.builder() + .uid("test-uid") + .summary("개강") + .dtstart("20260831") + .dtend(null) + .build(); + + // when + Optional result = + AcademicEventConverter.convertToAcademicEvent(icsEvent); + + // then + assertThat(result).isPresent(); + + AcademicEvent event = result.get(); + + assertThat(event.getStartTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 31, 0, 0, 0)); + + assertThat(event.getEndTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 31, 23, 59, 59)); + } + + @Test + @DisplayName("DTEND가 없는 일반 일정은 시작 시간과 종료 시간을 동일하게 설정한다") + void convert_non_all_day_event_without_dtend() { + // given + IcsEvent icsEvent = IcsEvent.builder() + .uid("test-uid") + .summary("수강신청") + .dtstart("20260818T093000") + .dtend(null) + .build(); + + // when + Optional result = + AcademicEventConverter.convertToAcademicEvent(icsEvent); + + // then + assertThat(result).isPresent(); + + AcademicEvent event = result.get(); + + assertThat(event.getStartTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 18, 9, 30)); + + assertThat(event.getEndTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 18, 9, 30)); + } + + @Test + @DisplayName("DTSTART가 없으면 변환하지 않는다") + void convert_without_dtstart() { + // given + IcsEvent icsEvent = IcsEvent.builder() + .uid("test-uid") + .summary("개강") + .dtstart(null) + .dtend("20260818") + .build(); + + // when + Optional result = + AcademicEventConverter.convertToAcademicEvent(icsEvent); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("DTSTART와 DTEND의 형식이 다르면 변환하지 않는다") + void convert_different_dtstart_dtend_format() { + // given + IcsEvent icsEvent = IcsEvent.builder() + .uid("test-uid") + .summary("개강") + .dtstart("20260831") + .dtend("20260901T090000") + .build(); + + // when + Optional result = + AcademicEventConverter.convertToAcademicEvent(icsEvent); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("종일 일정은 종료 시간을 1초 앞당겨 변환한다") + void convert_all_day_event() { + // given + IcsEvent icsEvent = IcsEvent.builder() + .uid("test-uid") + .summary("개강") + .dtstart("20260817") + .dtend("20260818") + .build(); + + // when + Optional result = + AcademicEventConverter.convertToAcademicEvent(icsEvent); + + // then + assertThat(result).isPresent(); + + AcademicEvent event = result.get(); + + assertThat(event.getStartTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 17, 0, 0, 0)); + + assertThat(event.getEndTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 17, 23, 59, 59)); + } + + @Test + @DisplayName("종일 일정이 아닌 경우 시작 시간과 종료 시간을 그대로 유지한다") + void convert_non_all_day_event() { + // given + IcsEvent icsEvent = IcsEvent.builder() + .uid("test-uid") + .summary("수강신청") + .dtstart("20260818T093000") + .dtend("20260818T170000") + .build(); + + // when + Optional result = + AcademicEventConverter.convertToAcademicEvent(icsEvent); + + // then + assertThat(result).isPresent(); + + AcademicEvent event = result.get(); + + assertThat(event.getStartTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 18, 9, 30)); + + assertThat(event.getEndTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 18, 17, 0)); + } + + @DisplayName("종일 이벤트의 종료 시간이 시작 시간보다 이전이면 변환에서 제외") + @Test + void convert_exclude_all_day_event_when_end_time_is_before_start_time() { + // given + IcsEvent icsEvent = new IcsEvent( + "test-uid", + "테스트 일정", + "설명", + "20260817", + "20260817", + "PUBLIC", + "0", + "20260817T000000Z", + "TRANSPARENT", + "CONFIRMED", + "0", + "" + ); + + // when + AcademicEvent academicEvent = + AcademicEventConverter.convertToAcademicEvent(icsEvent).orElse(null); + + // then + assertThat(academicEvent).isNull(); + } + + @DisplayName("일반 이벤트의 시작 시간과 종료 시간이 같으면 변환에서 제외") + @Test + void convert_non_all_day_event_with_same_start_and_end_time() { + // given + IcsEvent icsEvent = new IcsEvent( + "test-uid", + "테스트 일정", + "설명", + "20260818T093000", + "20260818T093000", + "PUBLIC", + "0", + "20260818T093000Z", + "TRANSPARENT", + "CONFIRMED", + "0", + "" + ); + + // when + AcademicEvent academicEvent = + AcademicEventConverter.convertToAcademicEvent(icsEvent).orElse(null); + + // then + assertThat(academicEvent).isNotNull(); + assertThat(academicEvent.getStartTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 18, 9, 30)); + assertThat(academicEvent.getEndTime()) + .isEqualTo(LocalDateTime.of(2026, 8, 18, 9, 30)); + } } \ No newline at end of file diff --git a/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventUpdaterTest.java b/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventUpdaterTest.java index 93d7b8e17..052bc110d 100644 --- a/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventUpdaterTest.java +++ b/src/test/java/com/kustacks/kuring/worker/update/calendar/AcademicEventUpdaterTest.java @@ -83,12 +83,12 @@ void should_save_initial_calendar_data_to_db() throws IOException, ParserExcepti // 하계방학 이벤트 검증 assertEventFields(event1, "하계방학", 0, Transparent.TRANSPARENT, false, LocalDateTime.of(2024, 6, 22, 0, 0), - LocalDateTime.of(2024, 9, 2, 0, 0)); + LocalDateTime.of(2024, 9, 1, 23, 59, 59)); // 폐강교과목 공지 이벤트 검증 assertEventFields(event2, "폐강교과목 공지(1차)", 0, Transparent.TRANSPARENT, false, LocalDateTime.of(2024, 9, 2, 0, 0), - LocalDateTime.of(2024, 9, 3, 0, 0)); + LocalDateTime.of(2024, 9, 2, 23, 59, 59)); } @Test @@ -125,17 +125,17 @@ void should_update_existing_and_add_new_events() throws IOException, ParserExcep // 하계방학 이벤트 검증 assertEventFields(event1, "하계방학", 0, Transparent.TRANSPARENT, false, LocalDateTime.of(2024, 6, 22, 0, 0), - LocalDateTime.of(2024, 9, 2, 0, 0)); + LocalDateTime.of(2024, 9, 1, 23, 59, 59)); // 폐강교과목 공지 이벤트 검증 assertEventFields(event2, "폐강교과목 공지(1차)", 1, Transparent.OPAQUE, true, LocalDateTime.of(2024, 9, 3, 0, 0), - LocalDateTime.of(2024, 9, 4, 0, 0)); + LocalDateTime.of(2024, 9, 3, 23, 59, 59)); // 신규 공지 이벤트 검증 assertEventFields(event3, "김한주가넣은가짜학사일정", 0, Transparent.OPAQUE, true, LocalDateTime.of(2028, 2, 29, 0, 0), - LocalDateTime.of(2028, 2, 29, 0, 0)); + LocalDateTime.of(2028, 2, 29, 23, 59, 59)); } @Test diff --git a/src/test/resources/calendar/academic-calendar-updated.ics b/src/test/resources/calendar/academic-calendar-updated.ics index 763ed6f0c..719762d38 100644 --- a/src/test/resources/calendar/academic-calendar-updated.ics +++ b/src/test/resources/calendar/academic-calendar-updated.ics @@ -4980,7 +4980,7 @@ UID:140000008200E00074C5B7101A82E00800000000C1545CD275FFDB01000000000000000 010000000A6974AF4EE07FD42979F2F3DA1D208DC SUMMARY:김한주가넣은가짜학사일정 DTSTART;VALUE=DATE:20280229 -DTEND;VALUE=DATE:20280229 +DTEND;VALUE=DATE:20280301 CLASS:PUBLIC PRIORITY:9 DTSTAMP:20250826T143707Z