From cf76def8e0f8ca35a0419134638f687f00862a8f Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 19:40:20 +0900 Subject: [PATCH] =?UTF-8?q?[Fix]=20=EC=9A=94=EC=9D=BC=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EC=A3=BC=EA=B0=84=20=EB=B3=B5=EC=8A=B5=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=95=B1=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #302 로 들어간 검증이 스토어에 나가 있는 구버전 앱 사용자를 막았다. 구버전 앱에는 요일을 고르라는 클라이언트 검증이 없어서 "매주" 만 고른 저장이 그대로 올라오고, 예전 서버는 그 요청을 매일 크론으로 받아 줬다. 그래서 요일이 빈 주간 알림 행을 가진 복습 세트는 제목만 바꿔도 계속 400 이 나 수정할 방법이 없었다 - PracticeNotificationWeekDayPolicy 를 새로 두고 X-App-Version 이 기준 버전(ono.practice-note.week-days-required-version, 기본 4.0.0) 이상인 요청만 400/6003 으로 막는다. 헤더가 없거나 읽을 수 없는 값이면 구버전으로 본다 - 미션의 LegacyAccrualPolicy 는 읽기만 하고 그대로 쓰지 않았다. 거기 붙은 비상 스위치(ono.mission.legacy-accrual.enabled)를 내리면 복습 알림 검증까지 같이 움직이기 때문이다. 공용으로 뺀 것은 AppVersionResolver.isAtLeast 의 버전 비교까지다 - PracticeNotificationScheduler.convertDtoToCron 의 매일 폴백을 되살렸다. 구버전 요청은 예전 서버와 같게 매일로 저장된다. 스케줄러에 있던 검증은 뺐다. 진입부인 PracticeNoteService 에서 이미 걸러지고, 거기서 막아야 Quartz 잡 삭제가 일어나지 않는다 - POST(registerPractice)와 PATCH(updatePracticeInfo) 두 경로 모두에 적용했다 Closes #317 --- .../common/web/AppVersionResolver.java | 17 ++ .../dto/PracticeNotificationRegisterDto.java | 5 +- .../service/PracticeNoteService.java | 22 ++- .../PracticeNotificationScheduler.java | 31 +--- .../PracticeNotificationWeekDayPolicy.java | 55 ++++++ .../common/web/AppVersionResolverTest.java | 41 +++++ .../PracticeNoteIntegrationTest.java | 174 +++++++++++++----- .../PracticeNotificationSchedulerTest.java | 57 ++---- ...PracticeNotificationWeekDayPolicyTest.java | 74 ++++++++ 9 files changed, 366 insertions(+), 110 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java create mode 100644 src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java diff --git a/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java index 48da86c0..b9ab8d99 100644 --- a/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java +++ b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java @@ -44,4 +44,21 @@ public Optional resolve() { HttpServletRequest request = servletAttributes.getRequest(); return AppVersion.parse(request.getHeader(APP_VERSION_HEADER)); } + + /** + * 이번 요청이 {@code rawThreshold} 와 같거나 높은 버전의 앱에서 왔는가. + * + *

모르면 아니라고 답한다. 헤더가 없거나, 읽을 수 없는 값이거나, 애초에 HTTP 요청이 아닌 + * 자리에서 불렸으면 전부 구버전으로 본다. 기준값을 읽지 못했을 때도 같다. 설정 오타 하나로 + * 모든 요청이 갑자기 신버전 취급을 받는 것보다, 아무도 신버전이 아닌 쪽이 되돌리기 쉽다. + * + *

