From eddcc58c34463628535ea36ed4b6b3e045e8baf1 Mon Sep 17 00:00:00 2001 From: Jeong Grius Date: Wed, 30 Sep 2026 03:58:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Fix:=20=EC=84=9C=EB=B2=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=EB=A5=BC=20Asia/Seoul=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 JVM이 UTC라 생성, 제출 시각이 9시간 이르게 보이고 알림이 의도보다 9시간 늦게 발송되던 문제 수정. 기존 plan 날짜는 운영 기동 시 한 번 자동 보정(백업 plan_backup_20260930), 배포 후 이전 이미지로 롤백 금지 --- .../java/com/mjsec/lms/LmsApplication.java | 8 ++ .../service/AttendanceAlertService.java | 4 +- .../lms/common/config/PlanDateCorrection.java | 71 ++++++++++++ .../mjsec/lms/common/config/TimeZones.java | 17 +++ .../service/ScheduledTaskService.java | 5 +- .../service/WeeklyAlertService.java | 4 +- .../mjsec/lms/LmsApplicationTimeZoneTest.java | 59 ++++++++++ .../AttendanceAlertServiceClockTest.java | 55 +++++++++ .../common/config/PlanDateCorrectionTest.java | 104 ++++++++++++++++++ .../service/ScheduledTaskServiceZoneTest.java | 42 +++++++ .../service/WeeklyAlertServiceClockTest.java | 69 ++++++++++++ 11 files changed, 434 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/mjsec/lms/common/config/PlanDateCorrection.java create mode 100644 src/main/java/com/mjsec/lms/common/config/TimeZones.java create mode 100644 src/test/java/com/mjsec/lms/LmsApplicationTimeZoneTest.java create mode 100644 src/test/java/com/mjsec/lms/attendance/service/AttendanceAlertServiceClockTest.java create mode 100644 src/test/java/com/mjsec/lms/common/config/PlanDateCorrectionTest.java create mode 100644 src/test/java/com/mjsec/lms/notification/service/ScheduledTaskServiceZoneTest.java create mode 100644 src/test/java/com/mjsec/lms/notification/service/WeeklyAlertServiceClockTest.java diff --git a/src/main/java/com/mjsec/lms/LmsApplication.java b/src/main/java/com/mjsec/lms/LmsApplication.java index 8092de0..838bbcb 100644 --- a/src/main/java/com/mjsec/lms/LmsApplication.java +++ b/src/main/java/com/mjsec/lms/LmsApplication.java @@ -1,5 +1,7 @@ package com.mjsec.lms; +import com.mjsec.lms.common.config.TimeZones; +import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -9,7 +11,13 @@ public class LmsApplication { public static void main(String[] args) { + applyDefaultTimeZone(); SpringApplication.run(LmsApplication.class, args); } + // 컨테이너 기본값(UTC)을 따르면 LocalDateTime.now(), 생성 시각이 9시간 어긋나서 서울로 고정함 + static void applyDefaultTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone(TimeZones.SEOUL_ZONE)); + } + } diff --git a/src/main/java/com/mjsec/lms/attendance/service/AttendanceAlertService.java b/src/main/java/com/mjsec/lms/attendance/service/AttendanceAlertService.java index 742b07a..ac8282f 100644 --- a/src/main/java/com/mjsec/lms/attendance/service/AttendanceAlertService.java +++ b/src/main/java/com/mjsec/lms/attendance/service/AttendanceAlertService.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.stream.Collectors; import com.mjsec.lms.notification.external.EmailService; +import com.mjsec.lms.common.config.TimeZones; @Service @Slf4j @@ -36,7 +37,8 @@ public void checkAndSendAbsenceAlert() { log.info("Starting 10-day absence alert check..."); // 정확히 10일 전 날짜 계산 - LocalDate targetDate = LocalDate.now().minusDays(10); + // 출석 날짜가 서울 날짜로 저장되므로 JVM 기본 시간대와 무관하게 서울 기준으로 계산함 + LocalDate targetDate = LocalDate.now(TimeZones.SEOUL_ZONE).minusDays(10); log.info("Checking absences for date: {}", targetDate); diff --git a/src/main/java/com/mjsec/lms/common/config/PlanDateCorrection.java b/src/main/java/com/mjsec/lms/common/config/PlanDateCorrection.java new file mode 100644 index 0000000..ce2c526 --- /dev/null +++ b/src/main/java/com/mjsec/lms/common/config/PlanDateCorrection.java @@ -0,0 +1,71 @@ +package com.mjsec.lms.common.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +// JVM 기본 시간대가 UTC이던 시절 plan의 start_date, end_date는 입력값보다 9시간 늦게 저장됨 +// 현재 운영 DB를 직접 건들 수 없는 상황이라 긴급 패치용으로 기동 시 한 번만 9시간 당김 +// 웹 서버가 요청을 받기 전에 돌아야 새 버전이 쓴 행까지 당기지 않음 +@Slf4j +@Component +@Profile("prod") +public class PlanDateCorrection implements SmartInitializingSingleton { + + static final String MARKER_TABLE = "data_correction"; + static final String MARKER_NAME = "2026-09-30-plan-date-kst"; + static final String BACKUP_TABLE = "plan_backup_20260930"; + + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + + public PlanDateCorrection(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + @Override + public void afterSingletonsInstantiated() { + jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS " + MARKER_TABLE + + " (name VARCHAR(100) PRIMARY KEY, applied_at DATETIME(6) NOT NULL)"); + + if (isApplied()) { + log.info("Plan date correction already applied, skipping: {}", MARKER_NAME); + return; + } + + try { + // 표식이 없으면 보정이 커밋된 적이 없으므로 이전 시도의 백업은 버리고 지금 상태로 다시 뜸 + // DDL은 MySQL에서 암묵적으로 커밋되므로 트랜잭션 밖에서 함 + jdbcTemplate.execute("DROP TABLE IF EXISTS " + BACKUP_TABLE); + jdbcTemplate.execute("CREATE TABLE " + BACKUP_TABLE + " AS SELECT * FROM plan"); + + Integer shifted = transactionTemplate.execute(status -> { + // 인스턴스가 혹시 두 개 겹쳐 떠도 표식을 먼저 넣은 쪽만 UPDATE까지 감 + jdbcTemplate.update("INSERT INTO " + MARKER_TABLE + " (name, applied_at) VALUES (?, NOW(6))", + MARKER_NAME); + return jdbcTemplate.update("UPDATE plan SET start_date = start_date - INTERVAL 9 HOUR, " + + "end_date = end_date - INTERVAL 9 HOUR"); + }); + log.info("Plan date correction applied: {} rows shifted by -9 hours, backup table {}", + shifted, BACKUP_TABLE); + } catch (DuplicateKeyException e) { + log.info("Plan date correction already applied by another instance"); + } catch (RuntimeException e) { + // 틀린 마감 시각으로 서비스하지 않도록 기동을 멈춤, 표식과 UPDATE는 함께 롤백됨 + log.error("Plan date correction failed, application startup aborted", e); + throw e; + } + } + + private boolean isApplied() { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + MARKER_TABLE + " WHERE name = ?", Integer.class, MARKER_NAME); + return count != null && count > 0; + } +} diff --git a/src/main/java/com/mjsec/lms/common/config/TimeZones.java b/src/main/java/com/mjsec/lms/common/config/TimeZones.java new file mode 100644 index 0000000..ac6d0ac --- /dev/null +++ b/src/main/java/com/mjsec/lms/common/config/TimeZones.java @@ -0,0 +1,17 @@ +package com.mjsec.lms.common.config; + +import java.time.ZoneId; + +// 서비스 기준 시간대, 시각을 다루는 코드는 JVM 기본값 대신 이 값을 씀 +// application.yml의 hibernate.jdbc.time_zone과 같아야 LocalDateTime이 저장될 때 밀리지 않음 +public final class TimeZones { + + // @Scheduled(zone = ...)처럼 컴파일 타임 상수가 필요한 곳에서 씀 + public static final String SEOUL = "Asia/Seoul"; + + public static final ZoneId SEOUL_ZONE = ZoneId.of(SEOUL); + + private TimeZones() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/src/main/java/com/mjsec/lms/notification/service/ScheduledTaskService.java b/src/main/java/com/mjsec/lms/notification/service/ScheduledTaskService.java index 9691787..cc1722f 100644 --- a/src/main/java/com/mjsec/lms/notification/service/ScheduledTaskService.java +++ b/src/main/java/com/mjsec/lms/notification/service/ScheduledTaskService.java @@ -4,6 +4,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import com.mjsec.lms.attendance.service.AttendanceAlertService; +import com.mjsec.lms.common.config.TimeZones; @Service @Slf4j @@ -20,7 +21,7 @@ public class ScheduledTaskService { } //매주 월요일 0시에 체크 - @Scheduled(cron = "0 0 0 * * MON") + @Scheduled(cron = "0 0 0 * * MON", zone = TimeZones.SEOUL) public void checkAssignmentNotSubmitted() { log.info("Scheduled task started: checkAssignmentNotSubmitted"); try { @@ -32,7 +33,7 @@ public void checkAssignmentNotSubmitted() { } // 매일 오전 9시에 10일 경과 결석자 체크 - @Scheduled(cron = "0 0 9 * * *") + @Scheduled(cron = "0 0 9 * * *", zone = TimeZones.SEOUL) public void checkAbsenceAlert() { log.info("Scheduled task started: checkAbsenceAlert"); try { diff --git a/src/main/java/com/mjsec/lms/notification/service/WeeklyAlertService.java b/src/main/java/com/mjsec/lms/notification/service/WeeklyAlertService.java index 01d070e..e25f75c 100644 --- a/src/main/java/com/mjsec/lms/notification/service/WeeklyAlertService.java +++ b/src/main/java/com/mjsec/lms/notification/service/WeeklyAlertService.java @@ -16,6 +16,7 @@ import java.util.*; import java.util.stream.Collectors; import com.mjsec.lms.notification.external.EmailService; +import com.mjsec.lms.common.config.TimeZones; @Service @Slf4j @@ -41,7 +42,8 @@ public WeeklyAlertService(PlanRepository planRepository, public void checkAndSendWeeklyAssignmentReport() { log.info("Starting weekly assignment report generation..."); - LocalDateTime now = LocalDateTime.now(); + // 마감 시각이 서울 시각으로 저장되므로 JVM 기본 시간대와 무관하게 서울 기준으로 조회함 + LocalDateTime now = LocalDateTime.now(TimeZones.SEOUL_ZONE); LocalDateTime oneWeekAgo = now.minusWeeks(1); // hasAssignment=true이고 최근 1주일간 마감된 과제만 조회 diff --git a/src/test/java/com/mjsec/lms/LmsApplicationTimeZoneTest.java b/src/test/java/com/mjsec/lms/LmsApplicationTimeZoneTest.java new file mode 100644 index 0000000..5814eab --- /dev/null +++ b/src/test/java/com/mjsec/lms/LmsApplicationTimeZoneTest.java @@ -0,0 +1,59 @@ +package com.mjsec.lms; + +import java.util.TimeZone; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.boot.SpringApplication; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; + +@DisplayName("서버 기본 시간대 설정 테스트") +class LmsApplicationTimeZoneTest { + + private TimeZone originalTimeZone; + + @BeforeEach + void setUp() { + originalTimeZone = TimeZone.getDefault(); + // 운영 컨테이너처럼 JVM 기본 시간대가 UTC인 상황을 만든다 + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @AfterEach + void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + + @Test + @DisplayName("JVM 기본 시간대가 UTC여도 Asia/Seoul로 맞춘다") + void setsDefaultTimeZoneToSeoul() { + LmsApplication.applyDefaultTimeZone(); + + assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Seoul"); + } + + @Test + @DisplayName("main()은 스프링을 띄우기 전에 시간대를 서울로 맞춘다") + void mainAppliesSeoulBeforeSpringStarts() { + AtomicReference zoneAtRun = new AtomicReference<>(); + + try (MockedStatic spring = mockStatic(SpringApplication.class)) { + spring.when(() -> SpringApplication.run(eq(LmsApplication.class), any(String[].class))) + .thenAnswer(invocation -> { + zoneAtRun.set(TimeZone.getDefault().getID()); + return null; + }); + + LmsApplication.main(new String[0]); + } + + assertThat(zoneAtRun.get()).isEqualTo("Asia/Seoul"); + } +} diff --git a/src/test/java/com/mjsec/lms/attendance/service/AttendanceAlertServiceClockTest.java b/src/test/java/com/mjsec/lms/attendance/service/AttendanceAlertServiceClockTest.java new file mode 100644 index 0000000..1ae4ae4 --- /dev/null +++ b/src/test/java/com/mjsec/lms/attendance/service/AttendanceAlertServiceClockTest.java @@ -0,0 +1,55 @@ +package com.mjsec.lms.attendance.service; + +import com.mjsec.lms.attendance.repository.AttendanceRepository; +import com.mjsec.lms.notification.external.EmailService; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.TimeZone; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("결석 알림 조회 기준 날짜 테스트") +class AttendanceAlertServiceClockTest { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + @Mock + private AttendanceRepository attendanceRepository; + @Mock + private EmailService emailService; + + private TimeZone originalTimeZone; + + @BeforeEach + void setUp() { + originalTimeZone = TimeZone.getDefault(); + // main()의 기본 시간대 고정이 빠진 상황을 만든다 + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @AfterEach + void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + + @Test + @DisplayName("JVM 기본 시간대가 UTC여도 서울 날짜 기준 10일 전을 조회한다") + void absenceAlertUsesSeoulDate() { + when(attendanceRepository.findByAttendanceDate(any())).thenReturn(List.of()); + AttendanceAlertService service = new AttendanceAlertService(attendanceRepository, emailService); + + service.checkAndSendAbsenceAlert(); + + // 서울과 UTC 날짜가 다른 시간대(00~09시 KST)에만 수정 전 코드와 구별된다 + verify(attendanceRepository).findByAttendanceDate(LocalDate.now(SEOUL).minusDays(10)); + } +} diff --git a/src/test/java/com/mjsec/lms/common/config/PlanDateCorrectionTest.java b/src/test/java/com/mjsec/lms/common/config/PlanDateCorrectionTest.java new file mode 100644 index 0000000..a11094e --- /dev/null +++ b/src/test/java/com/mjsec/lms/common/config/PlanDateCorrectionTest.java @@ -0,0 +1,104 @@ +package com.mjsec.lms.common.config; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +// 실제 MySQL이 필요함 (CI는 docker-compose의 db를 씀) +// 보정 UPDATE는 plan 전체를 당기므로 테스트 행 말고 다른 행이 있는 DB에서는 건너뜀 +// prod 프로필로 뜨면 컨텍스트 기동 때 실제 보정이 돌아서 local로 고정함 +@SpringBootTest +@ActiveProfiles("local") +@DisplayName("기존 계획 날짜 9시간 보정 테스트") +class PlanDateCorrectionTest { + + private static final String TITLE = "plan-date-correction-test"; + + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired + private ApplicationContext applicationContext; + + @BeforeEach + void setUp() { + Integer otherPlans = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM plan WHERE title <> ?", Integer.class, TITLE); + assumeTrue(otherPlans != null && otherPlans == 0, "다른 계획 데이터가 있는 DB라 보정 테스트를 건너뜀"); + cleanUp(); + // 옛 버전(JVM UTC)이 09:00~23:59 입력을 저장한 모양 + jdbcTemplate.update( + "INSERT INTO plan (title, content, has_assignment, start_date, end_date) VALUES (?, 'c', 1, ?, ?)", + TITLE, LocalDateTime.of(2026, 10, 1, 18, 0), LocalDateTime.of(2026, 10, 2, 8, 59)); + jdbcTemplate.update( + "INSERT INTO plan (title, content, has_assignment, start_date, end_date) VALUES (?, 'c', 0, NULL, NULL)", + TITLE); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + private void cleanUp() { + jdbcTemplate.update("DELETE FROM plan WHERE title = ?", TITLE); + jdbcTemplate.execute("DROP TABLE IF EXISTS " + PlanDateCorrection.BACKUP_TABLE); + jdbcTemplate.execute("DROP TABLE IF EXISTS " + PlanDateCorrection.MARKER_TABLE); + } + + private PlanDateCorrection newCorrection() { + return new PlanDateCorrection(jdbcTemplate, transactionManager); + } + + private List column(String table, String column) { + return jdbcTemplate.queryForList( + "SELECT CAST(" + column + " AS CHAR) FROM " + table + " WHERE title = ? ORDER BY plan_id", + String.class, TITLE); + } + + @Test + @DisplayName("기존 계획의 시작일과 마감일을 9시간 당기고 NULL은 그대로 둔다") + void shiftsExistingPlanDates() { + newCorrection().afterSingletonsInstantiated(); + + assertThat(column("plan", "start_date")).containsExactly("2026-10-01 09:00:00.000000", null); + assertThat(column("plan", "end_date")).containsExactly("2026-10-01 23:59:00.000000", null); + } + + @Test + @DisplayName("보정 전 값을 백업 테이블에 남긴다") + void backsUpBeforeShifting() { + newCorrection().afterSingletonsInstantiated(); + + assertThat(column(PlanDateCorrection.BACKUP_TABLE, "end_date")) + .containsExactly("2026-10-02 08:59:00.000000", null); + } + + @Test + @DisplayName("다시 기동해도 한 번만 보정한다") + void runsOnlyOnce() { + newCorrection().afterSingletonsInstantiated(); + newCorrection().afterSingletonsInstantiated(); + + assertThat(column("plan", "end_date")).containsExactly("2026-10-01 23:59:00.000000", null); + } + + @Test + @DisplayName("prod 프로필이 아니면 보정 빈이 등록되지 않는다") + void notRegisteredOutsideProd() { + assertThat(applicationContext.getBeanNamesForType(PlanDateCorrection.class)).isEmpty(); + } +} diff --git a/src/test/java/com/mjsec/lms/notification/service/ScheduledTaskServiceZoneTest.java b/src/test/java/com/mjsec/lms/notification/service/ScheduledTaskServiceZoneTest.java new file mode 100644 index 0000000..276d750 --- /dev/null +++ b/src/test/java/com/mjsec/lms/notification/service/ScheduledTaskServiceZoneTest.java @@ -0,0 +1,42 @@ +package com.mjsec.lms.notification.service; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; + +import static org.assertj.core.api.Assertions.*; + +@DisplayName("알림 스케줄 시간대 테스트") +class ScheduledTaskServiceZoneTest { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + @ParameterizedTest + @DisplayName("알림 cron은 JVM 기본 시간대가 아니라 서울 시각으로 해석된다") + @CsvSource({ + // 메서드, 기준 시각(UTC), 다음 발송 시각(서울) + // UTC로 해석하면 각각 10-05 09:00, 10-01 18:00(서울)에 발송됨 + "checkAssignmentNotSubmitted, 2026-09-30T01:00, 2026-10-05T00:00", + "checkAbsenceAlert, 2026-09-30T01:00, 2026-10-01T09:00" + }) + void cronUsesSeoulZone(String methodName, String baseUtc, String expectedNextSeoul) throws Exception { + Method method = ScheduledTaskService.class.getMethod(methodName); + Scheduled scheduled = method.getAnnotation(Scheduled.class); + + assertThat(scheduled.zone()).isEqualTo("Asia/Seoul"); + + ZonedDateTime base = LocalDateTime.parse(baseUtc).atZone(ZoneOffset.UTC) + .withZoneSameInstant(ZoneId.of(scheduled.zone())); + ZonedDateTime next = CronExpression.parse(scheduled.cron()).next(base); + + assertThat(next.withZoneSameInstant(SEOUL).toLocalDateTime()) + .isEqualTo(LocalDateTime.parse(expectedNextSeoul)); + } +} diff --git a/src/test/java/com/mjsec/lms/notification/service/WeeklyAlertServiceClockTest.java b/src/test/java/com/mjsec/lms/notification/service/WeeklyAlertServiceClockTest.java new file mode 100644 index 0000000..11f8019 --- /dev/null +++ b/src/test/java/com/mjsec/lms/notification/service/WeeklyAlertServiceClockTest.java @@ -0,0 +1,69 @@ +package com.mjsec.lms.notification.service; + +import com.mjsec.lms.assignment.repository.PlanRepository; +import com.mjsec.lms.assignment.repository.SubmissionRepository; +import com.mjsec.lms.notification.external.EmailService; +import com.mjsec.lms.studygroup.repository.GroupMemberRepository; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.TimeZone; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("주간 리포트 조회 기준 시각 테스트") +class WeeklyAlertServiceClockTest { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + @Mock + private PlanRepository planRepository; + @Mock + private SubmissionRepository submissionRepository; + @Mock + private GroupMemberRepository groupMemberRepository; + @Mock + private EmailService emailService; + + private TimeZone originalTimeZone; + + @BeforeEach + void setUp() { + originalTimeZone = TimeZone.getDefault(); + // main()의 기본 시간대 고정이 빠진 상황을 만든다 + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @AfterEach + void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + + @Test + @DisplayName("JVM 기본 시간대가 UTC여도 서울 시각 기준 지난 1주를 조회한다") + void weeklyReportUsesSeoulNow() { + when(planRepository.findAssignmentsExpiredBetween(any(), any())).thenReturn(List.of()); + WeeklyAlertService service = new WeeklyAlertService( + planRepository, submissionRepository, groupMemberRepository, emailService); + + service.checkAndSendWeeklyAssignmentReport(); + + ArgumentCaptor start = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor end = ArgumentCaptor.forClass(LocalDateTime.class); + verify(planRepository).findAssignmentsExpiredBetween(start.capture(), end.capture()); + assertThat(Duration.between(LocalDateTime.now(SEOUL), end.getValue()).abs()) + .isLessThan(Duration.ofMinutes(1)); + assertThat(start.getValue()).isEqualTo(end.getValue().minusWeeks(1)); + } +} From 23a52b2081a6409eb5e6f6ac3c42f3e429c0d647 Mon Sep 17 00:00:00 2001 From: Jeong Grius Date: Wed, 30 Sep 2026 03:58:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20=EA=B3=BC=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C=20=ED=97=88=EC=9A=A9=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github.io, github.com, notion.site, notion.so, oopy.io, medium.com, brunch.co.kr, blogspot.com, gitbook.io 추가 --- .../lms/common/util/ValidationUtils.java | 11 +- .../ValidationUtilsSubmissionDomainTest.java | 120 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/mjsec/lms/common/util/ValidationUtilsSubmissionDomainTest.java diff --git a/src/main/java/com/mjsec/lms/common/util/ValidationUtils.java b/src/main/java/com/mjsec/lms/common/util/ValidationUtils.java index 79400e8..c3e1b1e 100644 --- a/src/main/java/com/mjsec/lms/common/util/ValidationUtils.java +++ b/src/main/java/com/mjsec/lms/common/util/ValidationUtils.java @@ -40,7 +40,16 @@ public class ValidationUtils { private static final String[] ALLOWED_DOMAINS = { "velog.io", "tistory.com", - "blog.naver.com" + "blog.naver.com", + "github.io", + "github.com", + "notion.site", + "notion.so", + "oopy.io", + "medium.com", + "brunch.co.kr", + "blogspot.com", + "gitbook.io" }; private static final String[] XSS_PATTERNS = { diff --git a/src/test/java/com/mjsec/lms/common/util/ValidationUtilsSubmissionDomainTest.java b/src/test/java/com/mjsec/lms/common/util/ValidationUtilsSubmissionDomainTest.java new file mode 100644 index 0000000..21634ab --- /dev/null +++ b/src/test/java/com/mjsec/lms/common/util/ValidationUtilsSubmissionDomainTest.java @@ -0,0 +1,120 @@ +package com.mjsec.lms.common.util; + +import com.mjsec.lms.assignment.repository.PlanCommentRepository; +import com.mjsec.lms.assignment.repository.PlanRepository; +import com.mjsec.lms.assignment.repository.SubmissionRepository; +import com.mjsec.lms.attendance.repository.AttendanceRepository; +import com.mjsec.lms.common.exception.RestApiException; +import com.mjsec.lms.common.type.ErrorCode; +import com.mjsec.lms.studygroup.repository.GroupMemberRepository; +import com.mjsec.lms.studygroup.repository.StudyActivityRepository; +import com.mjsec.lms.studygroup.repository.StudyGroupRepository; +import com.mjsec.lms.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("과제 제출 블로그 도메인 검증 테스트") +class ValidationUtilsSubmissionDomainTest { + + @Mock + private UserRepository userRepository; + @Mock + private StudyGroupRepository studyGroupRepository; + @Mock + private GroupMemberRepository groupMemberRepository; + @Mock + private AttendanceRepository attendanceRepository; + @Mock + private PlanRepository planRepository; + @Mock + private SubmissionRepository submissionRepository; + @Mock + private PlanCommentRepository planCommentRepository; + @Mock + private StudyActivityRepository studyActivityRepository; + + private ValidationUtils validationUtils; + + @BeforeEach + void setUp() { + validationUtils = new ValidationUtils( + userRepository, + studyGroupRepository, + groupMemberRepository, + attendanceRepository, + planRepository, + submissionRepository, + planCommentRepository, + studyActivityRepository + ); + } + + @ParameterizedTest + @DisplayName("기존 허용 도메인은 계속 통과한다") + @ValueSource(strings = { + "https://velog.io/@mjsec/week1", + "https://mjsec.tistory.com/12", + "https://blog.naver.com/mjsec/223000000000", + "https://m.blog.naver.com/mjsec/223000000000" + }) + void existingDomainsStillAllowed(String url) { + assertThatCode(() -> validationUtils.validateSubmissionContent(url)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @DisplayName("새로 추가한 블로그 도메인은 통과한다") + @ValueSource(strings = { + "https://mjsec.github.io/posts/week1", + "https://github.com/mjsec/writeups/blob/main/week1.md", + "https://mjsec.notion.site/week1-abc123", + "https://www.notion.so/mjsec/week1-abc123", + "https://mjsec.oopy.io/week1", + "https://medium.com/@mjsec/week1-abc123", + "https://brunch.co.kr/@mjsec/1", + "https://mjsec.blogspot.com/2026/09/week1.html", + "https://mjsec.gitbook.io/writeups/week1", + "https://MJSEC.GITHUB.IO/week1", + "https://mjsec.github.io" + }) + void addedDomainsAllowed(String url) { + assertThatCode(() -> validationUtils.validateSubmissionContent(url)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @DisplayName("목록에 없는 도메인과 이름만 비슷한 도메인은 거절한다") + @ValueSource(strings = { + "https://example.com/week1", + "https://evilgithub.io/week1", + "https://github.io.evil.com/week1", + "https://notvelog.io/@mjsec/week1", + "https://github.com@evil.com/week1" + }) + void unknownDomainsRejected(String url) { + assertThatThrownBy(() -> validationUtils.validateSubmissionContent(url)) + .isInstanceOf(RestApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.UNAUTHORIZED_DOMAIN); + } + + @Test + @DisplayName("여러 링크 중 하나라도 허용되지 않으면 거절한다") + void rejectsWhenAnyLinkIsNotAllowed() { + String content = "https://mjsec.github.io/week1 https://example.com/week1"; + + assertThatThrownBy(() -> validationUtils.validateSubmissionContent(content)) + .isInstanceOf(RestApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.UNAUTHORIZED_DOMAIN); + } +}