버전으로 동작을 가르는 곳이 늘어날 때 이 판정을 각자 들고 있으면 "모르면 구버전" 이라는 + * 규칙이 곳곳에서 조금씩 달라진다. 비교만 여기에 두고, 기준 버전과 그래서 무엇이 달라지는가는 + * 각 도메인이 정한다. 그래야 한 도메인의 설정이 다른 도메인의 동작을 끌고 가지 않는다. + */ + public boolean isAtLeast(String rawThreshold) { + return AppVersion.parse(rawThreshold) + .flatMap(threshold -> resolve().map(requested -> requested.isAtLeast(threshold))) + .orElse(false); + } } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java index b2390956..1a54c006 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java @@ -15,8 +15,9 @@ public record PracticeNotificationRegisterDto( /** * 주간 반복인데 요일을 하나도 고르지 않은 상태인지 확인한다. * - *

예전에는 이 상태가 크론 변환에서 매일 발송으로 되돌아갔다. 사용자는 특정 요일만 - * 고른 줄 알면서 매일 알림을 받았고, 요청이 잘못됐다는 신호도 없었다. + *

이 상태는 크론 변환에서 매일 발송으로 되돌아간다. 사용자는 특정 요일만 고른 줄 알면서 + * 매일 알림을 받는다. 그래서 신버전 앱 요청은 진입부에서 400 으로 막는다. 요일을 고르라는 + * 검증이 없는 구버전 앱 요청은 예전 서버와 같게 매일로 저장한다. */ public boolean isWeeklyWithoutWeekDays() { return WEEKLY.equalsIgnoreCase(repeatType) && (weekDays == null || weekDays.isEmpty()); diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index a01b2cc8..eed1b83c 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -60,6 +60,8 @@ public class PracticeNoteService { private final CustomEmojiValidator customEmojiValidator; + private final PracticeNotificationWeekDayPolicy weekDayPolicy; + private PracticeNote getPracticeEntity(Long practiceId, Long userId){ PracticeNote practiceNote = practiceNoteRepository.findById(practiceId) @@ -214,16 +216,26 @@ private void deletePracticeWithoutOwnerCheck(Long practiceId) { } /** - * 주간 반복 알림에 요일이 하나도 없으면 400 으로 거절한다. + * 주간 반복 알림에 요일이 하나도 없으면 신버전 앱 요청만 400 으로 거절한다. + * + *

이 검증이 요청 진입부에 있는 이유는 복습노트 저장이나 기존 Quartz 잡 삭제가 아예 일어나지 + * 않아야 하기 때문이다. Quartz 잡 삭제는 이 트랜잭션과 함께 롤백되지 않는다. * - *

스케줄러도 같은 검증을 하지만, 요청을 받은 자리에서 먼저 막아야 복습노트 저장이나 - * 기존 Quartz 잡 삭제가 아예 일어나지 않는다. Quartz 잡 삭제는 이 트랜잭션과 함께 - * 롤백되지 않기 때문이다. + *

구버전 요청은 예전처럼 통과시킨다. 요일이 빈 주간 반복은 스케줄러의 크론 변환에서 + * 매일 발송으로 저장된다. 구버전 앱에는 요일을 고르라는 검증이 없어서, 여기서 막으면 + * 그 사용자는 복습 세트를 영영 수정할 수 없다. 판정 기준은 + * {@link PracticeNotificationWeekDayPolicy} 한 곳에 있다. */ private void validatePracticeNotification(PracticeNotificationRegisterDto practiceNotification) { - if (practiceNotification != null && practiceNotification.isWeeklyWithoutWeekDays()) { + if (practiceNotification == null || !practiceNotification.isWeeklyWithoutWeekDays()) { + return; + } + + if (weekDayPolicy.requiresWeekDays()) { throw new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); } + + log.info("요일 없는 주간 반복 알림을 구버전 앱 요청으로 보고 매일 발송으로 저장한다"); } private List nullSafe(List values) { diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java index 82822261..dcec1af0 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java @@ -1,9 +1,7 @@ package com.aisip.OnO.backend.practicenote.service; -import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.practicenote.dto.PracticeNotificationRegisterDto; -import com.aisip.OnO.backend.practicenote.exception.PracticeNoteErrorCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.quartz.*; @@ -16,8 +14,6 @@ public class PracticeNotificationScheduler { private final Scheduler scheduler; public void schedulePracticeNotification(Long userId, Long practiceId, String practiceTitle, PracticeNotificationRegisterDto dto) { - validateNotification(dto); - try { JobDetail jobDetail = JobBuilder.newJob(PracticeNotificationJob.class) .withIdentity("practice-" + practiceId, "practice-reminder") @@ -44,10 +40,6 @@ public void schedulePracticeNotification(Long userId, Long practiceId, String pr } public void updateNotification(Long userId, Long practiceId, String title, PracticeNotificationRegisterDto dto) { - // 잡을 지운 뒤에 검증에 걸리면 기존 알림만 사라진다. Quartz 잡 삭제는 서비스 트랜잭션과 - // 함께 롤백되지 않으므로, 지우기 전에 먼저 막는다. - validateNotification(dto); - deleteNotification(practiceId); schedulePracticeNotification(userId, practiceId, title, dto); } @@ -61,18 +53,6 @@ public void deleteNotification(Long practiceId) { } } - /** - * 주간 반복인데 요일이 비어 있으면 거절한다. - * - *

예전에는 이 요청이 아래 크론 변환의 매일 폴백으로 흘러가, 사용자가 고르지도 않은 - * 매일 알림이 등록됐다. 잘못된 요청이라는 신호 없이 동작만 달라지는 쪽이 더 나쁘다. - */ - private void validateNotification(PracticeNotificationRegisterDto dto) { - if (dto.isWeeklyWithoutWeekDays()) { - throw new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); - } - } - private String convertDtoToCron(PracticeNotificationRegisterDto dto) { int hour = dto.hour(); int minute = dto.minute(); @@ -80,9 +60,8 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { if ("daily".equalsIgnoreCase(dto.repeatType())) { // 매일 지정된 시각에 실행 return String.format("0 %d %d ? * *", minute, hour); - } else if ("weekly".equalsIgnoreCase(dto.repeatType())) { + } else if ("weekly".equalsIgnoreCase(dto.repeatType()) && !dto.isWeeklyWithoutWeekDays()) { // 선택한 요일에만 지정된 시각에 실행 (e.g. MON,WED,FRI) - // 요일이 비어 있는 경우는 validateNotification 이 이미 걸러 냈다. String dayString = dto.weekDays().stream() .map(this::convertDayToQuartz) .reduce((a, b) -> a + "," + b) @@ -91,8 +70,12 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { return String.format("0 %d %d ? * %s", minute, hour, dayString); } - // daily/weekly 가 아닌 값(null 포함)은 지금처럼 매일로 둔다. - // 구버전 앱이 repeatType 을 비워 보내는 경우까지 여기서 막으면 기존 알림이 통째로 끊긴다. + // 매일 폴백. 여기로 오는 경우는 둘이다. + // 1) daily/weekly 가 아닌 값(null 포함). 구버전 앱이 repeatType 을 비워 보낸다. + // 2) 주간 반복인데 요일이 비어 있는 구버전 앱 요청. 신버전 요청은 진입부인 + // PracticeNoteService 에서 이미 400 으로 걸러지고 여기까지 오지 않는다. + // 구버전 앱에는 요일을 고르라는 검증이 없어서, 여기서 막으면 그 사용자는 + // 복습 세트를 저장할 수도 수정할 수도 없다. 예전 서버와 같게 매일로 저장한다. return String.format("0 %d %d ? * *", minute, hour); } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java new file mode 100644 index 00000000..a50c06dc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java @@ -0,0 +1,55 @@ +package com.aisip.OnO.backend.practicenote.service; + +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 주간 반복 알림에 요일을 강제할 요청인지 정하는 한 곳. + * + *

요일 없는 주간 반복을 400 으로 막는 검증(#302)이 구버전 앱 사용자를 가뒀다. + * 구버전 앱에는 요일을 고르라는 클라이언트 검증이 없어서 "매주" 만 고른 저장이 그대로 올라오고, + * 예전 서버는 그 요청을 매일 크론으로 받아 줬다. 그래서 운영 DB 에 요일이 빈 주간 알림 행이 이미 있고, + * 그 복습 세트를 연 구버전 사용자는 제목만 바꿔도 계속 400 을 받는다. 앱을 올리기 전에는 빠져나갈 길이 없다. + * + *

그래서 요청이 온 앱 버전으로 가른다. 기준 버전 이상에서 온 요청만 막고, 그 아래는 예전처럼 + * 매일 크론으로 저장한다. 프론트는 신버전에서 요일을 강제하므로 신버전 사용자는 이 조합을 만들 수 없다. + * + *

미션의 {@code LegacyAccrualPolicy} 를 그대로 쓰지 않은 이유. 거기에는 자동 적립을 통째로 + * 멈추는 비상 스위치({@code ono.mission.legacy-accrual.enabled})가 붙어 있다. 그 스위치를 내렸을 때 + * 복습 알림 검증까지 같이 움직이면, XP 사고를 막으려고 끈 설정이 알림 저장 동작을 조용히 바꾼다. + * 공용으로 두는 것은 {@link AppVersionResolver#isAtLeast(String)} 의 버전 비교까지다. + */ +@Component +@RequiredArgsConstructor +public class PracticeNotificationWeekDayPolicy { + + private final AppVersionResolver appVersionResolver; + + /** + * 요일 없는 주간 반복을 거절하기 시작하는 첫 앱 버전. + * + *

기본값이 {@code 4.0.0} 인 근거는 헤더 자체가 이 버전 라인에서 처음 붙는다 는 것이다 + * (AI-SIP/OnO_FRONT#214, 프론트 {@code pubspec.yaml} 이 {@code 4.0.0+70}). 헤더를 안 보내는 + * 스토어 빌드는 기준값이 무엇이든 구버전으로 떨어지므로, 이 값은 헤더를 보내는 앱 중 + * 어디까지를 새 앱으로 볼지만 가른다. 헤더를 붙인 커밋과 요일을 강제하는 클라이언트 검증은 + * 같은 미출시 빌드에 들어 있다. + * + *

설정값으로 둔 이유는 요일 강제가 빠진 빌드가 뒤늦게 드러났을 때 배포 없이 기준을 올려 + * 되돌리기 위해서다. + */ + @Value("${ono.practice-note.week-days-required-version:4.0.0}") + private String weekDaysRequiredVersion; + + /** + * 이번 요청에서 주간 반복에 요일을 강제하는가. + * + *

모르면 강제하지 않는다. 헤더가 없는 요청은 구버전이다. 잘못 보면 신버전 사용자가 + * 요일 없는 주간 알림을 하나 저장할 뿐이지만, 반대로 틀리면 구버전 사용자는 복습 세트를 + * 영영 수정할 수 없다. + */ + public boolean requiresWeekDays() { + return appVersionResolver.isAtLeast(weekDaysRequiredVersion); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java index d03dd724..b206434a 100644 --- a/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java +++ b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java @@ -79,6 +79,47 @@ void emptyOnThreadWithoutInheritedContext() throws Exception { } } + @Test + @DisplayName("기준 버전과 같거나 높으면 참이다") + void isAtLeastWhenHeaderMeetsThreshold() { + bindRequestWithHeader("4.0.0+70"); + + assertThat(resolver.isAtLeast("4.0.0")).isTrue(); + assertThat(resolver.isAtLeast("3.9.9")).isTrue(); + } + + @Test + @DisplayName("기준 버전보다 낮으면 거짓이다") + void isAtLeastFalseBelowThreshold() { + bindRequestWithHeader("3.6.0+67"); + + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + } + + @Test + @DisplayName("헤더가 없거나 읽을 수 없거나 HTTP 요청이 아니면 전부 거짓이다") + void isAtLeastFalseWhenVersionUnknown() { + // 버전으로 동작을 가르는 쪽의 규칙이 "모르면 구버전" 이라 세 경우가 같은 답이어야 한다. + bindRequestWithHeader(null); + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + + bindRequestWithHeader("abc"); + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + + RequestContextHolder.resetRequestAttributes(); + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + } + + @Test + @DisplayName("기준값을 읽을 수 없으면 아무도 신버전이 아니다") + void isAtLeastFalseWhenThresholdUnparsable() { + // 설정 오타 하나로 모든 요청이 갑자기 신버전 취급을 받으면 안 된다. + bindRequestWithHeader("9.9.9+999"); + + assertThat(resolver.isAtLeast("사.영.영")).isFalse(); + assertThat(resolver.isAtLeast("")).isFalse(); + } + private void bindRequestWithHeader(String appVersion) { MockHttpServletRequest request = new MockHttpServletRequest(); if (appVersion != null) { diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java index aff4d785..ddedde25 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.practicenote.integration; import com.aisip.OnO.backend.common.emoji.CustomEmojiErrorCase; +import com.aisip.OnO.backend.common.web.AppVersionResolver; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteCompleteRequestDto; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteDeleteRequestDto; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.util.List; @@ -35,6 +37,12 @@ @DisplayName("복습노트 API") class PracticeNoteIntegrationTest extends PracticeNoteTestSupport { + /** 요일을 고르게 강제하는 앱. 프론트가 보내는 형식 그대로다. */ + private static final String NEW_APP_VERSION = "4.0.0+70"; + + /** 그 검증이 없는 구버전 앱. */ + private static final String LEGACY_APP_VERSION = "3.6.0+67"; + private Long userId; private Long otherUserId; private List problems; @@ -196,25 +204,6 @@ void registersWeeklyNotification() throws Exception { userId, practiceNoteId.longValue(), "주간 복습", weeklyNotification(List.of(1, 3, 5))); } - @Test - @DisplayName("주간 반복인데 요일이 비어 있으면 400 이고 아무것도 저장하지 않는다") - void rejectsWeeklyNotificationWithoutWeekDays() throws Exception { - String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( - null, "요일 없는 주간 복습", List.of(), weeklyNotification(List.of()))); - - mockMvc.perform(post("/api/practiceNotes") - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errorCode") - .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); - - // 예전에는 매일 발송으로 되돌아가 복습노트까지 그대로 만들어졌다. - assertThat(practiceNoteRepository.findAllByUserId(userId)).isEmpty(); - verify(practiceNotificationScheduler, never()) - .schedulePracticeNotification(any(), any(), any(), any()); - } - @Test @DisplayName("다른 사용자의 문제로는 만들 수 없다") void rejectsOtherUserProblem() throws Exception { @@ -362,28 +351,6 @@ void updatesWithoutProblemLists() throws Exception { .isEqualTo("제목만 변경"); } - @Test - @DisplayName("주간 반복인데 요일이 비어 있으면 400 이고 기존 알림을 건드리지 않는다") - void rejectsWeeklyNotificationWithoutWeekDays() throws Exception { - PracticeNote practiceNote = savePracticeNote(userId, "복습", List.of(), dailyNotification()); - String body = objectMapper.writeValueAsString(new PracticeNoteUpdateDto( - practiceNote.getId(), "요일 없는 주간", List.of(), List.of(), weeklyNotification(List.of()))); - - mockMvc.perform(patch("/api/practiceNotes") - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errorCode") - .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); - - PracticeNote unchanged = practiceNoteRepository.findById(practiceNote.getId()).orElseThrow(); - assertThat(unchanged.getTitle()).isEqualTo("복습"); - assertThat(unchanged.getPracticeNotification().getRepeatType()).isEqualTo("daily"); - // Quartz 잡 삭제는 트랜잭션과 함께 롤백되지 않는다. 아예 호출되면 안 된다. - verify(practiceNotificationScheduler, never()).updateNotification(any(), any(), any(), any()); - verify(practiceNotificationScheduler, never()).deleteNotification(any()); - } - @Test @DisplayName("다른 사용자의 복습노트는 수정할 수 없다") void rejectsOtherUserPracticeNote() throws Exception { @@ -489,4 +456,129 @@ void returns401WithoutAuthentication() throws Exception { .andExpect(status().isUnauthorized()); } } + + /** + * 요일 없는 주간 반복 알림을 앱 버전으로 가른다. + * + *

신버전 앱은 요일을 고르게 강제하므로 이 조합을 만들 수 없다. 구버전 앱에는 그 검증이 없어서 + * 사용자가 "매주" 만 고른 저장이 그대로 올라오고, 예전 서버는 매일 발송으로 받아 줬다. 그래서 운영 DB 에 + * 요일이 빈 주간 알림 행이 이미 있고, 그 복습 세트를 연 구버전 사용자는 제목만 바꿔도 계속 400 을 받는다. + * 앱을 올리기 전에는 빠져나갈 길이 없다. + * + *

헤더가 없는 요청은 구버전이다. 스토어에 나간 빌드가 헤더를 보내지 않는다. + * + *

구버전 요청이 매일 크론으로 변환되는지는 {@code PracticeNotificationSchedulerTest} 가 본다. + * 여기서는 요청이 통과해 저장되고 스케줄러까지 그 설정 그대로 닿는지를 본다. + */ + @Nested + @DisplayName("요일 없는 주간 반복 알림은 앱 버전으로 가른다") + class WeekDaysRequiredByAppVersion { + + @Test + @DisplayName("신버전 앱의 등록 요청은 400 이고 아무것도 저장하지 않는다") + void rejectsRegisterFromNewApp() throws Exception { + String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( + null, "요일 없는 주간 복습", List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(fromApp(post("/api/practiceNotes"), NEW_APP_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); + + assertThat(practiceNoteRepository.findAllByUserId(userId)).isEmpty(); + verify(practiceNotificationScheduler, never()) + .schedulePracticeNotification(any(), any(), any(), any()); + } + + @Test + @DisplayName("구버전 앱의 등록 요청은 예전처럼 받아 준다") + void acceptsRegisterFromLegacyApp() throws Exception { + registersWeeklyWithoutWeekDays(LEGACY_APP_VERSION); + } + + @Test + @DisplayName("헤더가 없는 등록 요청도 구버전으로 보고 받아 준다") + void acceptsRegisterWithoutAppVersionHeader() throws Exception { + registersWeeklyWithoutWeekDays(null); + } + + @Test + @DisplayName("신버전 앱의 수정 요청은 400 이고 기존 알림을 건드리지 않는다") + void rejectsUpdateFromNewApp() throws Exception { + PracticeNote practiceNote = savePracticeNote(userId, "복습", List.of(), dailyNotification()); + String body = objectMapper.writeValueAsString(new PracticeNoteUpdateDto( + practiceNote.getId(), "요일 없는 주간", List.of(), List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(fromApp(patch("/api/practiceNotes"), NEW_APP_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); + + PracticeNote unchanged = practiceNoteRepository.findById(practiceNote.getId()).orElseThrow(); + assertThat(unchanged.getTitle()).isEqualTo("복습"); + assertThat(unchanged.getPracticeNotification().getRepeatType()).isEqualTo("daily"); + // Quartz 잡 삭제는 트랜잭션과 함께 롤백되지 않는다. 아예 호출되면 안 된다. + verify(practiceNotificationScheduler, never()).updateNotification(any(), any(), any(), any()); + verify(practiceNotificationScheduler, never()).deleteNotification(any()); + } + + @Test + @DisplayName("구버전 앱의 수정 요청은 예전처럼 받아 준다") + void acceptsUpdateFromLegacyApp() throws Exception { + updatesWeeklyWithoutWeekDays(LEGACY_APP_VERSION); + } + + @Test + @DisplayName("헤더가 없는 수정 요청도 구버전으로 보고 받아 준다") + void acceptsUpdateWithoutAppVersionHeader() throws Exception { + updatesWeeklyWithoutWeekDays(null); + } + + private void registersWeeklyWithoutWeekDays(String appVersion) throws Exception { + String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( + null, "요일 없는 주간 복습", List.of(), weeklyNotification(List.of()))); + + String response = mockMvc.perform(fromApp(post("/api/practiceNotes"), appVersion) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + Number practiceNoteId = JsonPath.read(response, "$.data"); + assertThat(practiceNoteRepository.findById(practiceNoteId.longValue())) + .isPresent() + .hasValueSatisfying(saved -> + assertThat(saved.getPracticeNotification().getRepeatType()).isEqualTo("weekly")); + verify(practiceNotificationScheduler).schedulePracticeNotification( + userId, practiceNoteId.longValue(), "요일 없는 주간 복습", weeklyNotification(List.of())); + } + + private void updatesWeeklyWithoutWeekDays(String appVersion) throws Exception { + PracticeNote practiceNote = savePracticeNote(userId, "복습", List.of(), dailyNotification()); + String body = objectMapper.writeValueAsString(new PracticeNoteUpdateDto( + practiceNote.getId(), "요일 없는 주간", List.of(), List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(fromApp(patch("/api/practiceNotes"), appVersion) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + + PracticeNote updated = practiceNoteRepository.findById(practiceNote.getId()).orElseThrow(); + assertThat(updated.getTitle()).isEqualTo("요일 없는 주간"); + assertThat(updated.getPracticeNotification().getRepeatType()).isEqualTo("weekly"); + verify(practiceNotificationScheduler).updateNotification( + userId, practiceNote.getId(), "요일 없는 주간", weeklyNotification(List.of())); + } + } + + /** {@code null} 이면 헤더를 아예 붙이지 않는다. 헤더를 안 보내는 구버전 앱이다. */ + private MockHttpServletRequestBuilder fromApp(MockHttpServletRequestBuilder request, String appVersion) { + return appVersion == null + ? request + : request.header(AppVersionResolver.APP_VERSION_HEADER, appVersion); + } } diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java index c9a1b599..4359ba73 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java @@ -1,8 +1,6 @@ package com.aisip.OnO.backend.practicenote.service; -import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.practicenote.dto.PracticeNotificationRegisterDto; -import com.aisip.OnO.backend.practicenote.exception.PracticeNoteErrorCase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -27,7 +25,6 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; /** @@ -92,35 +89,23 @@ void weeklyCronForEveryWeekDay() throws Exception { } @Test - @DisplayName("주간 반복인데 요일이 비어 있으면 400 으로 거절한다") - void weeklyWithoutWeekDaysIsRejected() throws Exception { - PracticeNotificationRegisterDto dto = - new PracticeNotificationRegisterDto(7, 7, 0, "weekly", List.of()); - - assertThatThrownBy(() -> - notificationScheduler.schedulePracticeNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) - .isInstanceOf(ApplicationException.class) - .extracting(e -> ((ApplicationException) e).getErrorCase()) - .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + @DisplayName("주간 반복인데 요일이 비어 있으면 매일 크론으로 되돌아간다") + void weeklyWithoutWeekDaysFallsBackToDaily() throws Exception { + // 신버전 요청은 진입부(PracticeNoteService)에서 이미 400 으로 걸러진다. 여기까지 오는 것은 + // 요일을 고르라는 검증이 없는 구버전 앱 요청이고, 예전 서버와 같게 매일로 저장돼야 한다. + String cron = scheduleAndCaptureCron( + new PracticeNotificationRegisterDto(7, 7, 0, "weekly", List.of())); - // 매일 발송으로 되돌아가던 폴백이 없어야 한다. 잡 자체가 등록되면 안 된다. - verify(quartzScheduler, never()).addJob(any(), anyBoolean()); - verify(quartzScheduler, never()).scheduleJob(any(Trigger.class)); + assertThat(cron).isEqualTo("0 0 7 ? * *"); } @Test - @DisplayName("주간 반복인데 요일이 null 이어도 400 으로 거절한다") - void weeklyWithNullWeekDaysIsRejected() throws Exception { - PracticeNotificationRegisterDto dto = - new PracticeNotificationRegisterDto(7, 7, 0, "WEEKLY", null); - - assertThatThrownBy(() -> - notificationScheduler.schedulePracticeNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) - .isInstanceOf(ApplicationException.class) - .extracting(e -> ((ApplicationException) e).getErrorCase()) - .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + @DisplayName("주간 반복인데 요일이 null 이어도 매일 크론으로 되돌아간다") + void weeklyWithNullWeekDaysFallsBackToDaily() throws Exception { + String cron = scheduleAndCaptureCron( + new PracticeNotificationRegisterDto(7, 7, 0, "WEEKLY", null)); - verify(quartzScheduler, never()).addJob(any(), anyBoolean()); + assertThat(cron).isEqualTo("0 0 7 ? * *"); } @Test @@ -195,20 +180,16 @@ void wrapsDeleteException() throws Exception { } @Test - @DisplayName("갱신이 요일 없는 주간 반복이면 기존 잡을 지우기 전에 거절한다") - void updateNotificationRejectsBeforeDeleting() throws Exception { + @DisplayName("갱신이 요일 없는 주간 반복이어도 매일 크론으로 다시 등록한다") + void updateNotificationWithoutWeekDaysFallsBackToDaily() throws Exception { PracticeNotificationRegisterDto dto = new PracticeNotificationRegisterDto(7, 21, 0, "weekly", List.of()); - assertThatThrownBy(() -> - notificationScheduler.updateNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) - .isInstanceOf(ApplicationException.class) - .extracting(e -> ((ApplicationException) e).getErrorCase()) - .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); - - // Quartz 삭제는 서비스 트랜잭션과 함께 롤백되지 않는다. 지우고 나서 실패하면 - // 사용자가 쓰던 알림만 사라진다. - verify(quartzScheduler, never()).deleteJob(any()); + notificationScheduler.updateNotification(USER_ID, PRACTICE_ID, "복습 세트", dto); + + ArgumentCaptor triggerCaptor = ArgumentCaptor.forClass(Trigger.class); + verify(quartzScheduler).scheduleJob(triggerCaptor.capture()); + assertThat(((CronTrigger) triggerCaptor.getValue()).getCronExpression()).isEqualTo("0 0 21 ? * *"); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java new file mode 100644 index 00000000..89f74eab --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java @@ -0,0 +1,74 @@ +package com.aisip.OnO.backend.practicenote.service; + +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import org.junit.jupiter.api.DisplayName; +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.beans.factory.annotation.Value; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Field; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * 요일 없는 주간 반복 알림을 어떤 요청에서 막을지 정하는 판정. + * + *

버전 비교 자체는 {@code AppVersionResolverTest} 가 본다. 여기서는 이 도메인이 무엇을 기준으로 + * 삼는가와, 그 기준이 미션 도메인의 설정에 끌려가지 않는가를 본다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("주간 알림 요일 강제 판정") +class PracticeNotificationWeekDayPolicyTest { + + @Mock + private AppVersionResolver appVersionResolver; + + @InjectMocks + private PracticeNotificationWeekDayPolicy policy; + + @Test + @DisplayName("기준 버전은 설정으로 바꿀 수 있고 기본값은 4.0.0 이다") + void thresholdIsConfigurable() throws NoSuchFieldException { + // 코드에 박으면 기준을 되돌릴 때마다 배포해야 한다. 값이 아니라 선언을 본다. + Field field = PracticeNotificationWeekDayPolicy.class.getDeclaredField("weekDaysRequiredVersion"); + + assertThat(field.getAnnotation(Value.class).value()) + .as("프로퍼티 이름과 기본값은 운영에서 기준을 옮길 때 쓰는 계약이다") + .isEqualTo("${ono.practice-note.week-days-required-version:4.0.0}"); + } + + @Test + @DisplayName("기준 버전 이상에서 온 요청이면 요일을 강제한다") + void requiresWeekDaysForNewApp() { + ReflectionTestUtils.setField(policy, "weekDaysRequiredVersion", "4.0.0"); + given(appVersionResolver.isAtLeast("4.0.0")).willReturn(true); + + assertThat(policy.requiresWeekDays()).isTrue(); + } + + @Test + @DisplayName("그 아래거나 버전을 모르면 강제하지 않는다") + void doesNotRequireWeekDaysForLegacyApp() { + // 헤더가 없는 요청도 여기로 떨어진다. 잘못 보면 구버전 사용자가 복습 세트를 영영 수정할 수 없다. + ReflectionTestUtils.setField(policy, "weekDaysRequiredVersion", "4.0.0"); + given(appVersionResolver.isAtLeast("4.0.0")).willReturn(false); + + assertThat(policy.requiresWeekDays()).isFalse(); + } + + @Test + @DisplayName("미션 도메인에 기대지 않는다") + void doesNotDependOnMissionDomain() { + // 미션의 LegacyAccrualPolicy 에는 자동 적립을 통째로 끄는 비상 스위치가 붙어 있다. + // 그 스위치를 내렸을 때 복습 알림 검증까지 같이 움직이면 안 된다. + assertThat(Arrays.stream(PracticeNotificationWeekDayPolicy.class.getDeclaredFields()) + .map(field -> field.getType().getName())) + .noneMatch(type -> type.startsWith("com.aisip.OnO.backend.mission")); + } +}