From ba1c5b723ba0d87eac86df70a4c8979b2ce42ee1 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Mon, 11 May 2026 03:09:55 +0900 Subject: [PATCH 1/7] Add Korean policy pages --- .../ontime_back/config/SecurityConfig.java | 2 +- .../AccountDeletionPageController.java | 405 ++++++--- .../controller/PrivacyPolicyController.java | 823 ++++++++++++------ .../oauth/google/GoogleLoginService.java | 4 +- .../AccountDeletionPageControllerTest.java | 21 + .../PrivacyPolicyControllerTest.java | 27 + .../jwt/JwtAuthenticationFilterTest.java | 2 +- 7 files changed, 887 insertions(+), 397 deletions(-) diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java index 5acee5f..3986586 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java @@ -72,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .headers(headers -> headers .frameOptions(frameOptions -> frameOptions.disable())) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/account-deletion", "/privacy-policy", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll() + .requestMatchers("/", "/account-deletion", "/account-deletion/**", "/privacy-policy", "/privacy-policy/**", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll() .requestMatchers("/health", "/actuator/health/**", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "/swagger-ui.html").permitAll() .requestMatchers("/error").permitAll() diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/AccountDeletionPageController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/AccountDeletionPageController.java index bcfa05f..6ed8dcc 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/AccountDeletionPageController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/AccountDeletionPageController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; @RestController @@ -13,134 +14,284 @@ public class AccountDeletionPageController { @GetMapping(value = "/account-deletion", produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity getAccountDeletionPage() { + return html(koreanAccountDeletionPage()); + } + + @GetMapping(value = "/account-deletion/en", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity getEnglishAccountDeletionPage() { + return html(englishAccountDeletionPage()); + } + + private ResponseEntity html(String body) { return ResponseEntity.ok() - .contentType(MediaType.TEXT_HTML) + .contentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8)) .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) - .body(""" - - - - - - OnTime Account Deletion Request - - - -
-
-

OnTime Account Deletion Request

-

App name: OnTime

-

Developer: ejun

-

Contact email: jjoonleo@gmail.com

-
- -
-

Request Account Deletion Outside The App

-

- You can request deletion of your OnTime account without installing or opening the app by emailing - jjoonleo@gmail.com. - Please use the email address associated with your OnTime account and include the subject - "OnTime account deletion request". -

-

- OnTime may ask for additional information only as needed to verify that the request is from the - account owner. You do not need to log in to this website to submit the request by email. -

- -

Data Deleted

-

- When a user deletes their OnTime account, OnTime deletes the local account and associated app data, - including schedules, preparation data, notification schedules, user settings, alarm settings, - alarm status, device records, FCM tokens, and session tokens. -

- -

Data Retained

-

- If the user submits optional account deletion feedback, OnTime may retain that feedback for up to - 1 year to review service quality and deletion-related support issues. This feedback is stored - separately from the deleted account and uses a hashed email value instead of the plaintext email address. -

-

- Operational logs, monitoring records, and security records may be retained for up to 90 days for - service operation, debugging, security, and abuse-prevention purposes, unless a longer period is - required for legal compliance or an active security investigation. -

-

- Backup copies containing deleted account data are removed according to the normal backup rotation - and are retained for no longer than 30 days, unless a longer period is required by law or security investigation. -

- -

Privacy Policy

-

- The public OnTime privacy policy is available at - https://ontime-back.duckdns.org/privacy-policy. - It can be listed in Google Play Console together with this account deletion request URL. -

-
-
- - - """); + .body(body); + } + + private String koreanAccountDeletionPage() { + return """ + + + + + + + + + OnTime 계정 삭제 요청 + + + +
+
+

OnTime 계정 삭제 요청

+

앱 이름: OnTime

+

개발자: ejun

+

문의 이메일: jjoonleo@gmail.com

+

English: Account deletion request

+
+ +
+

앱 외부에서 계정 삭제 요청하기

+

+ OnTime 앱을 설치하거나 열지 않아도 + jjoonleo@gmail.com으로 + 이메일을 보내 계정 삭제를 요청할 수 있습니다. OnTime 계정에 연결된 이메일 주소로 보내주시고, + 제목에 "OnTime account deletion request"를 포함해 주세요. +

+

+ OnTime은 요청자가 계정 소유자인지 확인하는 데 필요한 경우에만 추가 정보를 요청할 수 있습니다. + 이메일로 삭제를 요청하기 위해 이 웹사이트에 로그인할 필요는 없습니다. +

+ +

삭제되는 데이터

+

+ 사용자가 OnTime 계정을 삭제하면 OnTime은 로컬 계정과 관련 앱 데이터를 삭제합니다. + 여기에는 일정, 준비 데이터, 알림 일정, 사용자 설정, 알람 설정, 알람 상태, + 기기 기록, FCM 토큰, 세션 토큰이 포함됩니다. +

+ +

보관되는 데이터

+

+ 사용자가 선택적으로 계정 삭제 피드백을 제출한 경우, OnTime은 서비스 품질 및 삭제 관련 + 지원 이슈를 검토하기 위해 해당 피드백을 최대 1년 동안 보관할 수 있습니다. 이 피드백은 + 삭제된 계정과 분리되어 저장되며, 일반 텍스트 이메일 주소 대신 해시 처리된 이메일 값을 사용합니다. +

+

+ 운영 로그, 모니터링 기록, 보안 기록은 서비스 운영, 디버깅, 보안 및 악용 방지를 위해 + 최대 90일 동안 보관될 수 있습니다. 법적 준수 또는 진행 중인 보안 조사에 더 긴 기간이 + 필요한 경우에는 예외적으로 더 오래 보관될 수 있습니다. +

+

+ 삭제된 계정 데이터가 포함된 백업 사본은 일반적인 백업 순환 주기에 따라 제거되며, + 법률 또는 보안 조사상 더 긴 기간이 필요한 경우를 제외하고 30일을 초과하여 보관되지 않습니다. +

+ +

개인정보 처리방침

+

+ OnTime의 공개 개인정보 처리방침은 + https://ontime-back.duckdns.org/privacy-policy에서 + 확인할 수 있습니다. 이 URL은 계정 삭제 요청 URL과 함께 Google Play Console에 등록할 수 있습니다. +

+
+
+ + + """; + } + + private String englishAccountDeletionPage() { + return """ + + + + + + + + OnTime Account Deletion Request + + + +
+
+

OnTime Account Deletion Request

+

App name: OnTime

+

Developer: ejun

+

Contact email: jjoonleo@gmail.com

+

한국어: 계정 삭제 요청

+
+ +
+

Request Account Deletion Outside The App

+

+ You can request deletion of your OnTime account without installing or opening the app by emailing + jjoonleo@gmail.com. + Please use the email address associated with your OnTime account and include the subject + "OnTime account deletion request". +

+

+ OnTime may ask for additional information only as needed to verify that the request is from the + account owner. You do not need to log in to this website to submit the request by email. +

+ +

Data Deleted

+

+ When a user deletes their OnTime account, OnTime deletes the local account and associated app data, + including schedules, preparation data, notification schedules, user settings, alarm settings, + alarm status, device records, FCM tokens, and session tokens. +

+ +

Data Retained

+

+ If the user submits optional account deletion feedback, OnTime may retain that feedback for up to + 1 year to review service quality and deletion-related support issues. This feedback is stored + separately from the deleted account and uses a hashed email value instead of the plaintext email address. +

+

+ Operational logs, monitoring records, and security records may be retained for up to 90 days for + service operation, debugging, security, and abuse-prevention purposes, unless a longer period is + required for legal compliance or an active security investigation. +

+

+ Backup copies containing deleted account data are removed according to the normal backup rotation + and are retained for no longer than 30 days, unless a longer period is required by law or security investigation. +

+ +

Privacy Policy

+

+ The public OnTime privacy policy is available at + https://ontime-back.duckdns.org/privacy-policy. + It can be listed in Google Play Console together with this account deletion request URL. +

+
+
+ + + """; } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/PrivacyPolicyController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/PrivacyPolicyController.java index 008b9b9..e0ed66d 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/PrivacyPolicyController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/PrivacyPolicyController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; @RestController @@ -13,283 +14,573 @@ public class PrivacyPolicyController { @GetMapping(value = "/privacy-policy", produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity getPrivacyPolicy() { + return html(koreanPrivacyPolicy()); + } + + @GetMapping(value = "/privacy-policy/en", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity getEnglishPrivacyPolicy() { + return html(englishPrivacyPolicy()); + } + + private ResponseEntity html(String body) { return ResponseEntity.ok() - .contentType(MediaType.TEXT_HTML) + .contentType(new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8)) .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) - .body(""" - - - - - - - - OnTime Privacy Policy - - - -
-
-

OnTime Privacy Policy

-

App name: OnTime

-

Developer/entity: ejun

-

Contact email: jjoonleo@gmail.com

-

Effective date: May 10, 2026

-
+ .body(body); + } + + private String koreanPrivacyPolicy() { + return """ + + + + + + + + + + OnTime 개인정보 처리방침 + + + +
+
+

OnTime 개인정보 처리방침

+

앱 이름: OnTime

+

개발자/운영 주체: ejun

+

문의 이메일: jjoonleo@gmail.com

+

시행일: 2026년 5월 10일

+

English: Privacy Policy

+
+ +
+

+ OnTime은 ejun이 제공합니다. 본 개인정보 처리방침은 사용자가 OnTime 앱을 이용할 때 + OnTime이 데이터를 수집, 이용, 공유, 보호, 보관 및 삭제하는 방식을 설명합니다. +

+

+ 개인정보 관련 문의 또는 요청은 + jjoonleo@gmail.com으로 연락해 주세요. +

+ +

OnTime이 수집하거나 접근하는 데이터

+

+ OnTime은 계정, 일정, 준비 알림, 알람, 알림 및 지원 기능을 제공하기 위해 다음 데이터를 + 수집하거나 접근합니다. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
데이터예시목적
계정 데이터이메일 주소, 표시 이름, 이메일 가입용 비밀번호, Google 로그인 토큰, Apple identity token, Apple authorization code, 사용 가능한 경우 Apple이 제공한 이름 또는 이메일.계정 생성 및 인증, 로그인 상태 유지, 소셜 로그인 지원, 프로필 정보 불러오기.
일정/준비 데이터일정 이름과 시간, 장소 정보, 이동 시간, 여유 시간, 메모, 일정 상태, 지각 시간, 준비 단계, 준비 소요 시간, 단계 순서.일정 및 준비 계획의 생성, 수정, 표시, 완료, 삭제.
알람/알림 데이터알람 설정, 알림 권한 상태, 기기 ID, FCM 토큰, 플랫폼, 앱 버전, OS 버전, 지원 알람 제공자, 알람 상태 보고, 활성화 또는 건너뛴 일정 ID, 알람 실패 사유.일정 리마인더 및 알람 알림 발송, 현재 기기 등록, 알람 복구, 알람 동작 범위 진단.
피드백선택적인 계정 삭제 피드백 또는 기타 피드백 메시지.사용자 피드백 및 계정 삭제 요청 처리.
로컬 앱 데이터기기에 저장된 사용자, 일정, 장소, 준비, 알람, 토큰 캐시 데이터.앱 상태를 로컬에서 유지하고 앱 동작 지원.
기술/진단 데이터네트워크 요청 메타데이터, 서버 로그, 오류 메타데이터, 보안 관련 운영 기록.서비스 운영, 보안, 디버깅 및 유지보수.
+

+ 현재 Android 릴리스 매니페스트에서 OnTime은 위치, 연락처, 카메라, 마이크, 전화, SMS, + 저장소, 캘린더, 주변 기기 또는 Bluetooth 권한에 대한 앱 소유 접근을 요청하지 않습니다. + OnTime은 일정 리마인더와 알람 기능을 제공하기 위해 알림, 정확한 알람, 전체 화면 인텐트, + 부팅 완료, 진동, Firebase Messaging 및 네트워크 관련 권한을 사용합니다. +

+ +

OnTime의 데이터 이용 방식

+

OnTime은 수집한 데이터를 다음 목적으로 사용합니다.

+
    +
  • 사용자 계정 생성, 인증 및 관리.
  • +
  • 이메일/비밀번호, Google, Apple 로그인 지원.
  • +
  • 일정 생성, 수정, 완료, 삭제 및 표시.
  • +
  • 기본 준비 단계 및 일정별 준비 단계 생성과 수정.
  • +
  • 일정 리마인더, 준비 알림 및 알람 알림 발송.
  • +
  • 알람 및 알림 발송을 위한 현재 기기 등록과 등록 해제.
  • +
  • 선택적 피드백 및 계정 삭제 피드백 처리.
  • +
  • 보안 유지, 악용 방지, 장애 디버깅 및 서비스 운영.
  • +
+ +

제3자 서비스 및 처리자

+

+ OnTime은 핵심 앱 동작에 필요한 범위에서 제3자 서비스와 SDK를 사용합니다. 여기에는 + Google 계정 인증을 위한 Google Sign-In, Apple 계정 인증을 위한 Apple Sign-In, + 앱 초기화 및 푸시 알림 발송을 위한 Firebase Core와 Firebase Cloud Messaging, + 계정, 일정, 준비, 알람, 알림, 피드백 및 삭제 요청 처리를 위한 OnTime 백엔드/API + 인프라가 포함됩니다. +

+ +

데이터 공유

+

+ OnTime은 앱 기능, 인증, 알림, 호스팅, 보안, 운영 및 지원을 제공하는 데 필요한 범위에서만 + 서비스 제공자와 데이터를 공유합니다. 현재 릴리스 빌드에서 OnTime은 앱 내 광고를 사용하지 않습니다. +

+ +

안전한 데이터 처리

+

+ OnTime은 HTTPS API 통신, 토큰 기반 인증, 로컬 보안 토큰 저장, 릴리스 로그 제한, + 비식별화 및 마스킹 관행을 통해 개인 및 민감한 데이터를 보호합니다. 릴리스 빌드는 토큰, + Authorization 헤더, 요청 본문, 응답 본문, 개인 일정 페이로드, 전체 알람 페이로드, + OAuth 값 또는 FCM 토큰을 로그로 남겨서는 안 됩니다. +

+ +

데이터 보관

+

+ OnTime은 서비스를 제공하고, 보안을 유지하며, 법적 의무를 이행하고, 분쟁을 해결하며, + 약관을 집행하는 데 필요한 기간 동안 계정, 일정, 준비, 알람, 알림, 피드백 및 기술 데이터를 보관합니다. +

+

+ OnTime 계정이 삭제되면 계정 데이터와 사용자 소유 앱 데이터가 삭제됩니다. 여기에는 관련 일정, + 준비 데이터, 알림 일정, 사용자 설정, 알람 설정, 알람 상태, 기기 기록, FCM 토큰, + 세션 토큰이 포함됩니다. +

+

+ 사용자가 선택적으로 계정 삭제 피드백을 제출한 경우, OnTime은 서비스 품질 및 삭제 관련 + 지원 이슈를 검토하기 위해 해당 피드백을 최대 1년 동안 보관할 수 있습니다. +

+

+ 운영 로그, 모니터링 기록, 보안 기록은 서비스 운영, 디버깅, 보안 및 악용 방지를 위해 + 최대 90일 동안 보관될 수 있습니다. 법적 준수 또는 진행 중인 보안 조사에 더 긴 기간이 + 필요한 경우에는 예외적으로 더 오래 보관될 수 있습니다. +

+

+ 삭제된 계정 데이터가 포함된 백업 사본은 일반적인 백업 순환 주기에 따라 제거되며, + 법률 또는 진행 중인 보안 조사상 더 긴 기간이 필요한 경우를 제외하고 30일을 초과하여 + 보관되지 않습니다. +

+ +

계정 및 데이터 삭제

+

+ 사용자는 OnTime 앱 안에서 계정 삭제를 요청할 수 있습니다. 삭제가 성공하면 앱은 사용자를 로그아웃합니다. +

+

+ 사용자는 앱 외부에서도 + https://ontime-back.duckdns.org/account-deletion에서 + 계정 삭제를 요청할 수 있습니다. +

+

+ Google 및 Apple 소셜 계정의 경우, 백엔드는 로컬 OnTime 계정을 삭제하기 전에 저장된 + 제공자 토큰 해지를 시도합니다. 제공자 토큰 해지가 실패하더라도 백엔드는 로컬 OnTime 계정을 삭제합니다. + OnTime 계정 삭제는 사용자의 Google 계정 또는 Apple ID를 삭제하지 않습니다. +

+ +

아동

+

+ OnTime은 아동을 대상으로 하지 않습니다. 아동이 OnTime에 개인정보를 제공했다고 생각하는 경우 + jjoonleo@gmail.com으로 연락해 주세요. 요청을 검토하겠습니다. +

+ +

방침 변경

+

+ OnTime은 앱 동작, 법적 요구사항 또는 서비스 제공자의 변경을 반영하기 위해 본 개인정보 처리방침을 + 업데이트할 수 있습니다. 방침이 변경되면 위 시행일이 업데이트됩니다. +

+
+
+ + + """; + } + + private String englishPrivacyPolicy() { + return """ + + + + + + + + + OnTime Privacy Policy + + + +
+
+

OnTime Privacy Policy

+

App name: OnTime

+

Developer/entity: ejun

+

Contact email: jjoonleo@gmail.com

+

Effective date: May 10, 2026

+

한국어: 개인정보 처리방침

+
-
-

- OnTime is provided by ejun. This Privacy Policy explains how OnTime collects, uses, - shares, protects, retains, and deletes data when you use the OnTime app. -

-

- For privacy questions or requests, contact - jjoonleo@gmail.com. -

+
+

+ OnTime is provided by ejun. This Privacy Policy explains how OnTime collects, uses, + shares, protects, retains, and deletes data when you use the OnTime app. +

+

+ For privacy questions or requests, contact + jjoonleo@gmail.com. +

-

Data OnTime Collects Or Accesses

-

- OnTime collects or accesses the following data to provide accounts, schedules, - preparation reminders, alarms, notifications, and support features. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DataExamplesPurpose
Account dataEmail address, display name, password for email sign-up, Google sign-in token, Apple identity token, Apple authorization code, and Apple-provided name or email when available.Create and authenticate accounts, keep users signed in, support social sign-in, and load profile information.
Schedule/preparation dataSchedule names and times, place information, movement time, spare time, notes, schedule state, lateness time, preparation steps, preparation durations, and step order.Create, update, display, finish, and delete schedules and preparation plans.
Alarm/notification dataAlarm settings, notification permission state, device ID, FCM token, platform, app version, OS version, supported alarm providers, alarm status reports, armed or skipped schedule IDs, and alarm failure reason.Deliver schedule reminders and alarm notifications, register the current device, restore alarms, and diagnose alarm coverage.
FeedbackOptional account deletion feedback or other feedback messages.Process user feedback and account deletion requests.
Local app dataCached user, schedule, place, preparation, alarm, and token data stored on the device.Keep app state available locally and support app operation.
Technical/diagnostic dataNetwork request metadata, server logs, error metadata, and security-related operational records.Operate, secure, debug, and maintain the service.
-

- OnTime does not request app-owned access to location, contacts, camera, microphone, - phone, SMS, storage, calendar, nearby-device, or Bluetooth permissions in the current - Android release manifest. OnTime uses notification, exact alarm, full-screen intent, - boot completion, vibration, Firebase messaging, and network-related permissions to - provide schedule reminders and alarm functionality. -

+

Data OnTime Collects Or Accesses

+

+ OnTime collects or accesses the following data to provide accounts, schedules, + preparation reminders, alarms, notifications, and support features. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DataExamplesPurpose
Account dataEmail address, display name, password for email sign-up, Google sign-in token, Apple identity token, Apple authorization code, and Apple-provided name or email when available.Create and authenticate accounts, keep users signed in, support social sign-in, and load profile information.
Schedule/preparation dataSchedule names and times, place information, movement time, spare time, notes, schedule state, lateness time, preparation steps, preparation durations, and step order.Create, update, display, finish, and delete schedules and preparation plans.
Alarm/notification dataAlarm settings, notification permission state, device ID, FCM token, platform, app version, OS version, supported alarm providers, alarm status reports, armed or skipped schedule IDs, and alarm failure reason.Deliver schedule reminders and alarm notifications, register the current device, restore alarms, and diagnose alarm coverage.
FeedbackOptional account deletion feedback or other feedback messages.Process user feedback and account deletion requests.
Local app dataCached user, schedule, place, preparation, alarm, and token data stored on the device.Keep app state available locally and support app operation.
Technical/diagnostic dataNetwork request metadata, server logs, error metadata, and security-related operational records.Operate, secure, debug, and maintain the service.
+

+ OnTime does not request app-owned access to location, contacts, camera, microphone, + phone, SMS, storage, calendar, nearby-device, or Bluetooth permissions in the current + Android release manifest. OnTime uses notification, exact alarm, full-screen intent, + boot completion, vibration, Firebase messaging, and network-related permissions to + provide schedule reminders and alarm functionality. +

-

How OnTime Uses Data

-

OnTime uses collected data to:

-
    -
  • Create, authenticate, and manage user accounts.
  • -
  • Support email/password, Google, and Apple sign-in.
  • -
  • Create, update, finish, delete, and display schedules.
  • -
  • Create and update default and schedule-specific preparation steps.
  • -
  • Send schedule reminders, preparation notifications, and alarm notifications.
  • -
  • Register and unregister the current device for alarm and notification delivery.
  • -
  • Process optional feedback and account deletion feedback.
  • -
  • Maintain security, prevent abuse, debug failures, and operate the service.
  • -
+

How OnTime Uses Data

+

OnTime uses collected data to:

+
    +
  • Create, authenticate, and manage user accounts.
  • +
  • Support email/password, Google, and Apple sign-in.
  • +
  • Create, update, finish, delete, and display schedules.
  • +
  • Create and update default and schedule-specific preparation steps.
  • +
  • Send schedule reminders, preparation notifications, and alarm notifications.
  • +
  • Register and unregister the current device for alarm and notification delivery.
  • +
  • Process optional feedback and account deletion feedback.
  • +
  • Maintain security, prevent abuse, debug failures, and operate the service.
  • +
-

Third-Party Services And Processors

-

- OnTime uses third-party services and SDKs where needed for core app behavior, including - Google Sign-In for Google account authentication, Apple Sign-In for Apple account - authentication, Firebase Core and Firebase Cloud Messaging for app initialization and - push notification delivery, and OnTime backend/API infrastructure for account, - schedule, preparation, alarm, notification, feedback, and deletion request processing. -

+

Third-Party Services And Processors

+

+ OnTime uses third-party services and SDKs where needed for core app behavior, including + Google Sign-In for Google account authentication, Apple Sign-In for Apple account + authentication, Firebase Core and Firebase Cloud Messaging for app initialization and + push notification delivery, and OnTime backend/API infrastructure for account, + schedule, preparation, alarm, notification, feedback, and deletion request processing. +

-

Data Sharing

-

- OnTime shares data with service providers only as needed to provide app functionality, - authentication, notifications, hosting, security, operations, and support. OnTime does - not use in-app advertising in the current release build. -

+

Data Sharing

+

+ OnTime shares data with service providers only as needed to provide app functionality, + authentication, notifications, hosting, security, operations, and support. OnTime does + not use in-app advertising in the current release build. +

-

Secure Data Handling

-

- OnTime uses HTTPS API communication, token-based authentication, local secure token - storage, release-log restrictions, and redaction practices to protect personal and - sensitive data. Release builds must not log tokens, authorization headers, request - bodies, response bodies, personal schedule payloads, full alarm payloads, OAuth values, - or FCM tokens. -

+

Secure Data Handling

+

+ OnTime uses HTTPS API communication, token-based authentication, local secure token + storage, release-log restrictions, and redaction practices to protect personal and + sensitive data. Release builds must not log tokens, authorization headers, request + bodies, response bodies, personal schedule payloads, full alarm payloads, OAuth values, + or FCM tokens. +

-

Data Retention

-

- OnTime keeps account, schedule, preparation, alarm, notification, feedback, and - technical data for as long as needed to provide the service, maintain security, meet - legal obligations, resolve disputes, and enforce agreements. -

-

- When an OnTime account is deleted, account data and user-owned app data are deleted, - including associated schedules, preparation data, notification schedules, user settings, - alarm settings, alarm status, device records, FCM tokens, and session tokens. -

-

- If a user submits optional account deletion feedback, OnTime may retain that feedback - for up to 1 year to review service quality and deletion-related support issues. -

-

- Operational logs, monitoring records, and security records may be retained for up to 90 - days for service operation, debugging, security, and abuse-prevention purposes, unless a - longer period is required for legal compliance or an active security investigation. -

-

- Backup copies that contain deleted account data are removed according to the normal - backup rotation and are retained for no longer than 30 days, unless a longer period is - required by law or an active security investigation. -

+

Data Retention

+

+ OnTime keeps account, schedule, preparation, alarm, notification, feedback, and + technical data for as long as needed to provide the service, maintain security, meet + legal obligations, resolve disputes, and enforce agreements. +

+

+ When an OnTime account is deleted, account data and user-owned app data are deleted, + including associated schedules, preparation data, notification schedules, user settings, + alarm settings, alarm status, device records, FCM tokens, and session tokens. +

+

+ If a user submits optional account deletion feedback, OnTime may retain that feedback + for up to 1 year to review service quality and deletion-related support issues. +

+

+ Operational logs, monitoring records, and security records may be retained for up to 90 + days for service operation, debugging, security, and abuse-prevention purposes, unless a + longer period is required for legal compliance or an active security investigation. +

+

+ Backup copies that contain deleted account data are removed according to the normal + backup rotation and are retained for no longer than 30 days, unless a longer period is + required by law or an active security investigation. +

-

Account And Data Deletion

-

- Users can request account deletion from within the OnTime app. On successful deletion, - the app signs the user out. -

-

- Users can also request account deletion outside the app at - https://ontime-back.duckdns.org/account-deletion. -

-

- For Google and Apple social accounts, the backend attempts to revoke the stored provider - token before deleting the local OnTime account. If provider token revocation fails, the - backend still deletes the local OnTime account. Deleting an OnTime account does not - delete the user's Google account or Apple ID. -

+

Account And Data Deletion

+

+ Users can request account deletion from within the OnTime app. On successful deletion, + the app signs the user out. +

+

+ Users can also request account deletion outside the app at + https://ontime-back.duckdns.org/account-deletion. +

+

+ For Google and Apple social accounts, the backend attempts to revoke the stored provider + token before deleting the local OnTime account. If provider token revocation fails, the + backend still deletes the local OnTime account. Deleting an OnTime account does not + delete the user's Google account or Apple ID. +

-

Children

-

- OnTime is not directed to children. If you believe a child has provided personal data to - OnTime, contact jjoonleo@gmail.com so the - request can be reviewed. -

+

Children

+

+ OnTime is not directed to children. If you believe a child has provided personal data to + OnTime, contact jjoonleo@gmail.com so the + request can be reviewed. +

-

Changes To This Policy

-

- OnTime may update this Privacy Policy to reflect changes in app behavior, legal - requirements, or service providers. The effective date above will be updated when the - policy changes. -

-
-
- - - """); +

Changes To This Policy

+

+ OnTime may update this Privacy Policy to reflect changes in app behavior, legal + requirements, or service providers. The effective date above will be updated when the + policy changes. +

+ +
+ + + """; } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java index 512960c..ddc4438 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java @@ -192,7 +192,7 @@ private void logGoogleIdentityTokenClaims(String identityToken) { Long expirationTimeSeconds = payload.getExpirationTimeSeconds(); long nowSeconds = System.currentTimeMillis() / 1000; log.info( - "Google identity token claims aud={}, azp={}, iss={}, exp={}, now={}, secondsUntilExp={}, audienceAllowed={}", + "Google identity credential claims aud={}, azp={}, iss={}, exp={}, now={}, secondsUntilExp={}, audienceAllowed={}", maskClientId(audience), payload.get("azp"), payload.getIssuer(), @@ -202,7 +202,7 @@ private void logGoogleIdentityTokenClaims(String identityToken) { validClientIds.contains(audience) ); } catch (Exception e) { - log.info("Google identity token claim parsing failed: {}", e.getClass().getSimpleName()); + log.info("Google identity credential claim parsing failed: {}", e.getClass().getSimpleName()); } } diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/AccountDeletionPageControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/AccountDeletionPageControllerTest.java index 2615a25..53dcd9d 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/AccountDeletionPageControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/AccountDeletionPageControllerTest.java @@ -22,6 +22,27 @@ void getAccountDeletionPage() throws Exception { .andExpect(status().isOk()) .andExpect(header().string("Cache-Control", containsString("max-age=3600"))) .andExpect(content().contentTypeCompatibleWith("text/html")) + .andExpect(content().string(containsString(""))) + .andExpect(content().string(containsString("OnTime 계정 삭제 요청"))) + .andExpect(content().string(containsString("앱 이름: OnTime"))) + .andExpect(content().string(containsString("개발자: ejun"))) + .andExpect(content().string(containsString("문의 이메일"))) + .andExpect(content().string(containsString("앱을 설치하거나 열지 않아도"))) + .andExpect(content().string(containsString("일정, 준비 데이터, 알림 일정"))) + .andExpect(content().string(containsString("최대 1년 동안 보관"))) + .andExpect(content().string(containsString("최대 90일 동안 보관"))) + .andExpect(content().string(containsString("30일을 초과하여 보관되지 않습니다"))) + .andExpect(content().string(containsString("https://ontime-back.duckdns.org/privacy-policy"))); + } + + @DisplayName("영문 계정 삭제 요청 페이지를 로그인 없이 HTML로 조회한다.") + @Test + void getEnglishAccountDeletionPage() throws Exception { + mockMvc.perform(get("/account-deletion/en")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", containsString("max-age=3600"))) + .andExpect(content().contentTypeCompatibleWith("text/html")) + .andExpect(content().string(containsString(""))) .andExpect(content().string(containsString("OnTime Account Deletion Request"))) .andExpect(content().string(containsString("App name: OnTime"))) .andExpect(content().string(containsString("Developer: ejun"))) diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/PrivacyPolicyControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/PrivacyPolicyControllerTest.java index 37e3486..a75396c 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/PrivacyPolicyControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/PrivacyPolicyControllerTest.java @@ -22,6 +22,33 @@ void getPrivacyPolicy() throws Exception { .andExpect(status().isOk()) .andExpect(header().string("Cache-Control", containsString("max-age=3600"))) .andExpect(content().contentTypeCompatibleWith("text/html")) + .andExpect(content().string(containsString(""))) + .andExpect(content().string(containsString("OnTime 개인정보 처리방침"))) + .andExpect(content().string(containsString("앱 이름: OnTime"))) + .andExpect(content().string(containsString("개발자/운영 주체: ejun"))) + .andExpect(content().string(containsString("문의 이메일"))) + .andExpect(content().string(containsString("시행일: 2026년 5월 10일"))) + .andExpect(content().string(containsString("https://ontime-back.duckdns.org/account-deletion"))) + .andExpect(content().string(containsString("계정 데이터"))) + .andExpect(content().string(containsString("일정/준비 데이터"))) + .andExpect(content().string(containsString("알람/알림 데이터"))) + .andExpect(content().string(containsString("피드백"))) + .andExpect(content().string(containsString("로컬 앱 데이터"))) + .andExpect(content().string(containsString("기술/진단 데이터"))) + .andExpect(content().string(containsString("계정 데이터와 사용자 소유 앱 데이터가 삭제됩니다"))) + .andExpect(content().string(containsString("최대 1년"))) + .andExpect(content().string(containsString("최대 90일"))) + .andExpect(content().string(containsString("30일을 초과하여"))); + } + + @DisplayName("영문 개인정보 처리방침 페이지를 로그인 없이 HTML로 조회한다.") + @Test + void getEnglishPrivacyPolicy() throws Exception { + mockMvc.perform(get("/privacy-policy/en")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", containsString("max-age=3600"))) + .andExpect(content().contentTypeCompatibleWith("text/html")) + .andExpect(content().string(containsString(""))) .andExpect(content().string(containsString("OnTime Privacy Policy"))) .andExpect(content().string(containsString("App name: OnTime"))) .andExpect(content().string(containsString("Developer/entity: ejun"))) diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java index bc90ee2..280ab03 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java @@ -16,7 +16,7 @@ class JwtAuthenticationFilterTest { @DisplayName("공개 HTML 페이지는 액세스 토큰 없이 JWT 필터를 통과한다.") @ParameterizedTest - @ValueSource(strings = {"/account-deletion", "/privacy-policy"}) + @ValueSource(strings = {"/account-deletion", "/account-deletion/en", "/privacy-policy", "/privacy-policy/en"}) void skipsPublicHtmlPages(String path) throws Exception { JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); UserRepository userRepository = mock(UserRepository.class); From 53e2ee250bb9b0ad4ab2a9a895791cd630f81667 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 21:41:11 +0900 Subject: [PATCH 2/7] Add schedule start lifecycle locking --- docs/schedule-start-api.md | 301 ++++++++++++++++++ .../controller/ScheduleController.java | 18 ++ .../dto/AlarmWindowScheduleDto.java | 3 + .../ontime_back/dto/ScheduleAddDto.java | 2 + .../devkor/ontime_back/dto/ScheduleDto.java | 18 +- .../dto/StartScheduleResponseDto.java | 13 + .../devkor/ontime_back/entity/Schedule.java | 26 +- .../oauth/google/GoogleLoginService.java | 4 +- .../PreparationScheduleRepository.java | 2 + .../repository/ScheduleRepository.java | 11 + .../ontime_back/response/ApiResponseForm.java | 8 +- .../ontime_back/response/ErrorCode.java | 6 +- .../response/GlobalExceptionHandler.java | 6 + .../service/PreparationScheduleService.java | 3 +- .../ontime_back/service/ScheduleService.java | 133 +++++++- .../StartedSchedulePreparationRepair.java | 22 ++ .../ontime_back/service/UserService.java | 12 +- .../V12__add_started_at_to_schedule.sql | 1 + .../V13__add_finished_at_to_schedule.sql | 1 + .../controller/ScheduleControllerTest.java | 70 ++++ .../PreparationScheduleServiceTest.java | 44 ++- .../service/ScheduleServiceTest.java | 234 +++++++++++++- .../ontime_back/service/UserServiceTest.java | 33 +- 23 files changed, 936 insertions(+), 35 deletions(-) create mode 100644 docs/schedule-start-api.md create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/StartScheduleResponseDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/service/StartedSchedulePreparationRepair.java create mode 100644 ontime-back/src/main/resources/db/migration/V12__add_started_at_to_schedule.sql create mode 100644 ontime-back/src/main/resources/db/migration/V13__add_finished_at_to_schedule.sql diff --git a/docs/schedule-start-api.md b/docs/schedule-start-api.md new file mode 100644 index 0000000..9e30e32 --- /dev/null +++ b/docs/schedule-start-api.md @@ -0,0 +1,301 @@ +# Schedule Start API + +Frontend integration guide for server-authoritative preparation start state. + +## Summary + +The backend now uses `startedAt` as the source of truth for whether preparation has started. Flutter should stop treating client-side `isStarted` as authoritative. + +Preparation becomes frozen when the user starts a schedule: + +- Default preparation is a mutable template. +- Started schedule preparation is a schedule-specific snapshot. +- After `startedAt` is set, schedule preparation reads come from the frozen snapshot, not from the user's default preparation. + +## Authentication + +All endpoints require the current OnTime access token. + +```http +Authorization: Bearer {accessToken} +Content-Type: application/json +``` + +## Schedule Response Field + +Schedule responses now include nullable `startedAt`. + +```json +{ + "scheduleId": "3fa85f64-5717-4562-b3fc-2c963f66afe5", + "place": { + "placeId": "70d460da-6a82-4c57-a285-567cdeda5670", + "placeName": "Home" + }, + "scheduleName": "Party", + "moveTime": 20, + "scheduleTime": "2026-05-13T19:30:00", + "scheduleSpareTime": 20, + "scheduleNote": "Write a message.", + "latenessTime": -1, + "doneStatus": "NOT_ENDED", + "startedAt": "2026-05-13T10:15:30Z", + "finishedAt": null +} +``` + +Fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `startedAt` | ISO-8601 UTC datetime or `null` | `null` means preparation has not explicitly started. Non-null means the schedule is locked for editing. | +| `finishedAt` | ISO-8601 UTC datetime or `null` | Non-null means the schedule was explicitly finished by the finish endpoint. | +| `doneStatus` | enum | `NOT_ENDED`, `NORMAL`, `LATE`, or `ABNORMAL`. Finished schedules cannot be edited or deleted. | +| `latenessTime` | integer or `null` | Completion result. `-1` is legacy/unended data; new finish calls use `0` for normal or positive minutes for late. | + +Frontend rule: + +```text +canEditSchedule = doneStatus == "NOT_ENDED" && startedAt == null +canDeleteSchedule = doneStatus == "NOT_ENDED" +``` + +## Start Preparation + +Call this endpoint when the user taps "Start preparation". + +```http +POST /schedules/{scheduleId}/start +``` + +Request body: none. + +Behavior: + +- If the schedule has not started, backend sets `startedAt` to server time. +- If the schedule still uses default preparation, backend copies the current default preparation into schedule-specific preparation rows. +- If the schedule already started, backend returns success without changing `startedAt` or replacing the frozen preparation snapshot. +- If the schedule is finished, backend returns `409 SCHEDULE_ALREADY_FINISHED`. + +Success response: + +```json +{ + "status": "success", + "code": 200, + "message": "OK", + "data": { + "schedule": { + "scheduleId": "3fa85f64-5717-4562-b3fc-2c963f66afe5", + "place": { + "placeId": "70d460da-6a82-4c57-a285-567cdeda5670", + "placeName": "Home" + }, + "scheduleName": "Party", + "moveTime": 20, + "scheduleTime": "2026-05-13T19:30:00", + "scheduleSpareTime": 20, + "scheduleNote": "Write a message.", + "latenessTime": -1, + "doneStatus": "NOT_ENDED", + "startedAt": "2026-05-13T10:15:30Z", + "finishedAt": null + }, + "preparations": [ + { + "preparationId": "123e4567-e89b-12d3-a456-426614174011", + "preparationName": "Wash up", + "preparationTime": 10, + "nextPreparationId": "123e4567-e89b-12d3-a456-426614174012" + }, + { + "preparationId": "123e4567-e89b-12d3-a456-426614174012", + "preparationName": "Get dressed", + "preparationTime": 15, + "nextPreparationId": null + } + ] + } +} +``` + +Frontend behavior: + +- After success, update local schedule state from `data.schedule`. +- Use `data.preparations` as the running preparation steps. +- Hide or disable schedule edit actions because `startedAt != null`. +- It is safe to retry this request; the endpoint is idempotent. + +## Update Schedule + +Existing endpoint: + +```http +PUT /schedules/{scheduleId} +``` + +New server-side guard: + +- Allowed only when `doneStatus == "NOT_ENDED"` and `startedAt == null`. +- Backend ignores incoming `isStarted`; do not send or depend on it for locking. + +Started schedule error: + +```json +{ + "status": "error", + "code": "SCHEDULE_ALREADY_STARTED", + "message": "Started schedules cannot be edited.", + "data": null +} +``` + +Finished schedule error: + +```json +{ + "status": "error", + "code": "SCHEDULE_ALREADY_FINISHED", + "message": "Finished schedules cannot be edited.", + "data": null +} +``` + +## Update Schedule-Specific Preparation + +Existing endpoints: + +```http +POST /schedules/{scheduleId}/preparations +PUT /schedules/{scheduleId}/preparations +``` + +New server-side guard: + +- Allowed only when `doneStatus == "NOT_ENDED"` and `startedAt == null`. +- Returns `409 SCHEDULE_ALREADY_STARTED` after preparation has started. +- Returns `409 SCHEDULE_ALREADY_FINISHED` after the schedule is finished. + +Reason: + +- Editing steps or durations after start would invalidate the active preparation flow. + +## Delete Schedule + +Existing endpoint: + +```http +DELETE /schedules/{scheduleId} +``` + +Rule: + +- Allowed when `doneStatus == "NOT_ENDED"`. +- Started but unfinished schedules can be deleted. +- Finished schedules cannot be deleted. + +Finished schedule error: + +```json +{ + "status": "error", + "code": "SCHEDULE_ALREADY_FINISHED", + "message": "Finished schedules cannot be edited.", + "data": null +} +``` + +## Default Preparation Updates + +Existing user-default preparation update remains allowed. + +```http +PUT /preparations +``` + +Frontend behavior: + +- Users may edit default preparation in settings even if they have already started a schedule. +- This updates only the default template. +- It does not change any started schedule's frozen preparation snapshot. +- Future or unstarted schedules that still use default preparation may resolve the updated default template. + +## Finish Preparation + +Existing endpoint: + +```http +PUT /schedules/{scheduleId}/finish +``` + +New server-side guard: + +- Allowed only after explicit start, when `startedAt != null`. +- Unstarted missed schedules remain `NOT_ENDED` and do not count toward punctuality score. +- Finished schedules still return `409 SCHEDULE_ALREADY_FINISHED`. + +Unstarted schedule error: + +```json +{ + "status": "error", + "code": "SCHEDULE_NOT_STARTED", + "message": "Schedules must be started before they can be finished.", + "data": null +} +``` + +Punctuality scoring rule: + +```text +includedInPunctualityScore = + startedAt != null + && finishedAt != null + && doneStatus in (NORMAL, LATE) +``` + +So a schedule with: + +```text +doneStatus == NOT_ENDED +startedAt == null +scheduleTime < now +``` + +is a missed/unstarted schedule. It can be deleted, but it is not auto-finished and does not affect punctuality score. + +`ABNORMAL` is also excluded from punctuality score. It is reserved for abnormal completion states and should not improve or worsen the score. + +## Alarm Window Response + +`GET /schedules/alarm-window` also includes `startedAt` and `finishedAt` for each schedule. + +```json +{ + "scheduleId": "3fa85f64-5717-4562-b3fc-2c963f66afe5", + "scheduleName": "Morning meeting", + "scheduleTime": "2026-05-13T09:30:00", + "moveTime": 20, + "scheduleSpareTime": 10, + "doneStatus": "NOT_ENDED", + "startedAt": "2026-05-13T08:15:30Z", + "finishedAt": null, + "preparationStartTime": "2026-05-13T08:40:00", + "defaultAlarmTime": "2026-05-13T08:30:00", + "preparations": [] +} +``` + +## Migration Notes For Flutter + +Recommended client changes: + +- Read `startedAt` from schedule responses. +- Read `finishedAt` when displaying explicit completion state. +- Treat `startedAt != null` as "preparation has started". +- Stop treating `isStarted` as authoritative. +- Call `POST /schedules/{id}/start` only when the user explicitly taps "Start preparation". +- On start success, replace local running preparation state with `data.preparations`. +- Hide or disable schedule/preparation edit actions when `startedAt != null`. +- Hide or disable edit/delete actions when `doneStatus != "NOT_ENDED"`, except delete is still allowed for started schedules if `doneStatus == "NOT_ENDED"`. +- Do not call finish for schedules that never successfully started. diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java index 6d2197c..d8bcca4 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java @@ -145,6 +145,24 @@ public ResponseEntity> getScheduleById( return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(schedule)); } + @Operation(summary = "준비 시작") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "준비 시작 성공"), + @ApiResponse(responseCode = "409", description = "이미 종료된 약속") + }) + @PostMapping("/{scheduleId}/start") + public ResponseEntity> startSchedule( + HttpServletRequest request, + @Parameter(description = "준비를 시작할 스케줄 ID (UUID 형식)", + required = true, + example = "3fa85f64-5717-4562-b3fc-2c963f66afe5") + @PathVariable UUID scheduleId) { + + Long userId = userAuthService.getUserIdFromToken(request); + StartScheduleResponseDto response = scheduleService.startSchedule(userId, scheduleId); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(response)); + } + // 약속 삭제 @Operation(summary = "사용자 일정 삭제", parameters = { diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java index 6dcd968..95f1cbd 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java @@ -4,6 +4,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -19,6 +20,8 @@ public class AlarmWindowScheduleDto { private Integer moveTime; private Integer scheduleSpareTime; private DoneStatus doneStatus; + private Instant startedAt; + private Instant finishedAt; private LocalDateTime preparationStartTime; private LocalDateTime defaultAlarmTime; private List preparations; diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java index ef333c8..1cbf9af 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java @@ -56,6 +56,8 @@ public Schedule toEntity(User user, Place place) { .scheduleTime(this.scheduleTime) .isChange(false) .isStarted(false) + .startedAt(null) + .finishedAt(null) .scheduleSpareTime(this.scheduleSpareTime) .latenessTime(-1) .scheduleNote(this.scheduleNote) diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java index 260f71e..3b8af77 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java @@ -5,7 +5,7 @@ import devkor.ontime_back.entity.User; import jakarta.persistence.*; import lombok.*; -import java.sql.Time; +import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; @@ -22,4 +22,20 @@ public class ScheduleDto { private String scheduleNote; private Integer latenessTime; private DoneStatus doneStatus; + private Instant startedAt; + private Instant finishedAt; + + public ScheduleDto(UUID scheduleId, PlaceDto place, String scheduleName, Integer moveTime, + LocalDateTime scheduleTime, Integer scheduleSpareTime, String scheduleNote, + Integer latenessTime, DoneStatus doneStatus) { + this(scheduleId, place, scheduleName, moveTime, scheduleTime, scheduleSpareTime, + scheduleNote, latenessTime, doneStatus, null, null); + } + + public ScheduleDto(UUID scheduleId, PlaceDto place, String scheduleName, Integer moveTime, + LocalDateTime scheduleTime, Integer scheduleSpareTime, String scheduleNote, + Integer latenessTime, DoneStatus doneStatus, Instant startedAt) { + this(scheduleId, place, scheduleName, moveTime, scheduleTime, scheduleSpareTime, + scheduleNote, latenessTime, doneStatus, startedAt, null); + } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/StartScheduleResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/StartScheduleResponseDto.java new file mode 100644 index 0000000..3b8b06f --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/StartScheduleResponseDto.java @@ -0,0 +1,13 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class StartScheduleResponseDto { + private ScheduleDto schedule; + private List preparations; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java b/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java index 6214392..3dbf587 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java @@ -6,7 +6,7 @@ import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; -import java.sql.Time; +import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; @@ -47,6 +47,12 @@ public class Schedule { private Boolean isStarted; // 버튼누름여부 + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "finished_at") + private Instant finishedAt; + @Enumerated(EnumType.STRING) private DoneStatus doneStatus; @@ -64,13 +70,12 @@ public void updateSchedule(Place place, ScheduleModDto scheduleModDto) { this.moveTime = scheduleModDto.getMoveTime(); this.scheduleTime = scheduleModDto.getScheduleTime(); this.scheduleSpareTime = scheduleModDto.getScheduleSpareTime(); - this.latenessTime = scheduleModDto.getLatenessTime(); this.scheduleNote = scheduleModDto.getScheduleNote(); - syncDoneStatusFromLatenessTime(); } - public void startSchedule() { + public void startSchedule(Instant startedAt) { this.isStarted = true; + this.startedAt = startedAt; } public void changePreparationSchedule() {this.isChange = true;} @@ -81,6 +86,18 @@ public void updateLatenessTime(Integer latenessTime) { syncDoneStatusFromLatenessTime(); } + public void finish(Integer latenessTime, Instant finishedAt) { + this.latenessTime = latenessTime; + this.finishedAt = finishedAt; + if (latenessTime == null) { + this.doneStatus = DoneStatus.ABNORMAL; + } else if (latenessTime > 0) { + this.doneStatus = DoneStatus.LATE; + } else { + this.doneStatus = DoneStatus.NORMAL; + } + } + private void syncDoneStatusFromLatenessTime() { if (latenessTime == null || latenessTime == -1) { this.doneStatus = DoneStatus.NOT_ENDED; @@ -94,4 +111,3 @@ private void syncDoneStatusFromLatenessTime() { } } } - diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java index 512960c..ddc4438 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java @@ -192,7 +192,7 @@ private void logGoogleIdentityTokenClaims(String identityToken) { Long expirationTimeSeconds = payload.getExpirationTimeSeconds(); long nowSeconds = System.currentTimeMillis() / 1000; log.info( - "Google identity token claims aud={}, azp={}, iss={}, exp={}, now={}, secondsUntilExp={}, audienceAllowed={}", + "Google identity credential claims aud={}, azp={}, iss={}, exp={}, now={}, secondsUntilExp={}, audienceAllowed={}", maskClientId(audience), payload.get("azp"), payload.getIssuer(), @@ -202,7 +202,7 @@ private void logGoogleIdentityTokenClaims(String identityToken) { validClientIds.contains(audience) ); } catch (Exception e) { - log.info("Google identity token claim parsing failed: {}", e.getClass().getSimpleName()); + log.info("Google identity credential claim parsing failed: {}", e.getClass().getSimpleName()); } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java index b16a62b..03b7e53 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java @@ -20,4 +20,6 @@ public interface PreparationScheduleRepository extends JpaRepository findByScheduleWithNextPreparation(@Param("schedule") Schedule schedule); void deleteBySchedule(Schedule schedule); + + boolean existsBySchedule(Schedule schedule); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java index f3ac024..6f82770 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java @@ -3,7 +3,9 @@ import devkor.ontime_back.entity.DoneStatus; import devkor.ontime_back.entity.Schedule; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -25,6 +27,10 @@ public interface ScheduleRepository extends JpaRepository { @Query("SELECT s FROM Schedule s JOIN FETCH s.user WHERE s.scheduleId = :scheduleId") Optional findByIdWithUser(@Param("scheduleId") UUID scheduleId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Schedule s JOIN FETCH s.user LEFT JOIN FETCH s.place WHERE s.scheduleId = :scheduleId") + Optional findByIdWithUserAndPlaceForUpdate(@Param("scheduleId") UUID scheduleId); + // deleteById()는 내부적으로 findById()를 실행하여 엔티티를 로드 후 삭제 -> JPQL DELETE를 사용해 한 번의 DELETE 쿼리만 실행 @Modifying @Query("DELETE FROM Schedule s WHERE s.scheduleId = :scheduleId") @@ -65,4 +71,9 @@ List findAlarmWindowSchedules(@Param("userId") Long userId, @Param("endDate") LocalDateTime endDate, @Param("doneStatus") DoneStatus doneStatus); + @Query("SELECT s FROM Schedule s JOIN FETCH s.user " + + "WHERE s.startedAt IS NOT NULL " + + "AND NOT EXISTS (SELECT ps FROM PreparationSchedule ps WHERE ps.schedule = s)") + List findStartedSchedulesWithoutPreparationSnapshot(); + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java b/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java index 89960db..f6f3f81 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java @@ -8,10 +8,10 @@ public class ApiResponseForm { // 제네릭 api 응답 객체 private String status; - private int code; + private Object code; private String message; private final T data; - public ApiResponseForm(String status, int code, String message, T data) { + public ApiResponseForm(String status, Object code, String message, T data) { this.status = status; // HttpResponse의 생성자 호출 (부모 클래스의 생성자 또는 메서드를 호출, 자식 클래스는 부모 클래스의 private 필드에 직접 접근 X) this.code = code; this.message = message; @@ -54,4 +54,8 @@ public static ApiResponseForm error(int code, String message, T data) { return new ApiResponseForm<>("error", code, message, data); } + public static ApiResponseForm error(String code, String message) { + return new ApiResponseForm<>("error", code, message, null); + } + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java index 366b15d..3cb837b 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java @@ -25,13 +25,15 @@ public enum ErrorCode { USER_SETTING_ALREADY_EXIST(1007, "이미 존재하는 userSettingId 입니다.", HttpStatus.BAD_REQUEST), PASSWORD_INCORRECT(1008, "기존 비밀번호가 틀렸습니다.", HttpStatus.BAD_REQUEST), SAME_PASSWORD(1009, "새 비밀번호와 기존 비밀번호가 일치합니다.", HttpStatus.BAD_REQUEST), - SCHEDULE_NOT_FOUND(1010, "해당 약속이 존재하지 않습니다.", HttpStatus.BAD_REQUEST), + SCHEDULE_NOT_FOUND(1010, "해당 약속이 존재하지 않습니다.", HttpStatus.NOT_FOUND), FIREBASE(1011, "FIREBASE로 메세지를 발송하였으나 오류가 발생했습니다.(유효하지 않은 토큰 등)", HttpStatus.BAD_REQUEST), FIRST_PREPARATION_NOT_FOUND(1012, "해당 ID의 사용자의 준비과정을 찾을 수 없습니다.", HttpStatus.BAD_REQUEST), NOTIFICATION_NOT_FOUND(1013, "알림을 찾을 수 없습니다.", HttpStatus.BAD_REQUEST ), PREPARATION_ALREADY_EXISTS(1014, "해당 사용자의 준비과정이 이미 존재합니다.", HttpStatus.BAD_REQUEST), - SCHEDULE_ALREADY_FINISHED(1015, "이미 종료된 약속입니다.", HttpStatus.BAD_REQUEST), + SCHEDULE_ALREADY_FINISHED(1015, "Finished schedules cannot be edited.", HttpStatus.CONFLICT), SCHEDULE_ID_MISMATCH(1016, "경로의 scheduleId와 요청 본문의 scheduleId가 일치하지 않습니다.", HttpStatus.BAD_REQUEST), + SCHEDULE_ALREADY_STARTED(1017, "Started schedules cannot be edited.", HttpStatus.CONFLICT), + SCHEDULE_NOT_STARTED(1018, "Schedules must be started before they can be finished.", HttpStatus.CONFLICT), ALARM_SETTINGS_INVALID_FIELD(1101, "ALARM_SETTINGS_INVALID_FIELD", HttpStatus.BAD_REQUEST), ALARM_WINDOW_RANGE_TOO_LONG(1102, "ALARM_WINDOW_RANGE_TOO_LONG", HttpStatus.BAD_REQUEST), DEVICE_SESSION_NOT_ACTIVE(1103, "DEVICE_SESSION_NOT_ACTIVE", HttpStatus.CONFLICT), diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java b/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java index 3bb7186..9e7f4c1 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/GlobalExceptionHandler.java @@ -27,6 +27,12 @@ public class GlobalExceptionHandler { public ResponseEntity> handleGeneralException(GeneralException e) { // GeneralException에서 ErrorCode를 가져와 처리 ErrorCode errorCode = e.getErrorCode(); + if (errorCode == ErrorCode.SCHEDULE_ALREADY_STARTED + || errorCode == ErrorCode.SCHEDULE_ALREADY_FINISHED + || errorCode == ErrorCode.SCHEDULE_NOT_STARTED) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ApiResponseForm.error(errorCode.name(), errorCode.getMessage())); + } return ResponseEntity.status(errorCode.getHttpStatus()) .body(ApiResponseForm.error(errorCode.getCode(), errorCode.getMessage())); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java index bfdeae1..3ee8b59 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java @@ -50,12 +50,13 @@ public void updatePreparationSchedules(Long userId, UUID scheduleId, List preparationDtoList, boolean shouldDelete) { - Schedule schedule = scheduleRepository.findByIdWithUser(scheduleId) + Schedule schedule = scheduleRepository.findByIdWithUserAndPlaceForUpdate(scheduleId) .orElseThrow(() -> new GeneralException(SCHEDULE_NOT_FOUND)); if (!schedule.getUser().getId().equals(userId)) { throw new GeneralException(UNAUTHORIZED_ACCESS); } + scheduleService.assertScheduleEditable(schedule); if (shouldDelete) { preparationScheduleRepository.deleteBySchedule(schedule); diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java index 5cfca01..3f7ee3f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java @@ -6,13 +6,15 @@ import devkor.ontime_back.response.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.time.Duration; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; @@ -48,6 +50,37 @@ private Schedule getScheduleWithAuthorization(UUID scheduleId, Long userId) { return schedule; } + private Schedule getLockedScheduleWithAuthorization(UUID scheduleId, Long userId) { + Schedule schedule = scheduleRepository.findByIdWithUserAndPlaceForUpdate(scheduleId) + .orElseThrow(() -> new GeneralException(SCHEDULE_NOT_FOUND)); + + if (!schedule.getUser().getId().equals(userId)) { + throw new GeneralException(UNAUTHORIZED_ACCESS); + } + + return schedule; + } + + public void assertScheduleEditable(Schedule schedule) { + if (isFinished(schedule)) { + throw new GeneralException(SCHEDULE_ALREADY_FINISHED); + } + if (schedule.getStartedAt() != null) { + throw new GeneralException(SCHEDULE_ALREADY_STARTED); + } + } + + private void assertScheduleNotFinished(Schedule schedule) { + if (isFinished(schedule)) { + throw new GeneralException(SCHEDULE_ALREADY_FINISHED); + } + } + + private boolean isFinished(Schedule schedule) { + return schedule.getFinishedAt() != null + || (schedule.getDoneStatus() != null && schedule.getDoneStatus() != DoneStatus.NOT_ENDED); + } + // 특정 기간의 약속 조회 public List showSchedulesByPeriod(Long userId, LocalDateTime startDate, LocalDateTime endDate) { Integer userSpareTime = userRepository.findSpareTimeById(userId); @@ -84,7 +117,8 @@ public ScheduleDto showScheduleByScheduleId(Long userId, UUID scheduleId) { // schedule 삭제 @Transactional public void deleteSchedule(UUID scheduleId, Long userId) { - getScheduleWithAuthorization(scheduleId, userId); + Schedule schedule = getLockedScheduleWithAuthorization(scheduleId, userId); + assertScheduleNotFinished(schedule); NotificationSchedule notification = notificationScheduleRepository.findByScheduleScheduleId(scheduleId) .orElseThrow(() -> new GeneralException(NOTIFICATION_NOT_FOUND)); scheduleRepository.deleteByScheduleId(scheduleId); @@ -94,7 +128,8 @@ public void deleteSchedule(UUID scheduleId, Long userId) { @Transactional public void modifySchedule(Long userId, UUID scheduleId, ScheduleModDto scheduleModDto) { User user = userRepository.findById(userId).orElseThrow(() -> new GeneralException(USER_NOT_FOUND)); - Schedule schedule = getScheduleWithAuthorization(scheduleId, userId); + Schedule schedule = getLockedScheduleWithAuthorization(scheduleId, userId); + assertScheduleEditable(schedule); Place place = placeRepository.findByPlaceName(scheduleModDto.getPlaceName()) .orElseGet(() -> placeRepository.save(new Place(scheduleModDto.getPlaceId(), scheduleModDto.getPlaceName()))); @@ -143,6 +178,74 @@ public void addSchedule(ScheduleAddDto scheduleAddDto, Long userId) { notificationService.scheduleReminder(notification); } + @Transactional + public StartScheduleResponseDto startSchedule(Long userId, UUID scheduleId) { + Schedule schedule = getLockedScheduleWithAuthorization(scheduleId, userId); + assertScheduleNotFinished(schedule); + + if (schedule.getStartedAt() == null) { + schedule.startSchedule(Instant.now()); + freezePreparationSnapshotIfNeeded(schedule); + scheduleRepository.save(schedule); + } + + return new StartScheduleResponseDto( + mapToDto(schedule), + getPreparations(userId, scheduleId) + ); + } + + private void freezePreparationSnapshotIfNeeded(Schedule schedule) { + boolean hasScheduleSpecificPreparations = preparationScheduleRepository.existsBySchedule(schedule); + if (!hasScheduleSpecificPreparations) { + copyDefaultPreparationsToSchedule(schedule); + } + schedule.changePreparationSchedule(); + } + + private void copyDefaultPreparationsToSchedule(Schedule schedule) { + List defaultPreparations = preparationUserRepository.findByUserIdWithNextPreparation(schedule.getUser().getId()); + Map preparationMap = new HashMap<>(); + + List snapshots = defaultPreparations.stream() + .map(defaultPreparation -> { + PreparationSchedule snapshot = new PreparationSchedule( + UUID.randomUUID(), + schedule, + defaultPreparation.getPreparationName(), + defaultPreparation.getPreparationTime(), + null + ); + preparationMap.put(defaultPreparation.getPreparationUserId(), snapshot); + return snapshot; + }) + .collect(Collectors.toList()); + + preparationScheduleRepository.saveAll(snapshots); + + defaultPreparations.stream() + .filter(defaultPreparation -> defaultPreparation.getNextPreparation() != null) + .forEach(defaultPreparation -> { + PreparationSchedule current = preparationMap.get(defaultPreparation.getPreparationUserId()); + PreparationSchedule next = preparationMap.get(defaultPreparation.getNextPreparation().getPreparationUserId()); + if (current != null && next != null) { + current.updateNextPreparation(next); + } + }); + + preparationScheduleRepository.saveAll(snapshots); + } + + @Transactional + public int repairStartedSchedulePreparationSnapshots() { + List schedules = scheduleRepository.findStartedSchedulesWithoutPreparationSnapshot(); + schedules.forEach(schedule -> { + freezePreparationSnapshotIfNeeded(schedule); + scheduleRepository.save(schedule); + }); + return schedules.size(); + } + public LocalDateTime getNotificationTime(Schedule schedule, User user) { Integer preparationTime = calculatePreparationTime(schedule, user); Integer moveTime = defaultNonNegative(schedule.getMoveTime()); @@ -174,7 +277,7 @@ public List getLatenessHistory(Long userId) { // 지각 시간 업데이트 @Transactional public void updateLatenessTime(Schedule schedule, Integer latenessTime) { - schedule.updateLatenessTime(latenessTime); + schedule.finish(latenessTime, Instant.now()); scheduleRepository.save(schedule); } @@ -187,22 +290,26 @@ public void finishSchedule(Long userId, UUID scheduleId, FinishPreparationDto fi throw new GeneralException(SCHEDULE_ID_MISMATCH); } - Schedule schedule = getScheduleWithAuthorization(scheduleId, userId); + Schedule schedule = getLockedScheduleWithAuthorization(scheduleId, userId); boolean alreadyFinishedByDoneStatus = schedule.getDoneStatus() != null && schedule.getDoneStatus() != DoneStatus.NOT_ENDED; boolean alreadyFinishedByLatenessTime = schedule.getLatenessTime() != null && schedule.getLatenessTime() != -1; - if (alreadyFinishedByDoneStatus || alreadyFinishedByLatenessTime) { + if (schedule.getFinishedAt() != null || alreadyFinishedByDoneStatus || alreadyFinishedByLatenessTime) { throw new GeneralException(SCHEDULE_ALREADY_FINISHED); } + if (schedule.getStartedAt() == null) { + throw new GeneralException(SCHEDULE_NOT_STARTED); + } - updateLatenessTime(schedule, finishPreparationDto.getLatenessTime()); - userService.updatePunctualityScore(userId, finishPreparationDto.getLatenessTime()); + schedule.finish(finishPreparationDto.getLatenessTime(), Instant.now()); + scheduleRepository.save(schedule); + userService.updatePunctualityScore(userId, schedule.getDoneStatus()); } // schedule에 따른 preparation 조회 public List getPreparations(Long userId, UUID scheduleId) { Schedule schedule = getScheduleWithAuthorization(scheduleId, userId); - if (Boolean.TRUE.equals(schedule.getIsChange())) { + if (schedule.getStartedAt() != null || Boolean.TRUE.equals(schedule.getIsChange())) { return preparationScheduleRepository.findByScheduleWithNextPreparation(schedule).stream() .map(preparationSchedule -> new PreparationDto( preparationSchedule.getPreparationScheduleId(), @@ -256,12 +363,14 @@ private ScheduleDto mapToDto(Schedule schedule) { (schedule.getScheduleSpareTime() == null) ? schedule.getUser().getSpareTime() : schedule.getScheduleSpareTime(), schedule.getScheduleNote(), schedule.getLatenessTime(), - schedule.getDoneStatus() + schedule.getDoneStatus(), + schedule.getStartedAt(), + schedule.getFinishedAt() ); } private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, List userPreparations, Integer defaultAlarmOffsetMinutes) { - List preparations = Boolean.TRUE.equals(schedule.getIsChange()) + List preparations = schedule.getStartedAt() != null || Boolean.TRUE.equals(schedule.getIsChange()) ? preparationScheduleRepository.findByScheduleWithNextPreparation(schedule).stream() .map(this::mapPreparationScheduleToDto) .collect(Collectors.toList()) @@ -286,6 +395,8 @@ private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, List 0) { + log.info("Repaired {} started schedules without preparation snapshots", repairedCount); + } + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/UserService.java b/ontime-back/src/main/java/devkor/ontime_back/service/UserService.java index 05e7cfe..d9772c7 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/UserService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/UserService.java @@ -2,6 +2,7 @@ import devkor.ontime_back.dto.UpdateSpareTimeDto; import devkor.ontime_back.dto.UserOnboardingDto; +import devkor.ontime_back.entity.DoneStatus; import devkor.ontime_back.entity.User; import devkor.ontime_back.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -37,18 +38,23 @@ public Float resetPunctualityScore(Long userId) { // 성실도 점수 업데이트 @Transactional - public User updatePunctualityScore(Long userId, Integer latenessTime) { + public User updatePunctualityScore(Long userId, DoneStatus doneStatus) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저 id입니다.")); + if (doneStatus != DoneStatus.NORMAL && doneStatus != DoneStatus.LATE) { + return user; + } + + boolean isLate = doneStatus == DoneStatus.LATE; if ( user.getPunctualityScore() == null || user.getPunctualityScore() == (float) -1) { // 초기화 이후 첫 약속 user.setScheduleCountAfterReset(1); - user.setLatenessCountAfterReset(latenessTime > 0 ? 1 : 0); + user.setLatenessCountAfterReset(isLate ? 1 : 0); } else { // 기존 성실도 점수가 존재 -> 약속 수와 지각 수 업데이트 user.setScheduleCountAfterReset(user.getScheduleCountAfterReset() + 1); - if (latenessTime > 0) { + if (isLate) { user.setLatenessCountAfterReset(user.getLatenessCountAfterReset() + 1); } } diff --git a/ontime-back/src/main/resources/db/migration/V12__add_started_at_to_schedule.sql b/ontime-back/src/main/resources/db/migration/V12__add_started_at_to_schedule.sql new file mode 100644 index 0000000..100c922 --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V12__add_started_at_to_schedule.sql @@ -0,0 +1 @@ +ALTER TABLE schedule ADD COLUMN started_at TIMESTAMP NULL; diff --git a/ontime-back/src/main/resources/db/migration/V13__add_finished_at_to_schedule.sql b/ontime-back/src/main/resources/db/migration/V13__add_finished_at_to_schedule.sql new file mode 100644 index 0000000..0cd004c --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V13__add_finished_at_to_schedule.sql @@ -0,0 +1 @@ +ALTER TABLE schedule ADD COLUMN finished_at TIMESTAMP NULL; diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java index 0eb6757..df17fd2 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java @@ -15,6 +15,7 @@ import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import java.time.Instant; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -287,6 +288,75 @@ void finishSchedule() throws Exception { verify(scheduleService, times(1)).finishSchedule(eq(userId), eq(scheduleId), any(FinishPreparationDto.class)); } + @DisplayName("준비 시작에 성공하면 startedAt과 준비과정을 반환한다.") + @Test + void startSchedule_success() throws Exception { + Long userId = 1L; + UUID scheduleId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afe5"); + Instant startedAt = Instant.parse("2026-05-13T10:15:30Z"); + ScheduleDto scheduleDto = new ScheduleDto( + scheduleId, + new PlaceDto(UUID.randomUUID(), "과학도서관"), + "공부하기", + 10, + LocalDateTime.of(2026, 5, 13, 19, 0), + 5, + "늦으면 안됨", + -1, + DoneStatus.NOT_ENDED, + startedAt + ); + List preparations = List.of( + new PreparationDto(UUID.randomUUID(), "세면", 10, null) + ); + + when(userAuthService.getUserIdFromToken(any())).thenReturn(userId); + when(scheduleService.startSchedule(userId, scheduleId)) + .thenReturn(new StartScheduleResponseDto(scheduleDto, preparations)); + + mockMvc.perform(post("/schedules/{scheduleId}/start", scheduleId) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.schedule.scheduleId").value(scheduleId.toString())) + .andExpect(jsonPath("$.data.schedule.startedAt").value("2026-05-13T10:15:30Z")) + .andExpect(jsonPath("$.data.preparations[0].preparationName").value("세면")); + + verify(scheduleService, times(1)).startSchedule(userId, scheduleId); + } + + @DisplayName("시작된 스케줄 수정 시 안정적인 문자열 에러 코드를 반환한다.") + @Test + void modifySchedule_failByAlreadyStarted() throws Exception { + Long userId = 1L; + UUID scheduleId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afe5"); + ScheduleModDto scheduleModDto = new ScheduleModDto( + UUID.randomUUID(), + "애기능생활관", + "학식먹기", + 10, + LocalDateTime.of(2026, 5, 13, 10, 0), + 20, + null, + "점심 식단 확인하자." + ); + + when(userAuthService.getUserIdFromToken(any())).thenReturn(userId); + doThrow(new GeneralException(ErrorCode.SCHEDULE_ALREADY_STARTED)) + .when(scheduleService).modifySchedule(eq(userId), eq(scheduleId), any(ScheduleModDto.class)); + + mockMvc.perform(put("/schedules/{scheduleId}", scheduleId) + .contentType(MediaType.APPLICATION_JSON) + .content(json(scheduleModDto))) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value("SCHEDULE_ALREADY_STARTED")) + .andExpect(jsonPath("$.message").value("Started schedules cannot be edited.")); + } + @DisplayName("약속 종료 요청에서 경로와 본문의 scheduleId가 다르면 400을 반환한다.") @Test void finishSchedule_failByScheduleIdMismatch() throws Exception { diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/PreparationScheduleServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/PreparationScheduleServiceTest.java index 4e4fd9c..ab20a4e 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/PreparationScheduleServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/PreparationScheduleServiceTest.java @@ -372,6 +372,48 @@ void handlePreparationSchedules_withUnauthorizedUser() { .isEqualTo(ErrorCode.UNAUTHORIZED_ACCESS); } + @Test + @DisplayName("시작된 스케줄의 준비과정 수정은 실패한다.") + void updatePreparationSchedules_rejectsStartedSchedule() { + User user = User.builder() + .email("started-prep@example.com") + .password(passwordEncoder.encode("password1234")) + .name("jinsuh") + .punctualityScore(-1f) + .scheduleCountAfterReset(0) + .latenessCountAfterReset(0) + .build(); + userRepository.save(user); -} + Place place = Place.builder() + .placeId(UUID.randomUUID()) + .placeName("과학도서관") + .build(); + placeRepository.save(place); + + Schedule schedule = Schedule.builder() + .scheduleId(UUID.randomUUID()) + .scheduleName("공부하기") + .scheduleTime(LocalDateTime.of(2027, 2, 23, 7, 0)) + .moveTime(10) + .latenessTime(-1) + .doneStatus(DoneStatus.NOT_ENDED) + .startedAt(java.time.Instant.now()) + .isStarted(true) + .isChange(true) + .place(place) + .user(user) + .build(); + scheduleRepository.save(schedule); + + List preparationDtoList = List.of( + new PreparationDto(UUID.randomUUID(), "세면", 10, null) + ); + + assertThatThrownBy(() -> preparationScheduleService.updatePreparationSchedules(user.getId(), schedule.getScheduleId(), preparationDtoList)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_STARTED); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java index 9f996d7..48fde1b 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java @@ -50,6 +50,9 @@ class ScheduleServiceTest { @Autowired private NotificationScheduleRepository notificationScheduleRepository; + @Autowired + private PreparationUserService preparationUserService; + @AfterEach void tearDown() { preparationUserRepository.deleteAll(); @@ -823,7 +826,8 @@ void modifySchedule_success() { assertThat(updatedSchedule.getScheduleNote()).isEqualTo("늦으면 안됨"); assertThat(updatedSchedule.getMoveTime()).isEqualTo(20); assertThat(updatedSchedule.getScheduleSpareTime()).isEqualTo(5); - assertThat(updatedSchedule.getLatenessTime()).isEqualTo(10); + assertThat(updatedSchedule.getLatenessTime()).isEqualTo(-1); + assertThat(updatedSchedule.getFinishedAt()).isNull(); } @@ -1223,6 +1227,7 @@ void updateLatenessTime(){ .scheduleName("을사년 새해") .scheduleTime(LocalDateTime.of(2025, 1, 1, 0, 0)) .latenessTime(-1) + .startedAt(java.time.Instant.now()) .user(addedUser) .build(); scheduleRepository.save(addedSchedule); @@ -1260,6 +1265,7 @@ void finishScheduleWithScheduleIdMismatch(){ .scheduleName("을사년 새해") .scheduleTime(LocalDateTime.of(2025, 1, 1, 0, 0)) .latenessTime(-1) + .startedAt(java.time.Instant.now()) .user(addedUser) .build(); scheduleRepository.save(addedSchedule); @@ -1304,6 +1310,7 @@ void finishSchedule(){ .scheduleName("을사년 새해") .scheduleTime(LocalDateTime.of(2025, 1, 1, 0, 0)) .latenessTime(-1) + .startedAt(java.time.Instant.now()) .user(addedUser) .build(); scheduleRepository.save(addedSchedule); @@ -1318,9 +1325,55 @@ void finishSchedule(){ // then assertThat(scheduleRepository.findById(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afe5")) .get().getLatenessTime()).isEqualTo(1); + assertThat(scheduleRepository.findById(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afe5")) + .get().getFinishedAt()).isNotNull(); assertThat(userRepository.findById(addedUser.getId()).get().getPunctualityScore()).isEqualTo(0f); } + @DisplayName("시작하지 않은 약속은 종료할 수 없고 성실도점수를 계산하지 않는다.") + @Test + void finishScheduleWithNotStartedSchedule(){ + User addedUser = User.builder() + .email("not-started@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .punctualityScore(-1f) + .scheduleCountAfterReset(0) + .latenessCountAfterReset(0) + .build(); + userRepository.save(addedUser); + + Schedule addedSchedule = Schedule.builder() + .scheduleId(UUID.randomUUID()) + .scheduleName("을사년 새해") + .scheduleTime(LocalDateTime.of(2025, 1, 1, 0, 0)) + .latenessTime(-1) + .doneStatus(DoneStatus.NOT_ENDED) + .startedAt(null) + .user(addedUser) + .build(); + scheduleRepository.save(addedSchedule); + + FinishPreparationDto finishPreparationDto = FinishPreparationDto.builder() + .scheduleId(addedSchedule.getScheduleId()) + .latenessTime(1) + .build(); + + assertThatThrownBy(() -> scheduleService.finishSchedule(addedUser.getId(), addedSchedule.getScheduleId(), finishPreparationDto)) + .isInstanceOf(GeneralException.class) + .hasMessage(ErrorCode.SCHEDULE_NOT_STARTED.getMessage()) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_NOT_STARTED); + + User user = userRepository.findById(addedUser.getId()).orElseThrow(); + Schedule schedule = scheduleRepository.findById(addedSchedule.getScheduleId()).orElseThrow(); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.NOT_ENDED); + assertThat(schedule.getLatenessTime()).isEqualTo(-1); + assertThat(user.getPunctualityScore()).isEqualTo(-1f); + assertThat(user.getScheduleCountAfterReset()).isEqualTo(0); + assertThat(user.getLatenessCountAfterReset()).isEqualTo(0); + } + @DisplayName("약속을 종료할 때, 잘못된 유저id를 인자로 넘기는 경우 예외가 발생한다.") @Test void finishScheduleWithWrongUserId(){ @@ -1692,4 +1745,183 @@ void getPreparations_failByWrongUser(){ .extracting("errorCode") .isEqualTo(ErrorCode.UNAUTHORIZED_ACCESS); } + + @Test + @DisplayName("준비 시작 시 startedAt을 설정하고 기본 준비과정을 스케줄 스냅샷으로 복사한다.") + void startSchedule_snapshotsDefaultPreparations() { + User user = saveUser("start-user@example.com"); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, null); + saveDefaultPreparations(user, "세면", "옷입기"); + + StartScheduleResponseDto response = scheduleService.startSchedule(user.getId(), schedule.getScheduleId()); + + Schedule startedSchedule = scheduleRepository.findById(schedule.getScheduleId()).orElseThrow(); + assertThat(startedSchedule.getStartedAt()).isNotNull(); + assertThat(startedSchedule.getIsChange()).isTrue(); + assertThat(response.getSchedule().getStartedAt()).isNotNull(); + assertThat(response.getPreparations()) + .extracting(PreparationDto::getPreparationName) + .containsExactlyInAnyOrder("세면", "옷입기"); + assertThat(preparationScheduleRepository.findByScheduleWithNextPreparation(startedSchedule)).hasSize(2); + } + + @Test + @DisplayName("준비 시작은 idempotent 하며 기존 startedAt과 스냅샷을 유지한다.") + void startSchedule_isIdempotent() { + User user = saveUser("idempotent-user@example.com"); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, null); + saveDefaultPreparations(user, "세면", "옷입기"); + + StartScheduleResponseDto firstResponse = scheduleService.startSchedule(user.getId(), schedule.getScheduleId()); + long firstSnapshotCount = preparationScheduleRepository.count(); + StartScheduleResponseDto secondResponse = scheduleService.startSchedule(user.getId(), schedule.getScheduleId()); + + assertThat(secondResponse.getSchedule().getStartedAt()).isEqualTo(firstResponse.getSchedule().getStartedAt()); + assertThat(preparationScheduleRepository.count()).isEqualTo(firstSnapshotCount); + } + + @Test + @DisplayName("시작된 스케줄은 기본 준비과정 변경 후에도 스냅샷 준비과정을 읽는다.") + void startedScheduleReadsFrozenPreparationsAfterDefaultUpdate() { + User user = saveUser("frozen-user@example.com"); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, null); + saveDefaultPreparations(user, "세면", "옷입기"); + scheduleService.startSchedule(user.getId(), schedule.getScheduleId()); + + List updatedDefaults = List.of( + new PreparationDto(UUID.randomUUID(), "운동하기", 5, null) + ); + preparationUserService.updatePreparationUsers(user.getId(), updatedDefaults); + + assertThat(scheduleService.getPreparations(user.getId(), schedule.getScheduleId())) + .extracting(PreparationDto::getPreparationName) + .containsExactlyInAnyOrder("세면", "옷입기"); + assertThat(preparationUserService.showAllPreparationUsers(user.getId())) + .extracting(PreparationDto::getPreparationName) + .containsExactly("운동하기"); + } + + @Test + @DisplayName("시작된 스케줄 수정은 SCHEDULE_ALREADY_STARTED로 실패한다.") + void modifySchedule_rejectsStartedSchedule() { + User user = saveUser("modify-started@example.com"); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, java.time.Instant.now()); + saveNotification(schedule); + + ScheduleModDto scheduleModDto = ScheduleModDto.builder() + .scheduleName("변경") + .scheduleTime(LocalDateTime.of(2027, 2, 24, 14, 0)) + .moveTime(20) + .scheduleNote("변경") + .placeId(place.getPlaceId()) + .placeName(place.getPlaceName()) + .scheduleSpareTime(5) + .latenessTime(null) + .build(); + + assertThatThrownBy(() -> scheduleService.modifySchedule(user.getId(), schedule.getScheduleId(), scheduleModDto)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_STARTED); + } + + @Test + @DisplayName("시작되었지만 끝나지 않은 스케줄은 삭제할 수 있다.") + void deleteSchedule_allowsStartedUnfinishedSchedule() { + User user = saveUser("delete-started@example.com"); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, java.time.Instant.now()); + saveNotification(schedule); + + scheduleService.deleteSchedule(schedule.getScheduleId(), user.getId()); + + assertThat(scheduleRepository.findById(schedule.getScheduleId())).isEmpty(); + } + + @Test + @DisplayName("종료된 스케줄은 시작할 수 없다.") + void startSchedule_rejectsFinishedSchedule() { + User user = saveUser("finished-start@example.com"); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NORMAL, null); + + assertThatThrownBy(() -> scheduleService.startSchedule(user.getId(), schedule.getScheduleId())) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.SCHEDULE_ALREADY_FINISHED); + } + + @Test + @DisplayName("알람 윈도우 스케줄 응답에 startedAt을 포함한다.") + void getAlarmWindowSchedules_includesStartedAt() { + User user = saveUser("alarm-window@example.com"); + user.updateAdditionalInfo(5, null); + userRepository.save(user); + Place place = savePlace(); + java.time.Instant startedAt = java.time.Instant.parse("2026-05-13T10:15:30Z"); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, startedAt); + + List schedules = scheduleService.getAlarmWindowSchedules( + user.getId(), + schedule.getScheduleTime().minusHours(1), + schedule.getScheduleTime().plusHours(1) + ); + + assertThat(schedules).hasSize(1); + assertThat(schedules.get(0).getStartedAt()).isEqualTo(startedAt); + } + + private User saveUser(String email) { + User user = User.builder() + .email(email) + .password(passwordEncoder.encode("password1234")) + .name(UUID.randomUUID().toString().substring(0, 8)) + .punctualityScore(-1f) + .scheduleCountAfterReset(0) + .latenessCountAfterReset(0) + .build(); + return userRepository.save(user); + } + + private Place savePlace() { + return placeRepository.save(Place.builder() + .placeId(UUID.randomUUID()) + .placeName("과학도서관") + .build()); + } + + private Schedule saveSchedule(User user, Place place, DoneStatus doneStatus, java.time.Instant startedAt) { + return scheduleRepository.save(Schedule.builder() + .scheduleId(UUID.randomUUID()) + .scheduleName("공부하기") + .scheduleTime(LocalDateTime.of(2027, 2, 23, 7, 0)) + .moveTime(10) + .latenessTime(-1) + .doneStatus(doneStatus) + .isStarted(startedAt != null) + .startedAt(startedAt) + .isChange(false) + .place(place) + .user(user) + .build()); + } + + private void saveDefaultPreparations(User user, String firstName, String secondName) { + PreparationUser second = preparationUserRepository.save(new PreparationUser( + UUID.randomUUID(), user, secondName, 15, null)); + preparationUserRepository.save(new PreparationUser( + UUID.randomUUID(), user, firstName, 10, second)); + } + + private void saveNotification(Schedule schedule) { + notificationScheduleRepository.save(NotificationSchedule.builder() + .notificationTime(LocalDateTime.of(2027, 2, 23, 6, 55)) + .isSent(false) + .schedule(schedule) + .build()); + } } diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java index b64ca0b..b895acb 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java @@ -3,6 +3,7 @@ import devkor.ontime_back.dto.UpdateSpareTimeDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserOnboardingDto; +import devkor.ontime_back.entity.DoneStatus; import devkor.ontime_back.entity.User; import devkor.ontime_back.repository.UserRepository; import org.junit.jupiter.api.AfterEach; @@ -152,7 +153,7 @@ void updatePunctualityFirstWithoutLateness(){ Long targetId = addedUser.getId(); // when - User updatedUser = userService.updatePunctualityScore(targetId, 0); + User updatedUser = userService.updatePunctualityScore(targetId, DoneStatus.NORMAL); // then assertThat(updatedUser.getId()).isNotNull(); @@ -181,7 +182,7 @@ void updatePunctualityFirstWithLateness(){ Long targetId = addedUser.getId(); // when - User updatedUser = userService.updatePunctualityScore(targetId, 1); + User updatedUser = userService.updatePunctualityScore(targetId, DoneStatus.LATE); // then assertThat(updatedUser.getId()).isNotNull(); @@ -210,7 +211,7 @@ void updatePunctualityNotFirstWithoutLateness(){ Long targetId = addedUser.getId(); // when - User updatedUser = userService.updatePunctualityScore(targetId, 0); + User updatedUser = userService.updatePunctualityScore(targetId, DoneStatus.NORMAL); // then assertThat(updatedUser.getId()).isNotNull(); @@ -239,7 +240,7 @@ void updatePunctualityNotFirstWithLateness(){ Long targetId = addedUser.getId(); // when - User updatedUser = userService.updatePunctualityScore(targetId, 1); + User updatedUser = userService.updatePunctualityScore(targetId, DoneStatus.LATE); // then assertThat(updatedUser.getId()).isNotNull(); @@ -249,6 +250,26 @@ void updatePunctualityNotFirstWithLateness(){ .contains(calculatePunctualityScore(4, 2), 4, 2); } + @DisplayName("ABNORMAL 상태는 성실도 점수 계산에 포함하지 않는다.") + @Test + void updatePunctualityWithAbnormalDoesNotCount(){ + User addedUser = User.builder() + .email("user@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .punctualityScore(calculatePunctualityScore(3, 1)) + .scheduleCountAfterReset(3) + .latenessCountAfterReset(1) + .build(); + userRepository.save(addedUser); + + User updatedUser = userService.updatePunctualityScore(addedUser.getId(), DoneStatus.ABNORMAL); + + assertThat(updatedUser) + .extracting("punctualityScore", "scheduleCountAfterReset", "latenessCountAfterReset") + .contains(calculatePunctualityScore(3, 1), 3, 1); + } + @DisplayName("성실도 점수 업데이트할 때 존재하지 않는 유저id를 인자로 넘기는 경우 예외가 발생한다.") @Test void updatePunctualityWithWrongUserId(){ @@ -265,7 +286,7 @@ void updatePunctualityWithWrongUserId(){ Long targetId = addedUser.getId() + 1; // when // then - assertThatThrownBy(() -> userService.updatePunctualityScore(targetId, 1)) + assertThatThrownBy(() -> userService.updatePunctualityScore(targetId, DoneStatus.LATE)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("존재하지 않는 유저 id입니다."); } @@ -321,4 +342,4 @@ float calculatePunctualityScore(int totalSchedules, int lateSchedules){ return (1 - ((float) lateSchedules / totalSchedules)) * 100; } -} \ No newline at end of file +} From 0c433ba670003ddffa224852b3a2058825bbc8db Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 13 May 2026 21:55:39 +0900 Subject: [PATCH 3/7] Stabilize schedule lifecycle CI tests --- .../devkor/ontime_back/service/ScheduleService.java | 11 ++++++++--- .../devkor/ontime_back/service/UserServiceTest.java | 6 ++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java index 3f7ee3f..9255039 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java @@ -12,6 +12,7 @@ import java.time.Instant; import java.time.Duration; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -184,7 +185,7 @@ public StartScheduleResponseDto startSchedule(Long userId, UUID scheduleId) { assertScheduleNotFinished(schedule); if (schedule.getStartedAt() == null) { - schedule.startSchedule(Instant.now()); + schedule.startSchedule(nowForPersistence()); freezePreparationSnapshotIfNeeded(schedule); scheduleRepository.save(schedule); } @@ -277,7 +278,7 @@ public List getLatenessHistory(Long userId) { // 지각 시간 업데이트 @Transactional public void updateLatenessTime(Schedule schedule, Integer latenessTime) { - schedule.finish(latenessTime, Instant.now()); + schedule.finish(latenessTime, nowForPersistence()); scheduleRepository.save(schedule); } @@ -300,7 +301,7 @@ public void finishSchedule(Long userId, UUID scheduleId, FinishPreparationDto fi throw new GeneralException(SCHEDULE_NOT_STARTED); } - schedule.finish(finishPreparationDto.getLatenessTime(), Instant.now()); + schedule.finish(finishPreparationDto.getLatenessTime(), nowForPersistence()); scheduleRepository.save(schedule); userService.updatePunctualityScore(userId, schedule.getDoneStatus()); } @@ -437,4 +438,8 @@ private Integer defaultNonNegative(Integer value) { return value == null ? 0 : Math.max(value, 0); } + private Instant nowForPersistence() { + return Instant.now().truncatedTo(ChronoUnit.SECONDS); + } + } diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java index b895acb..b214d01 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.data.Offset.offset; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -265,9 +266,10 @@ void updatePunctualityWithAbnormalDoesNotCount(){ User updatedUser = userService.updatePunctualityScore(addedUser.getId(), DoneStatus.ABNORMAL); + assertThat(updatedUser.getPunctualityScore()).isCloseTo(calculatePunctualityScore(3, 1), offset(0.0001f)); assertThat(updatedUser) - .extracting("punctualityScore", "scheduleCountAfterReset", "latenessCountAfterReset") - .contains(calculatePunctualityScore(3, 1), 3, 1); + .extracting("scheduleCountAfterReset", "latenessCountAfterReset") + .contains(3, 1); } @DisplayName("성실도 점수 업데이트할 때 존재하지 않는 유저id를 인자로 넘기는 경우 예외가 발생한다.") From f62b321001eca25cb5ec960d30c36bc32ca1b4be Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Thu, 14 May 2026 13:40:44 +0900 Subject: [PATCH 4/7] Make Swagger match backend behavior --- .../ontime_back/config/SwaggerConfig.java | 117 ++++++++++++++++-- .../controller/SocialAuthController.java | 37 +++--- .../controller/UserAuthController.java | 23 ++-- .../global/oauth/kakao/KakaoLoginFilter.java | 1 - .../ontime_back/response/ApiResponseForm.java | 13 ++ .../response/ValidationErrorResponse.java | 16 ++- .../ontime_back/config/SwaggerConfigTest.java | 70 +++++++++++ 7 files changed, 244 insertions(+), 33 deletions(-) create mode 100644 ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java index 816e2da..fe10976 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java @@ -1,44 +1,147 @@ package devkor.ontime_back.config; import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Collections; +import java.util.List; +import java.util.Set; + @Configuration @OpenAPIDefinition( servers = { @Server(url = "https://3.38.172.54.nip.io", description = "New Production Server"), - @Server(url = "http://localhost:8080", description = "Local Serever") + @Server(url = "http://localhost:8080", description = "Local Server") } ) public class SwaggerConfig { + private static final String ACCESS_TOKEN_SCHEME = "accessToken"; + private static final String REFRESH_TOKEN_SCHEME = "refreshToken"; + + private static final Set PUBLIC_PATHS = Set.of( + "/", + "/health", + "/account-deletion", + "/privacy-policy", + "/sign-up", + "/login", + "/oauth2/google/login", + "/oauth2/kakao/login", + "/oauth2/apple/login", + "/swagger-ui.html", + "/error" + ); + + private static final List PUBLIC_PATH_PREFIXES = List.of( + "/actuator/health", + "/v3/api-docs", + "/swagger-ui", + "/swagger-resources", + "/webjars", + "/css", + "/images", + "/js", + "/favicon.ico", + "/h2-console" + ); + @Bean public OpenAPI openAPI() { return new OpenAPI() .components(new Components() - .addSecuritySchemes("accessToken", new SecurityScheme() - .name("Authorization") // 헤더 이름 + .addSecuritySchemes(ACCESS_TOKEN_SCHEME, new SecurityScheme() + .name("Authorization") .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT") + .description("Send as `Authorization: Bearer `.") + ) + .addSecuritySchemes(REFRESH_TOKEN_SCHEME, new SecurityScheme() + .name("Authorization-refresh") + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .description("Send as `Authorization-refresh: Bearer ` to reissue an access token.") ) ) - .addSecurityItem(new SecurityRequirement().addList("accessToken")) // 요청에 SecurityScheme 적용 + .addSecurityItem(new SecurityRequirement().addList(ACCESS_TOKEN_SCHEME)) .info(apiInfo()); } + @Bean + public OpenApiCustomizer coherentOperationCustomizer() { + return openApi -> { + if (openApi.getPaths() == null) { + return; + } + + openApi.getPaths().forEach((path, pathItem) -> { + if (pathItem == null) { + return; + } + + pathItem.readOperationsMap().forEach((httpMethod, operation) -> { + if (httpMethod == PathItem.HttpMethod.GET) { + operation.setRequestBody(null); + } + if (isPublicPath(path)) { + operation.setSecurity(Collections.emptyList()); + } + removeStaleResponseExamples(operation); + }); + }); + }; + } + private Info apiInfo() { return new Info() .title("Ontime") - .description("Ontime API 명세서\n\n\n\n [JWT 인증 과정]\n\n/sign-up, /login, /{userId}/additional-info\n\n위 세 url을 제외하고는 헤더에 엑세스 토큰을 담아 요청을 보내야 함.\n\n(형식: \"Authorization [엑세스 토큰]\")\n\n\n토큰이 유효하면 요청이 처리될 것이고, 토큰이 유효하지 않으면 실패메세지가 반환될 것임.\n\n\n 엑세스토큰 인증이 실패하면 동일한 url(사실 아무 url이나 상관 없음. 실제로 해당 url로 요청 보내기전에 필터가 가로채서 처리함)로 헤더에 리프레시토큰을 담아 요청을 보내면 리프레시토큰의 유효성에 따라 엑세스토큰이 ResponseBody 재발급 될 것임.\n\n(형식: \"Authorization-refresh [리프레시토큰]\")") + .description(""" + Ontime API 명세서 + + [JWT 인증 과정] + 공개 엔드포인트(`/sign-up`, `/login`, `/oauth2/google/login`, `/oauth2/kakao/login`, `/oauth2/apple/login`, `/health`, `/account-deletion`, `/privacy-policy`)를 제외한 API는 access token이 필요합니다. + + Access token 요청 형식: `Authorization: Bearer ` + + Refresh token으로 access token을 재발급할 때는 보호 API 호출 전에 `Authorization-refresh: Bearer ` 헤더를 보냅니다. 재발급 성공 시 새 access token은 응답 헤더 `Authorization`으로 반환됩니다. + + 일반 로그인과 소셜 로그인, 회원가입 성공 시 access token은 `Authorization` 헤더로, refresh token은 `Authorization-refresh` 헤더로 반환됩니다. + """) .version("1.0.0"); } + + private boolean isPublicPath(String path) { + return PUBLIC_PATHS.contains(path) + || PUBLIC_PATH_PREFIXES.stream().anyMatch(path::startsWith); + } + + private void removeStaleResponseExamples(Operation operation) { + if (operation.getResponses() == null) { + return; + } + + operation.getResponses().values().forEach(apiResponse -> { + if (apiResponse.getContent() == null) { + return; + } + + apiResponse.getContent().values().forEach(mediaType -> { + mediaType.setExample(null); + mediaType.setExamples(null); + if (mediaType.getSchema() != null) { + mediaType.getSchema().setExample(null); + } + }); + }); + } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java index 97244a1..8e53076 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java @@ -2,13 +2,14 @@ import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.OAuthAppleRequestDto; -import devkor.ontime_back.dto.OAuthGoogleUserDto; +import devkor.ontime_back.dto.OAuthGoogleRequestDto; import devkor.ontime_back.dto.OAuthKakaoUserDto; import devkor.ontime_back.global.oauth.apple.AppleLoginService; import devkor.ontime_back.global.oauth.google.GoogleLoginService; import devkor.ontime_back.response.ApiResponseForm; import devkor.ontime_back.service.UserAuthService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -34,27 +35,30 @@ public class SocialAuthController { @Operation( summary = "구글 소셜 로그인/회원가입", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "구글 회원정보 데이터", + description = "구글 identity token 데이터", required = true, content = @Content( schema = @Schema( - type = "object", - example = "{\n \"idToken\": \"eyJhbGxxxxxxx\" ,\n \"refreshToken\": \"\"}}" + implementation = OAuthGoogleRequestDto.class, + example = "{\n \"idToken\": \"eyJhbGxxxxxxx\",\n \"refreshToken\": \"google-refresh-token\"\n}" ) ) ) ) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "구글 로그인/회원가입 성공 (로그인시 data : login, 회원가입시 data : register", content = @Content( + @ApiResponse(responseCode = "200", description = "구글 로그인/회원가입 성공. 토큰은 응답 헤더에 반환됨", headers = { + @Header(name = "Authorization", description = "Bearer access token"), + @Header(name = "Authorization-refresh", description = "Bearer refresh token") + }, content = @Content( mediaType = "application/json", schema = @Schema( - example = "{\n \"message\": \"유저의 ROLE이 GUEST이므로 온보딩API를 호출해 온보딩을 진행해야합니다.\",\n \"role\": \"GUEST\"}" + example = "{\n \"status\": \"success\",\n \"code\": \"200\",\n \"message\": \"로그인에 성공하였습니다.\",\n \"data\": {\n \"userId\": 1,\n \"email\": \"user@example.com\",\n \"name\": \"junbeom\",\n \"spareTime\": 10,\n \"note\": null,\n \"punctualityScore\": 100.0,\n \"role\": \"USER\"\n }\n}" ) )), @ApiResponse(responseCode = "4XX", description = "실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(이메일이 이미 존재할 경우, 이름이 이미 존재할 경우 다르게 출력)"))) }) @PostMapping("/google/login") - public String googleRegisterOrLogin(@Valid @RequestBody OAuthGoogleUserDto oAuthGoogleUserDto, HttpServletResponse response) { + public String googleRegisterOrLogin(@Valid @RequestBody OAuthGoogleRequestDto oAuthGoogleRequestDto, HttpServletResponse response) { return "구글 로그인/회원가입 성공"; // 로그인 처리는 필터에서 적용 } @@ -65,14 +69,17 @@ public String googleRegisterOrLogin(@Valid @RequestBody OAuthGoogleUserDto oAuth required = true, content = @Content( schema = @Schema( - type = "object", - example = "{\n \"id\": \"4803687123\", \n \"profile\": {\n \"nickname\": \"김철수\", \n \"thumbnail_image_url\": \"http://dfsklafj;ewoai.jpg\", \n \"profile_image_url\": \"http://dfsklafj;ewoai.jpg\", \n\"is_default_image\": false, \n \"is_default_nickname\": false\n }\n}" + implementation = OAuthKakaoUserDto.class, + example = "{\n \"id\": \"4803687123\",\n \"profile\": {\n \"nickname\": \"김철수\",\n \"thumbnailImageUrl\": \"https://example.com/thumb.jpg\",\n \"profile_image_url\": \"https://example.com/profile.jpg\",\n \"defaultImage\": false,\n \"defaultNickname\": false\n }\n}" ) ) ) ) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "카카오 로그인/회원가입 성공 (로그인시 data : login, 회원가입시 data : register", content = @Content( + @ApiResponse(responseCode = "200", description = "카카오 로그인/회원가입 성공. 토큰은 응답 헤더에 반환됨", headers = { + @Header(name = "Authorization", description = "Bearer access token"), + @Header(name = "Authorization-refresh", description = "Bearer refresh token") + }, content = @Content( mediaType = "application/json", schema = @Schema( example = "{\n \"message\": \"유저의 ROLE이 GUEST이므로 온보딩API를 호출해 온보딩을 진행해야합니다.\",\n \"role\": \"GUEST\"}" @@ -92,17 +99,19 @@ public String kakaoRegisterOrLogin(@Valid @RequestBody OAuthKakaoUserDto oAuthKa required = true, content = @Content( schema = @Schema( - type = "object", + implementation = OAuthAppleRequestDto.class, example = "{\n \"idToken\": \".\",\n \"authCode\": \".\",\n \"fullName\": \"허진서\" }" ) ) ) ) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "카카오 로그인/회원가입 성공 (로그인시 data : login, 회원가입시 data : register", content = @Content( + @ApiResponse(responseCode = "200", description = "애플 로그인/회원가입 성공. 토큰은 응답 헤더에 반환됨", headers = { + @Header(name = "Authorization", description = "Bearer access token"), + @Header(name = "Authorization-refresh", description = "Bearer refresh token") + }, content = @Content( mediaType = "application/json", schema = @Schema( - type = "object", - example = "{\n \"status\": \"success\",\n \"code\": \"200\",\n \"message\": \"%s\",\n \"data\": { \"userId\": %d,\n \"email\": \"%s\",\n \"name\": \"%s\",\n \"spareTime\": \"%s\",\n \"note\": \"%s\",\n \"punctualityScore\": %f,\n \"role\": \"%s\" }\n }" + example = "{\n \"status\": \"success\",\n \"code\": \"200\",\n \"message\": \"로그인에 성공하였습니다.\",\n \"data\": {\n \"userId\": 1,\n \"email\": \"user@example.com\",\n \"name\": \"junbeom\",\n \"spareTime\": 10,\n \"note\": null,\n \"punctualityScore\": 100.0,\n \"role\": \"USER\"\n }\n}" ) )), @ApiResponse(responseCode = "4XX", description = "실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(이메일이 이미 존재할 경우, 이름이 이미 존재할 경우 다르게 출력)"))) diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java index c45375d..2b79cac 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java @@ -5,11 +5,10 @@ import devkor.ontime_back.dto.LoginRequestDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; -import devkor.ontime_back.entity.User; -import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.response.ApiResponseForm; import devkor.ontime_back.service.UserAuthService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -34,15 +33,18 @@ public class UserAuthController { description = "회원가입 요청 JSON 데이터", required = true, content = @Content( - schema = @Schema(type = "object", example = "{\"email\": \"user@example.com\", \"password\": \"password123\", \"name\": \"junbeom\"}") + schema = @Schema(implementation = UserSignUpDto.class, example = "{\"email\": \"user@example.com\", \"password\": \"password123!\", \"name\": \"junbeom\"}") ) ) ) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "회원가입 성공", content = @Content( + @ApiResponse(responseCode = "200", description = "회원가입 성공", headers = { + @Header(name = "Authorization", description = "Bearer access token"), + @Header(name = "Authorization-refresh", description = "Bearer refresh token") + }, content = @Content( mediaType = "application/json", schema = @Schema( - example = "{\n \"status\": \"success\",\n \"code\": \"200\",\n \"message\": \"회원가입이 성공적으로 완료되었습니다. 온보딩을 진행해주세요( /user/onboarding )\",\n \"data\": {\n \"userId\": 1\n }}" + example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"회원가입이 성공적으로 완료되었습니다. 온보딩을 진행해주세요( /user/onboarding )\",\n \"data\": {\n \"userId\": 1,\n \"email\": \"user@example.com\",\n \"name\": \"junbeom\",\n \"spareTime\": null,\n \"note\": null,\n \"punctualityScore\": null,\n \"role\": \"GUEST\",\n \"socialType\": null\n }\n}" ) )), @ApiResponse(responseCode = "4XX", description = "회원가입 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(이메일이 이미 존재할 경우, 이름이 이미 존재할 경우 다르게 출력)"))) @@ -57,7 +59,10 @@ public ResponseEntity> signUp(HttpServletReque @Operation(summary = "일반 로그인 (로그인 요청을 통해 JWT 토큰을 발급받음)") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "일반 로그인 성공(반환 문자열 없음. 헤더에 토큰 반환)", content = @Content(mediaType = "application/json", schema = @Schema(example = "{\n \"message\": \"유저의 ROLE이 GUEST이므로 온보딩API를 호출해 온보딩을 진행해야합니다.\", \"role\": \"GUEST\"}"))), + @ApiResponse(responseCode = "200", description = "일반 로그인 성공. 토큰은 응답 헤더에 반환됨", headers = { + @Header(name = "Authorization", description = "Bearer access token"), + @Header(name = "Authorization-refresh", description = "Bearer refresh token") + }, content = @Content(mediaType = "application/json", schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": \"200\",\n \"message\": \"로그인에 성공하였습니다.\",\n \"data\": {\n \"userId\": 1,\n \"email\": \"user@example.com\",\n \"name\": \"junbeom\",\n \"spareTime\": 10,\n \"note\": null,\n \"punctualityScore\": 100.0,\n \"role\": \"USER\"\n }\n}"))), @ApiResponse(responseCode = "4XX", description = "일반 로그인 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(정확히 어떤 메세지인지는 모름)"))) }) @PostMapping("/login") @@ -66,7 +71,7 @@ public String login( description = "로그인 요청 JSON 데이터", required = true, content = @Content( - schema = @Schema(type = "object", example = "{\"email\": \"user@example.com\", \"password\": \"password123\"}") + schema = @Schema(implementation = LoginRequestDto.class, example = "{\"email\": \"user@example.com\", \"password\": \"password123!\"}") ) ) @Valid @RequestBody LoginRequestDto loginRequest) { @@ -81,7 +86,7 @@ public String login( required = true, content = @Content( schema = @Schema( - type = "object", + implementation = ChangePasswordDto.class, example = "{\"currentPassword\": \"password123\", \"newPassword\": \"1q2w3e4r!\"}" ) ) @@ -111,7 +116,7 @@ public ResponseEntity> changePassword(HttpServletRequest description = "계정 삭제 요청 JSON 데이터는 선택사항. 탈퇴 피드백을 남기려면 feedbackId, message를 전달", content = @Content( schema = @Schema( - type = "object", + implementation = FeedbackAddDto.class, example = "{\"feedbackId\": \"d784cde3-9ff9-4054-872a-500bbcc2198a\", \"message\": \"탈퇴 피드백입니다.\"}" ) ) diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java index 6c6e105..43c7847 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java @@ -151,7 +151,6 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR log.info("카카오 로그인 성공"); SecurityContextHolder.getContext().setAuthentication(authResult); response.setStatus(HttpServletResponse.SC_OK); - response.getWriter().write("{\"status\":\"success\", \"data\":\"login/register\"}"); } // 인증 실패 처리 diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java b/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java index 89960db..77337ba 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ApiResponseForm.java @@ -1,15 +1,28 @@ package devkor.ontime_back.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @Getter +@Schema(description = "공통 API 응답 래퍼") // @AllArgsConstructor(access = AccessLevel.PRIVATE) -> super 사용시 이용불가 // @EqualsAndHashCode(callSuper = true) // equals()와 hashCode() 메서드를 자동으로 생성하도록 public class ApiResponseForm { // 제네릭 api 응답 객체 + @Schema( + description = "응답 상태. 성공 응답은 success, 일반 오류는 error, JWT 필터 오류는 토큰 상태별 값을 반환합니다.", + example = "success", + allowableValues = {"success", "fail", "error", "accessTokenEmpty", "accessTokenInvalid", "refreshTokenInvalid"} + ) private String status; + + @Schema(description = "애플리케이션 응답 코드", example = "200") private int code; + + @Schema(description = "응답 메시지", example = "OK") private String message; + + @Schema(description = "응답 데이터. 오류 응답에서는 null일 수 있습니다.", nullable = true) private final T data; public ApiResponseForm(String status, int code, String message, T data) { this.status = status; // HttpResponse의 생성자 호출 (부모 클래스의 생성자 또는 메서드를 호출, 자식 클래스는 부모 클래스의 private 필드에 직접 접근 X) diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java b/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java index 9540e7f..24c9030 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ValidationErrorResponse.java @@ -1,9 +1,21 @@ package devkor.ontime_back.response; +import io.swagger.v3.oas.annotations.media.Schema; + import java.util.List; -public record ValidationErrorResponse(List errors) { +@Schema(description = "요청 검증 실패 상세") +public record ValidationErrorResponse( + @Schema(description = "필드별 검증 오류 목록") + List errors +) { - public record FieldError(String field, String message) { + @Schema(description = "필드 검증 오류") + public record FieldError( + @Schema(description = "오류가 발생한 필드명", example = "email") + String field, + @Schema(description = "검증 실패 메시지", example = "이메일 형식이 올바르지 않습니다.") + String message + ) { } } diff --git a/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java b/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java new file mode 100644 index 0000000..06a374f --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java @@ -0,0 +1,70 @@ +package devkor.ontime_back.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SwaggerConfigTest { + + private final SwaggerConfig swaggerConfig = new SwaggerConfig(); + + @Test + void openApiDocumentsRealTokenHeaders() { + OpenAPI openAPI = swaggerConfig.openAPI(); + + assertThat(openAPI.getComponents().getSecuritySchemes()) + .containsKeys("accessToken", "refreshToken"); + assertThat(openAPI.getSecurity()) + .containsExactly(new SecurityRequirement().addList("accessToken")); + assertThat(openAPI.getInfo().getDescription()) + .contains("Authorization: Bearer ") + .contains("Authorization-refresh: Bearer ") + .contains("응답 헤더 `Authorization`"); + } + + @Test + void customizerRemovesGetBodiesAndClearsSecurityForPublicEndpoints() { + MediaType staleResponseContent = new MediaType() + .schema(new StringSchema().example("stale")) + .example("stale"); + Operation privateGet = new Operation() + .requestBody(new RequestBody()) + .security(List.of(new SecurityRequirement().addList("accessToken"))) + .responses(new ApiResponses() + .addApiResponse("200", new ApiResponse() + .content(new Content().addMediaType("application/json", staleResponseContent)))); + Operation publicPost = new Operation() + .security(List.of(new SecurityRequirement().addList("accessToken"))); + Operation privatePost = new Operation() + .security(List.of(new SecurityRequirement().addList("accessToken"))); + + OpenAPI openAPI = new OpenAPI().paths(new Paths() + .addPathItem("/schedules", new PathItem() + .get(privateGet) + .post(privatePost)) + .addPathItem("/login", new PathItem() + .post(publicPost))); + + swaggerConfig.coherentOperationCustomizer().customise(openAPI); + + assertThat(privateGet.getRequestBody()).isNull(); + assertThat(privateGet.getSecurity()).containsExactly(new SecurityRequirement().addList("accessToken")); + assertThat(staleResponseContent.getExample()).isNull(); + assertThat(staleResponseContent.getSchema().getExample()).isNull(); + assertThat(privatePost.getSecurity()).containsExactly(new SecurityRequirement().addList("accessToken")); + assertThat(publicPost.getSecurity()).isEmpty(); + } +} From 19b366d348f27c990562dbcbe2cabddce867212c Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Thu, 14 May 2026 14:13:04 +0900 Subject: [PATCH 5/7] Add Swagger examples for API cases --- .../ontime_back/config/SwaggerConfig.java | 326 ++++++++++++++++++ .../ontime_back/config/SwaggerConfigTest.java | 120 ++++++- 2 files changed, 445 insertions(+), 1 deletion(-) diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java index fe10976..bb4a958 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java @@ -1,12 +1,20 @@ package devkor.ontime_back.config; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.examples.Example; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.springdoc.core.customizers.OpenApiCustomizer; @@ -14,7 +22,10 @@ import org.springframework.context.annotation.Configuration; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; @Configuration @@ -27,6 +38,10 @@ public class SwaggerConfig { private static final String ACCESS_TOKEN_SCHEME = "accessToken"; private static final String REFRESH_TOKEN_SCHEME = "refreshToken"; + private static final String JSON = "application/json"; + private static final String TEXT = "text/plain"; + private static final String HTML = "text/html"; + private static final ObjectMapper EXAMPLE_OBJECT_MAPPER = new ObjectMapper(); private static final Set PUBLIC_PATHS = Set.of( "/", @@ -55,6 +70,27 @@ public class SwaggerConfig { "/h2-console" ); + private static final Set NO_REQUEST_BODY_OPERATIONS = Set.of( + "POST /friends/links", + "POST /firebase-token/push-test", + "POST /schedules/{scheduleId}/start", + "PUT /users/me/punctuality-score", + "PUT /users/me/settings/reset" + ); + + private static final Set CONFLICT_OPERATIONS = Set.of( + "PATCH /users/me/alarm-settings", + "PUT /users/me/devices/current", + "DELETE /users/me/devices/current", + "POST /users/me/alarm-status", + "POST /schedules/{scheduleId}/start", + "PUT /schedules/{scheduleId}", + "DELETE /schedules/{scheduleId}", + "PUT /schedules/{scheduleId}/finish", + "POST /schedules/{scheduleId}/preparations", + "PUT /schedules/{scheduleId}/preparations" + ); + @Bean public OpenAPI openAPI() { return new OpenAPI() @@ -90,6 +126,7 @@ public OpenApiCustomizer coherentOperationCustomizer() { } pathItem.readOperationsMap().forEach((httpMethod, operation) -> { + String operationKey = operationKey(httpMethod, path); if (httpMethod == PathItem.HttpMethod.GET) { operation.setRequestBody(null); } @@ -97,6 +134,8 @@ public OpenApiCustomizer coherentOperationCustomizer() { operation.setSecurity(Collections.emptyList()); } removeStaleResponseExamples(operation); + addRequestExamples(operationKey, httpMethod, operation); + addResponseExamples(operationKey, path, httpMethod, operation); }); }); }; @@ -144,4 +183,291 @@ private void removeStaleResponseExamples(Operation operation) { }); }); } + + private String operationKey(PathItem.HttpMethod httpMethod, String path) { + return httpMethod.name() + " " + path; + } + + private void addRequestExamples(String operationKey, PathItem.HttpMethod httpMethod, Operation operation) { + if (httpMethod == PathItem.HttpMethod.GET || NO_REQUEST_BODY_OPERATIONS.contains(operationKey)) { + operation.setRequestBody(null); + return; + } + + Map examples = requestExamples(operationKey); + if (examples.isEmpty()) { + return; + } + + RequestBody requestBody = Optional.ofNullable(operation.getRequestBody()).orElseGet(RequestBody::new); + Content content = Optional.ofNullable(requestBody.getContent()).orElseGet(Content::new); + MediaType jsonContent = Optional.ofNullable(content.get(JSON)).orElseGet(MediaType::new); + examples.forEach((name, example) -> jsonContent.addExamples(name, example.toOpenApiExample())); + content.addMediaType(JSON, jsonContent); + requestBody.setContent(content); + operation.setRequestBody(requestBody); + } + + private void addResponseExamples(String operationKey, String path, PathItem.HttpMethod httpMethod, Operation operation) { + ApiResponses responses = Optional.ofNullable(operation.getResponses()).orElseGet(ApiResponses::new); + + if (isHtmlEndpoint(path)) { + ensureResponseExample(responses, "200", HTML, new NamedExample("HTML page", "HTML document returned by the public policy page.", "...")); + operation.setResponses(responses); + return; + } + + if ("/health".equals(path)) { + ensureResponseExample(responses, "200", TEXT, new NamedExample("Health check success", "Plain text load-balancer health response.", "Success Health Check")); + operation.setResponses(responses); + return; + } + + ensureResponseExample(responses, "200", JSON, successExample(operationKey, operation)); + ensureResponseExample(responses, "400", JSON, validationErrorExample()); + ensureResponseExample(responses, "400", JSON, malformedJsonExample()); + + if (!isPublicPath(path)) { + ensureResponseExample(responses, "401", JSON, accessTokenEmptyExample()); + ensureResponseExample(responses, "401", JSON, accessTokenInvalidExample()); + ensureResponseExample(responses, "401", JSON, refreshTokenInvalidExample()); + } + + if (path.contains("{scheduleId}") || path.contains("{uuid}")) { + ensureResponseExample(responses, "404", JSON, notFoundExample(path.contains("{uuid}") ? "친구 요청 링크를 찾을 수 없습니다." : "해당 약속이 존재하지 않습니다.")); + } + + if (CONFLICT_OPERATIONS.contains(operationKey)) { + ensureResponseExample(responses, "409", JSON, conflictExample(operationKey)); + } + + ensureResponseExample(responses, "500", JSON, unexpectedErrorExample()); + operation.setResponses(responses); + } + + private boolean isHtmlEndpoint(String path) { + return path.startsWith("/account-deletion") || path.startsWith("/privacy-policy"); + } + + private void ensureResponseExample(ApiResponses responses, String code, String mediaTypeName, NamedExample namedExample) { + ApiResponse response = Optional.ofNullable(responses.get(code)).orElseGet(ApiResponse::new); + Content content = Optional.ofNullable(response.getContent()).orElseGet(Content::new); + MediaType mediaType = Optional.ofNullable(content.get(mediaTypeName)).orElseGet(MediaType::new); + mediaType.addExamples(namedExample.name(), namedExample.toOpenApiExample()); + content.addMediaType(mediaTypeName, mediaType); + response.setContent(content); + if (response.getDescription() == null || response.getDescription().isBlank()) { + response.setDescription(defaultDescription(code)); + } + responses.addApiResponse(code, response); + } + + private String defaultDescription(String code) { + return switch (code) { + case "200" -> "Success"; + case "400" -> "Bad request"; + case "401" -> "Unauthorized"; + case "404" -> "Not found"; + case "409" -> "Conflict"; + case "500" -> "Unexpected server error"; + default -> "Response"; + }; + } + + private Map requestExamples(String operationKey) { + Map examples = new LinkedHashMap<>(); + switch (operationKey) { + case "PATCH /users/me/alarm-settings" -> { + examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}")); + examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}")); + } + case "PUT /users/me/devices/current" -> { + examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}")); + examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}")); + } + case "DELETE /users/me/devices/current" -> { + examples.put("with_device_id", json("Unregister explicit device", "Optional device ID body.", "{\"deviceId\":\"ios-device-000001\"}")); + examples.put("without_body", json("Unregister current token-bound device", "Body may be omitted.", "{}")); + } + case "POST /users/me/alarm-status" -> { + examples.put("armed_status", json("Armed status report", "Reports a successfully reconciled native alarm window.", "{\"deviceId\":\"ios-device-000001\",\"reconciledAt\":\"2026-05-05T09:00:00+09:00\",\"scheduleWindowStart\":\"2026-05-05T00:00:00\",\"scheduleWindowEnd\":\"2026-05-06T00:00:00\",\"alarmCoverageStart\":\"2026-05-05T00:00:00\",\"alarmCoverageEnd\":\"2026-05-06T00:00:00\",\"status\":\"armed\",\"permissionIssue\":null,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\",\"armedScheduleCount\":1,\"armedScheduleIds\":[\"3fa85f64-5717-4562-b3fc-2c963f66afe5\"],\"skippedScheduleCount\":0,\"failures\":[]}")); + examples.put("invalid_range", json("Invalid date range", "scheduleWindowStart must be before scheduleWindowEnd.", "{\"deviceId\":\"ios-device-000001\",\"reconciledAt\":\"2026-05-05T09:00:00+09:00\",\"scheduleWindowStart\":\"2026-05-06T00:00:00\",\"scheduleWindowEnd\":\"2026-05-05T00:00:00\",\"alarmCoverageStart\":\"2026-05-05T00:00:00\",\"alarmCoverageEnd\":\"2026-05-06T00:00:00\",\"status\":\"bad\",\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}")); + } + case "POST /feedback" -> feedbackExamples(examples, "피드백입니다. 이런게 아쉬워요"); + case "POST /firebase-token" -> { + examples.put("valid_token", json("Valid FCM token", "Stores the FCM token and optionally links it to a registered device.", "{\"firebaseToken\":\"fcm-token-abc123\",\"deviceId\":\"ios-device-000001\"}")); + examples.put("invalid_token", json("Invalid FCM token", "firebaseToken is required and deviceId must match the device ID format.", "{\"firebaseToken\":\"\",\"deviceId\":\"short\"}")); + } + case "POST /friends/{uuid}/approve" -> { + examples.put("accept", json("Accept friend request", "Accepts the pending friend request.", "{\"acceptStatus\":\"ACCEPTED\"}")); + examples.put("reject", json("Reject friend request", "Rejects the pending friend request.", "{\"acceptStatus\":\"REJECTED\"}")); + examples.put("invalid_status", json("Invalid accept status", "Only ACCEPTED or REJECTED is allowed.", "{\"acceptStatus\":\"PENDING\"}")); + } + case "POST /schedules/{scheduleId}/preparations", "PUT /schedules/{scheduleId}/preparations", "PUT /users/preparations" -> preparationListExamples(examples); + case "PUT /schedules/{scheduleId}" -> scheduleModExamples(examples); + case "POST /schedules" -> scheduleAddExamples(examples); + case "PUT /schedules/{scheduleId}/finish" -> { + examples.put("on_time_finish", json("Finish on time", "latenessTime is zero when the user is not late.", "{\"latenessTime\":0}")); + examples.put("late_finish", json("Finish late", "latenessTime is minutes late and must be 0-1440.", "{\"latenessTime\":12}")); + examples.put("invalid_lateness", json("Invalid lateness", "Negative latenessTime is rejected.", "{\"latenessTime\":-1}")); + } + case "POST /oauth2/google/login" -> { + examples.put("valid_google_identity", json("Valid Google login", "Google identity token from the client.", "{\"idToken\":\"eyJhbGciOi...\",\"refreshToken\":\"google-refresh-token\"}")); + examples.put("missing_id_token", json("Missing Google token", "idToken is required.", "{\"refreshToken\":\"google-refresh-token\"}")); + } + case "POST /oauth2/kakao/login" -> { + examples.put("valid_kakao_profile", json("Valid Kakao login", "Kakao profile payload from the client.", "{\"id\":\"4803687123\",\"profile\":{\"nickname\":\"김철수\",\"thumbnailImageUrl\":\"https://example.com/thumb.jpg\",\"profile_image_url\":\"https://example.com/profile.jpg\",\"defaultImage\":false,\"defaultNickname\":false}}")); + examples.put("missing_profile", json("Missing Kakao profile", "profile is required.", "{\"id\":\"4803687123\"}")); + } + case "POST /oauth2/apple/login" -> { + examples.put("valid_apple_login", json("Valid Apple login", "Apple identity token, auth code, and profile fields.", "{\"idToken\":\"eyJhbGciOi...\",\"authCode\":\"apple-auth-code\",\"fullName\":\"허진서\",\"email\":\"user@example.com\"}")); + examples.put("missing_auth_code", json("Missing Apple auth code", "authCode is required.", "{\"idToken\":\"eyJhbGciOi...\",\"fullName\":\"허진서\"}")); + } + case "DELETE /oauth2/apple/me", "DELETE /oauth2/google/me", "DELETE /users/me/delete" -> { + examples.put("without_feedback", json("Delete without feedback", "The request body may be omitted.", "{}")); + feedbackExamples(examples, "탈퇴 피드백입니다."); + } + case "POST /sign-up" -> { + examples.put("valid_signup", json("Valid signup", "Password must include letters, digits, and special characters.", "{\"email\":\"user@example.com\",\"password\":\"password123!\",\"name\":\"junbeom\"}")); + examples.put("invalid_signup", json("Invalid signup", "Invalid email, weak password, and blank name are rejected.", "{\"email\":\"bad-email\",\"password\":\"password123\",\"name\":\"\"}")); + } + case "POST /login" -> { + examples.put("valid_login", json("Valid login", "General login credentials.", "{\"email\":\"user@example.com\",\"password\":\"password123!\"}")); + examples.put("invalid_login", json("Invalid login", "Invalid email format and short password are rejected.", "{\"email\":\"bad-email\",\"password\":\"short\"}")); + } + case "PUT /users/me/password" -> { + examples.put("valid_change", json("Valid password change", "New password must include letters, digits, and special characters.", "{\"currentPassword\":\"password123!\",\"newPassword\":\"newPassword123!\"}")); + examples.put("same_password", json("Same password", "Business error when the new password matches the current password.", "{\"currentPassword\":\"password123!\",\"newPassword\":\"password123!\"}")); + } + case "PUT /users/me/spare-time" -> { + examples.put("valid_spare_time", json("Valid spare time", "newSpareTime must be 0-1440.", "{\"newSpareTime\":30}")); + examples.put("invalid_spare_time", json("Invalid spare time", "Negative spare time is rejected.", "{\"newSpareTime\":-1}")); + } + case "PUT /users/me/onboarding" -> onboardingExamples(examples); + case "PUT /users/me/settings" -> { + examples.put("valid_settings", json("Valid settings update", "All fields are optional, but provided numeric values are validated.", "{\"isNotificationsEnabled\":true,\"soundVolume\":75,\"isPlayOnSpeaker\":false,\"is24HourFormat\":true}")); + examples.put("invalid_volume", json("Invalid volume", "soundVolume must be 0-100.", "{\"soundVolume\":101}")); + } + default -> { + } + } + return examples; + } + + private void feedbackExamples(Map examples, String message) { + examples.put("with_feedback", json("With feedback", "Optional feedback payload.", "{\"feedbackId\":\"d784cde3-9ff9-4054-872a-500bbcc2198a\",\"message\":\"" + message + "\"}")); + examples.put("invalid_feedback", json("Invalid feedback", "message must be 1000 characters or fewer.", "{\"message\":\"" + "x".repeat(80) + "...\"}")); + } + + private void preparationListExamples(Map examples) { + examples.put("valid_preparations", json("Valid preparation list", "Each item requires an id, name, and time.", "[{\"preparationId\":\"123e4567-e89b-12d3-a456-426614174011\",\"preparationName\":\"기상하기\",\"preparationTime\":10,\"nextPreparationId\":\"123e4567-e89b-12d3-a456-426614174012\"},{\"preparationId\":\"123e4567-e89b-12d3-a456-426614174012\",\"preparationName\":\"세수하기\",\"preparationTime\":10,\"nextPreparationId\":null}]")); + examples.put("empty_preparations", json("Empty preparation list", "At least one preparation is required.", "[]")); + } + + private void scheduleAddExamples(Map examples) { + examples.put("valid_schedule", json("Valid schedule create", "Creates a new schedule and place association.", "{\"scheduleId\":\"3fa85f64-5717-4562-b3fc-2c963f66afe5\",\"placeId\":\"70d460da-6a82-4c57-a285-567cdeda5670\",\"placeName\":\"Home\",\"scheduleName\":\"Birthday Party\",\"moveTime\":10,\"scheduleTime\":\"2026-05-15T19:30:00\",\"isChange\":false,\"isStarted\":false,\"scheduleSpareTime\":20,\"scheduleNote\":\"Write a message.\"}")); + examples.put("invalid_schedule", json("Invalid schedule create", "Blank names and negative times are rejected.", "{\"scheduleId\":\"3fa85f64-5717-4562-b3fc-2c963f66afe5\",\"placeId\":\"70d460da-6a82-4c57-a285-567cdeda5670\",\"placeName\":\"\",\"scheduleName\":\"\",\"moveTime\":-1,\"scheduleTime\":\"2020-01-01T09:00:00\",\"scheduleSpareTime\":-1}")); + } + + private void scheduleModExamples(Map examples) { + examples.put("valid_schedule_update", json("Valid schedule update", "Updates the existing schedule referenced by the path.", "{\"placeId\":\"70d460da-6a82-4c57-a285-567cdeda5670\",\"placeName\":\"Office\",\"scheduleName\":\"Team Meeting\",\"moveTime\":20,\"scheduleTime\":\"2026-05-15T09:30:00\",\"scheduleSpareTime\":10,\"scheduleNote\":\"Bring laptop\",\"latenessTime\":0}")); + examples.put("invalid_schedule_update", json("Invalid schedule update", "Required names and non-negative times are validated.", "{\"placeId\":\"70d460da-6a82-4c57-a285-567cdeda5670\",\"placeName\":\"\",\"scheduleName\":\"\",\"moveTime\":-1,\"scheduleTime\":\"2026-05-15T09:30:00\"}")); + } + + private void onboardingExamples(Map examples) { + examples.put("valid_onboarding", json("Valid onboarding", "Sets spare time, note, and first preparation list.", "{\"spareTime\":30,\"note\":\"내 인생에 지각은 없다!!!\",\"preparationList\":[{\"preparationId\":\"123e4567-e89b-12d3-a456-426614174011\",\"preparationName\":\"기상하기\",\"preparationTime\":10,\"nextPreparationId\":null}]}")); + examples.put("invalid_onboarding", json("Invalid onboarding", "preparationList must not be empty and spareTime must be 0-1440.", "{\"spareTime\":-1,\"note\":\"too early\",\"preparationList\":[]}")); + } + + private NamedExample successExample(String operationKey, Operation operation) { + String message = Optional.ofNullable(operation.getSummary()).orElse("요청") + " 성공"; + String data = successData(operationKey); + return json("Success", "Successful response body.", "{\"status\":\"success\",\"code\":200,\"message\":\"" + escape(message) + "\",\"data\":" + data + "}"); + } + + private String successData(String operationKey) { + return switch (operationKey) { + case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}"; + case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}"; + case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}"; + case "DELETE /users/me/devices/current" -> "{\"active\":false}"; + case "POST /users/me/alarm-status" -> "{\"received\":true}"; + case "GET /users/me/alarm-status" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\",\"lastSeenAt\":\"2026-05-05T00:00:00Z\",\"reconciledAt\":\"2026-05-05T00:00:00Z\",\"scheduleWindowStart\":\"2026-05-05T00:00:00\",\"scheduleWindowEnd\":\"2026-05-06T00:00:00\",\"alarmCoverageStart\":\"2026-05-05T00:00:00\",\"alarmCoverageEnd\":\"2026-05-06T00:00:00\",\"status\":\"armed\",\"permissionIssue\":null,\"armedScheduleCount\":1,\"armedScheduleIds\":[\"3fa85f64-5717-4562-b3fc-2c963f66afe5\"],\"skippedScheduleCount\":0,\"failures\":[],\"updatedAt\":\"2026-05-05T00:00:00Z\"}"; + case "GET /documents/terms", "GET /documents/privacy", "GET /documents/ontime-description" -> "\"문서 본문입니다.\""; + case "POST /friends/links" -> "{\"friendShipId\":\"3fa85f64-5717-4562-b3fc-2c963f66afe5\"}"; + case "GET /friends/{uuid}/requests" -> "{\"requesterId\":2,\"requesterName\":\"junbeom\",\"requesterEmail\":\"requester@example.com\"}"; + case "GET /friends" -> "{\"friendsList\":[{\"friendId\":2,\"friendName\":\"junbeom\",\"friendEmail\":\"friend@example.com\"}]}"; + case "GET /schedules", "GET /schedules/alarm-window" -> "[" + scheduleData() + "]"; + case "GET /schedules/{scheduleId}" -> scheduleData(); + case "POST /schedules/{scheduleId}/start" -> "{\"scheduleId\":\"3fa85f64-5717-4562-b3fc-2c963f66afe5\",\"startedAt\":\"2026-05-15T08:40:00\",\"preparationCount\":2}"; + case "GET /schedules/lateness-history" -> "[{\"scheduleId\":\"3fa85f64-5717-4562-b3fc-2c963f66afe5\",\"scheduleName\":\"Morning meeting\",\"scheduleTime\":\"2026-05-15T09:30:00\",\"latenessTime\":12}]"; + case "GET /schedules/{scheduleId}/preparations", "GET /users/preparations" -> "[{\"preparationId\":\"123e4567-e89b-12d3-a456-426614174011\",\"preparationName\":\"기상하기\",\"preparationTime\":10,\"nextPreparationId\":null}]"; + case "GET /users/me/punctuality-score" -> "{\"punctualityScore\":97.5}"; + case "GET /users/me" -> "{\"userId\":1,\"email\":\"user@example.com\",\"name\":\"junbeom\",\"spareTime\":30,\"note\":\"내 인생에 지각은 없다!!!\",\"punctualityScore\":97.5,\"role\":\"USER\",\"socialType\":null}"; + case "POST /sign-up" -> "{\"userId\":1,\"email\":\"user@example.com\",\"name\":\"junbeom\",\"spareTime\":null,\"note\":null,\"punctualityScore\":null,\"role\":\"GUEST\",\"socialType\":null}"; + case "POST /login", "POST /oauth2/google/login", "POST /oauth2/apple/login" -> "{\"userId\":1,\"email\":\"user@example.com\",\"name\":\"junbeom\",\"spareTime\":10,\"note\":null,\"punctualityScore\":100.0,\"role\":\"USER\"}"; + case "POST /oauth2/kakao/login" -> "{\"message\":\"회원가입이 완료되었습니다. ROLE이 GUEST이므로 온보딩이 필요합니다.\",\"role\":\"GUEST\"}"; + default -> "null"; + }; + } + + private String scheduleData() { + return "{\"scheduleId\":\"3fa85f64-5717-4562-b3fc-2c963f66afe5\",\"place\":{\"placeId\":\"70d460da-6a82-4c57-a285-567cdeda5670\",\"placeName\":\"Office\"},\"scheduleName\":\"Morning meeting\",\"moveTime\":20,\"scheduleTime\":\"2026-05-15T09:30:00\",\"scheduleSpareTime\":10,\"scheduleNote\":\"Bring laptop\",\"latenessTime\":null,\"doneStatus\":\"NOT_STARTED\"}"; + } + + private NamedExample validationErrorExample() { + return json("Validation error", "Bean validation or parameter validation failed.", "{\"status\":\"error\",\"code\":1002,\"message\":\"유효하지 않은 입력값입니다.\",\"data\":{\"errors\":[{\"field\":\"request\",\"message\":\"필수 요청 값입니다.\"}]}}"); + } + + private NamedExample malformedJsonExample() { + return json("Malformed JSON", "Request body could not be parsed.", "{\"status\":\"error\",\"code\":1002,\"message\":\"유효하지 않은 입력값입니다.\",\"data\":{\"errors\":[{\"field\":\"request\",\"message\":\"요청 형식이 올바르지 않습니다.\"}]}}"); + } + + private NamedExample accessTokenEmptyExample() { + return json("Missing access token", "Authorization header was omitted.", "{\"status\":\"accessTokenEmpty\",\"code\":401,\"message\":\"Unauthorized: You must authenticate to access this resource.\",\"data\":null}"); + } + + private NamedExample accessTokenInvalidExample() { + return json("Invalid access token", "Authorization bearer token is expired, unknown, or invalid.", "{\"status\":\"accessTokenInvalid\",\"code\":401,\"message\":\"Unauthorized: You must authenticate to access this resource.\",\"data\":null}"); + } + + private NamedExample refreshTokenInvalidExample() { + return json("Invalid refresh token", "Authorization-refresh bearer token is expired, unknown, or invalid.", "{\"status\":\"refreshTokenInvalid\",\"code\":401,\"message\":\"Unauthorized: You must authenticate to access this resource.\",\"data\":null}"); + } + + private NamedExample notFoundExample(String message) { + return json("Not found", "Referenced path resource does not exist or is not visible to the user.", "{\"status\":\"error\",\"code\":1010,\"message\":\"" + escape(message) + "\",\"data\":null}"); + } + + private NamedExample conflictExample(String operationKey) { + String message = operationKey.contains("devices") || operationKey.contains("alarm-status") + ? "DEVICE_SESSION_NOT_ACTIVE" + : "Started or finished schedules cannot be edited."; + return json("Conflict", "Current resource state does not allow the requested operation.", "{\"status\":\"error\",\"code\":409,\"message\":\"" + escape(message) + "\",\"data\":null}"); + } + + private NamedExample unexpectedErrorExample() { + return json("Unexpected error", "Unhandled server error response.", "{\"status\":\"error\",\"code\":1000,\"message\":\"Unexpected Error: An unexpected error occurred.\",\"data\":null}"); + } + + private NamedExample json(String name, String description, String value) { + try { + return new NamedExample(name, description, EXAMPLE_OBJECT_MAPPER.readTree(value)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid Swagger example JSON: " + name, e); + } + } + + private String escape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private record NamedExample(String name, String description, Object value) { + private Example toOpenApiExample() { + return new Example() + .summary(name) + .description(description) + .value(value); + } + } } diff --git a/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java b/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java index 06a374f..f82a22d 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/config/SwaggerConfigTest.java @@ -13,7 +13,9 @@ import io.swagger.v3.oas.models.security.SecurityRequirement; import org.junit.jupiter.api.Test; +import java.util.Map; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -36,7 +38,7 @@ void openApiDocumentsRealTokenHeaders() { } @Test - void customizerRemovesGetBodiesAndClearsSecurityForPublicEndpoints() { + void customizerAddsOperationExamplesAndKeepsCoherenceRules() { MediaType staleResponseContent = new MediaType() .schema(new StringSchema().example("stale")) .example("stale"); @@ -64,7 +66,123 @@ void customizerRemovesGetBodiesAndClearsSecurityForPublicEndpoints() { assertThat(privateGet.getSecurity()).containsExactly(new SecurityRequirement().addList("accessToken")); assertThat(staleResponseContent.getExample()).isNull(); assertThat(staleResponseContent.getSchema().getExample()).isNull(); + assertThat(responseExamples(privateGet, "200")).containsKey("Success"); + assertThat(responseExamples(privateGet, "400")).containsKeys("Validation error", "Malformed JSON"); + assertThat(responseExamples(privateGet, "401")).containsKeys("Missing access token", "Invalid access token", "Invalid refresh token"); + assertThat(responseExamples(privateGet, "500")).containsKey("Unexpected error"); assertThat(privatePost.getSecurity()).containsExactly(new SecurityRequirement().addList("accessToken")); + assertThat(responseExamples(privatePost, "200")).containsKey("Success"); assertThat(publicPost.getSecurity()).isEmpty(); + assertThat(responseExamples(publicPost, "200")).containsKey("Success"); + assertThat(responseExamples(publicPost, "401")).isEmpty(); + } + + @Test + void customizerAddsExamplesForEveryControllerEndpointCase() { + OpenAPI openAPI = new OpenAPI().paths(new Paths() + .addPathItem("/health", new PathItem().get(operation())) + .addPathItem("/account-deletion", new PathItem().get(operation())) + .addPathItem("/account-deletion/en", new PathItem().get(operation())) + .addPathItem("/privacy-policy", new PathItem().get(operation())) + .addPathItem("/privacy-policy/en", new PathItem().get(operation())) + .addPathItem("/users/me/alarm-settings", new PathItem().get(operation()).patch(operationWithBody())) + .addPathItem("/users/me/devices/current", new PathItem().put(operationWithBody()).delete(operationWithBody())) + .addPathItem("/users/me/alarm-status", new PathItem().post(operationWithBody()).get(operation())) + .addPathItem("/documents/terms", new PathItem().get(operation())) + .addPathItem("/documents/privacy", new PathItem().get(operation())) + .addPathItem("/documents/ontime-description", new PathItem().get(operation())) + .addPathItem("/feedback", new PathItem().post(operationWithBody())) + .addPathItem("/firebase-token", new PathItem().post(operationWithBody())) + .addPathItem("/firebase-token/push-test", new PathItem().post(operation())) + .addPathItem("/friends/links", new PathItem().post(operation())) + .addPathItem("/friends/{uuid}/requests", new PathItem().get(operation())) + .addPathItem("/friends/{uuid}/approve", new PathItem().post(operationWithBody())) + .addPathItem("/friends", new PathItem().get(operation())) + .addPathItem("/schedules", new PathItem().get(operation()).post(operationWithBody())) + .addPathItem("/schedules/alarm-window", new PathItem().get(operation())) + .addPathItem("/schedules/{scheduleId}", new PathItem().get(operation()).put(operationWithBody()).delete(operation())) + .addPathItem("/schedules/{scheduleId}/start", new PathItem().post(operation())) + .addPathItem("/schedules/lateness-history", new PathItem().get(operation())) + .addPathItem("/schedules/{scheduleId}/preparations", new PathItem().get(operation()).post(operationWithBody()).put(operationWithBody())) + .addPathItem("/schedules/{scheduleId}/finish", new PathItem().put(operationWithBody())) + .addPathItem("/oauth2/google/login", new PathItem().post(operationWithBody())) + .addPathItem("/oauth2/kakao/login", new PathItem().post(operationWithBody())) + .addPathItem("/oauth2/apple/login", new PathItem().post(operationWithBody())) + .addPathItem("/oauth2/apple/me", new PathItem().delete(operationWithBody())) + .addPathItem("/oauth2/google/me", new PathItem().delete(operationWithBody())) + .addPathItem("/sign-up", new PathItem().post(operationWithBody())) + .addPathItem("/login", new PathItem().post(operationWithBody())) + .addPathItem("/users/me/password", new PathItem().put(operationWithBody())) + .addPathItem("/users/me/delete", new PathItem().delete(operationWithBody())) + .addPathItem("/users/me/punctuality-score", new PathItem().get(operation()).put(operation())) + .addPathItem("/users/me/spare-time", new PathItem().put(operationWithBody())) + .addPathItem("/users/me/onboarding", new PathItem().put(operationWithBody())) + .addPathItem("/users/me", new PathItem().get(operation())) + .addPathItem("/users/preparations", new PathItem().put(operationWithBody()).get(operation())) + .addPathItem("/users/me/settings", new PathItem().put(operationWithBody())) + .addPathItem("/users/me/settings/reset", new PathItem().put(operation()))); + + swaggerConfig.coherentOperationCustomizer().customise(openAPI); + + openAPI.getPaths().forEach((path, pathItem) -> + pathItem.readOperationsMap().forEach((method, operation) -> { + assertThat(anyResponseExamples(operation, "200")) + .as("%s %s success examples", method, path) + .isNotEmpty(); + + if (!path.startsWith("/account-deletion") && !path.startsWith("/privacy-policy") && !"/health".equals(path)) { + assertThat(responseExamples(operation, "400")) + .as("%s %s bad request examples", method, path) + .isNotEmpty(); + assertThat(responseExamples(operation, "500")) + .as("%s %s unexpected error examples", method, path) + .isNotEmpty(); + } + + if (operation.getRequestBody() != null) { + assertThat(requestExamples(operation)) + .as("%s %s request examples", method, path) + .isNotEmpty(); + } + })); + } + + private Operation operation() { + return new Operation() + .summary("test operation") + .responses(new ApiResponses() + .addApiResponse("200", new ApiResponse().description("OK"))); + } + + private Operation operationWithBody() { + return operation().requestBody(new RequestBody()); + } + + private Map requestExamples(Operation operation) { + return Optional.ofNullable(operation.getRequestBody()) + .map(RequestBody::getContent) + .map(content -> content.get("application/json")) + .map(MediaType::getExamples) + .orElse(Map.of()); + } + + private Map responseExamples(Operation operation, String code) { + return Optional.ofNullable(operation.getResponses()) + .map(responses -> responses.get(code)) + .map(ApiResponse::getContent) + .map(content -> content.get("application/json")) + .map(MediaType::getExamples) + .orElse(Map.of()); + } + + private Map anyResponseExamples(Operation operation, String code) { + return Optional.ofNullable(operation.getResponses()) + .map(responses -> responses.get(code)) + .map(ApiResponse::getContent) + .flatMap(content -> content.values().stream() + .map(MediaType::getExamples) + .filter(examples -> examples != null && !examples.isEmpty()) + .findFirst()) + .orElse(Map.of()); } } From 982a79c0d9fb1c879081727447d644abe56b2eec Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Fri, 15 May 2026 02:36:21 +0900 Subject: [PATCH 6/7] Raise backend coverage to 95 percent --- .github/workflows/test.yml | 12 +- ontime-back/build.gradle | 27 + .../global/oauth/apple/AppleLoginService.java | 2 +- .../oauth/google/GoogleLoginService.java | 30 +- .../global/oauth/kakao/KakaoLoginFilter.java | 2 +- .../ontime_back/ControllerTestSupport.java | 3 +- .../devkor/ontime_back/LoggingAspectTest.java | 104 +++ .../config/FirebaseInitializationTest.java | 67 ++ .../AlarmControllerApiContractTest.java | 178 +++++ .../controller/ScheduleControllerTest.java | 50 ++ .../controller/UserControllerTest.java | 30 + .../UtilityControllerApiContractTest.java | 127 +++ .../dto/AlarmSettingsPatchDtoTest.java | 78 ++ .../entity/ScheduleStatusTest.java | 51 ++ .../handler/LoginFailureHandlerTest.java | 27 + .../handler/LoginSuccessHandlerTest.java | 92 +++ .../service/LoginServiceTest.java | 46 ++ .../jwt/JwtAuthenticationFilterTest.java | 145 ++++ .../global/jwt/JwtTokenProviderTest.java | 151 +++- .../ontime_back/global/jwt/JwtUtilsTest.java | 50 ++ .../oauth/OAuthLoginFilterValidationTest.java | 291 +++++++ ...leLoginFilterAuthenticationResultTest.java | 52 ++ .../oauth/apple/AppleLoginServiceTest.java | 315 ++++++++ .../apple/ApplePublicKeyGeneratorTest.java | 58 ++ ...leLoginFilterAuthenticationResultTest.java | 84 ++ .../oauth/google/GoogleLoginServiceTest.java | 222 ++++++ ...aoLoginFilterAuthenticationResultTest.java | 78 ++ .../logging/RequestLogPolicyTest.java | 69 ++ .../response/ApiResponseFormTest.java | 52 ++ .../response/GlobalExceptionHandlerTest.java | 163 ++++ .../scheduler/NotificationSchedulerTest.java | 70 ++ .../ontime_back/service/AlarmServiceTest.java | 748 +++++++++++++++++- .../service/FeedbackServiceTest.java | 70 ++ .../service/FirebaseTokenServiceTest.java | 89 +++ .../service/NotificationServiceTest.java | 234 ++++++ .../service/ScheduleServiceTest.java | 50 ++ .../service/UserAuthServiceTest.java | 37 + .../ontime_back/service/UserServiceTest.java | 96 +++ .../service/UserSettingServiceTest.java | 107 +++ plans/test-validity-mutation-plan.md | 347 ++++++++ 40 files changed, 4483 insertions(+), 21 deletions(-) create mode 100644 ontime-back/src/test/java/devkor/ontime_back/LoggingAspectTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/config/FirebaseInitializationTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/controller/AlarmControllerApiContractTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/controller/UtilityControllerApiContractTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/dto/AlarmSettingsPatchDtoTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/entity/ScheduleStatusTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginFailureHandlerTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginSuccessHandlerTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/generallogin/service/LoginServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtUtilsTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilterAuthenticationResultTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/ApplePublicKeyGeneratorTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilterAuthenticationResultTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilterAuthenticationResultTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/logging/RequestLogPolicyTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/response/ApiResponseFormTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/response/GlobalExceptionHandlerTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/scheduler/NotificationSchedulerTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/FeedbackServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/FirebaseTokenServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/NotificationServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/UserSettingServiceTest.java create mode 100644 plans/test-validity-mutation-plan.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 99ee7e5..765c220 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,13 +61,21 @@ jobs: - # 4. Gradle 빌드 & JUnit 테스트 실행 + # 4. Gradle 검증 및 커버리지 검사 실행 - name: Run Tests with Gradle env: SPRING_PROFILES_ACTIVE: test run: | cd ontime-back - ./gradlew test + ./gradlew check + + - name: Upload JaCoCo Coverage Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-coverage-report + path: ontime-back/build/reports/jacoco/test/ + if-no-files-found: warn - name: Verify Flyway Migrations run: | diff --git a/ontime-back/build.gradle b/ontime-back/build.gradle index 9ce28be..ce12de8 100644 --- a/ontime-back/build.gradle +++ b/ontime-back/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'jacoco' id 'org.springframework.boot' version '3.3.4' id 'io.spring.dependency-management' version '1.1.6' id 'org.flywaydb.flyway' version '9.22.1' @@ -70,4 +71,30 @@ dependencies { tasks.named('test') { useJUnitPlatform() + finalizedBy tasks.named('jacocoTestReport') +} + +tasks.named('jacocoTestReport') { + dependsOn tasks.named('test') + + reports { + xml.required = true + html.required = true + } +} + +tasks.named('jacocoTestCoverageVerification') { + dependsOn tasks.named('jacocoTestReport') + + violationRules { + rule { + limit { + minimum = 0.95 + } + } + } +} + +tasks.named('check') { + dependsOn tasks.named('jacocoTestCoverageVerification') } diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java index cdd642f..3f6eb70 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java @@ -132,7 +132,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto User savedUser = userRepository.save(newUser); - String accessToken = jwtTokenProvider.createAccessToken(newUser.getEmail(), newUser.getId()); + String accessToken = jwtTokenProvider.createAccessToken(savedUser.getEmail(), savedUser.getId()); String refreshToken = jwtTokenProvider.createRefreshToken(); jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java index ddc4438..55ce929 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java @@ -17,6 +17,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -48,17 +49,31 @@ public class GoogleLoginService { private static final String GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke?token="; private final List validClientIds; + private final RestTemplate revokeRestTemplate; + @Autowired public GoogleLoginService( JwtTokenProvider jwtTokenProvider, UserRepository userRepository, UserAlarmSettingRepository userAlarmSettingRepository, @Value("${google.web.client-id}") String webClientId, @Value("${google.app.client-id}") String appClientId + ) { + this(jwtTokenProvider, userRepository, userAlarmSettingRepository, webClientId, appClientId, createRevokeRestTemplate()); + } + + GoogleLoginService( + JwtTokenProvider jwtTokenProvider, + UserRepository userRepository, + UserAlarmSettingRepository userAlarmSettingRepository, + String webClientId, + String appClientId, + RestTemplate revokeRestTemplate ) { this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; this.userAlarmSettingRepository = userAlarmSettingRepository; + this.revokeRestTemplate = revokeRestTemplate; this.validClientIds = Stream.concat( Stream.of(webClientId), Stream.of(appClientId.split(",")) @@ -71,6 +86,13 @@ public GoogleLoginService( .toList()); } + private static RestTemplate createRevokeRestTemplate() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(3000); + requestFactory.setReadTimeout(3000); + return new RestTemplate(requestFactory); + } + public Authentication handleLogin(OAuthGoogleRequestDto oAuthGoogleRequestDto, User user, HttpServletResponse response) throws IOException { user.updateSocialLoginToken(oAuthGoogleRequestDto.getRefreshToken()); @@ -129,7 +151,7 @@ public Authentication handleRegister(OAuthGoogleRequestDto oAuthGoogleRequestDto User savedUser = userRepository.save(newUser); - String accessToken = jwtTokenProvider.createAccessToken(newUser.getEmail(), newUser.getId()); + String accessToken = jwtTokenProvider.createAccessToken(savedUser.getEmail(), savedUser.getId()); String refreshToken = jwtTokenProvider.createRefreshToken(); jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); @@ -228,10 +250,6 @@ public boolean revokeToken(Long userId) { String googleRefreshToken = user.getSocialLoginToken(); - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(3000); - requestFactory.setReadTimeout(3000); - RestTemplate restTemplate = new RestTemplate(requestFactory); String revokeUrl = GOOGLE_REVOKE_URL + googleRefreshToken; HttpHeaders headers = new HttpHeaders(); @@ -239,7 +257,7 @@ public boolean revokeToken(Long userId) { HttpEntity request = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange( + ResponseEntity response = revokeRestTemplate.exchange( revokeUrl, HttpMethod.POST, request, String.class); return response.getStatusCode().is2xxSuccessful(); diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java index 43c7847..5338ac0 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java @@ -119,7 +119,7 @@ private Authentication handleRegister(OAuthKakaoUserDto oAuthKakaoUserDto, HttpS User savedUser = userRepository.save(newUser); - String accessToken = jwtTokenProvider.createAccessToken(newUser.getEmail(), newUser.getId()); + String accessToken = jwtTokenProvider.createAccessToken(savedUser.getEmail(), savedUser.getId()); String refreshToken = jwtTokenProvider.createRefreshToken(); jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); savedUser.updateAccessToken(accessToken); diff --git a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java index 60cd6e3..6d3fe16 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java +++ b/ontime-back/src/test/java/devkor/ontime_back/ControllerTestSupport.java @@ -27,7 +27,8 @@ AlarmController.class, SocialAuthController.class, AccountDeletionPageController.class, - PrivacyPolicyController.class + PrivacyPolicyController.class, + DocumentController.class } ) public abstract class ControllerTestSupport { diff --git a/ontime-back/src/test/java/devkor/ontime_back/LoggingAspectTest.java b/ontime-back/src/test/java/devkor/ontime_back/LoggingAspectTest.java new file mode 100644 index 0000000..80884d6 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/LoggingAspectTest.java @@ -0,0 +1,104 @@ +package devkor.ontime_back; + +import devkor.ontime_back.entity.ApiLog; +import devkor.ontime_back.logging.RequestLogPolicy; +import devkor.ontime_back.response.ErrorCode; +import devkor.ontime_back.response.GeneralException; +import devkor.ontime_back.service.ApiLogService; +import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LoggingAspectTest { + + private ApiLogService apiLogService; + private LoggingAspect loggingAspect; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + apiLogService = mock(ApiLogService.class); + loggingAspect = new LoggingAspect(apiLogService); + response = new MockHttpServletResponse(); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user-7", null); + authentication.setAuthenticated(true); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + SecurityContextHolder.clearContext(); + } + + @Test + void logRequestStoresSuccessfulResponseStatusAndExposesRequestId() throws Throwable { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/alarm/status"); + request.setRemoteAddr("203.0.113.10"); + request.addHeader(RequestLogPolicy.REQUEST_ID_HEADER, "request-1"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + when(joinPoint.proceed()).thenReturn(ResponseEntity.status(HttpStatus.CREATED).body("ok")); + + Object result = loggingAspect.logRequest(joinPoint); + + assertThat(result).isInstanceOf(ResponseEntity.class); + assertThat(response.getHeader(RequestLogPolicy.REQUEST_ID_HEADER)).isEqualTo("request-1"); + ArgumentCaptor captor = ArgumentCaptor.forClass(ApiLog.class); + verify(apiLogService).saveLog(captor.capture()); + ApiLog log = captor.getValue(); + assertThat(log.getRequestUrl()).isEqualTo("/alarm/status"); + assertThat(log.getRequestMethod()).isEqualTo("POST"); + assertThat(log.getUserId()).isEqualTo("user-7"); + assertThat(log.getClientIp()).isEqualTo("203.0.113.10"); + assertThat(log.getResponseStatus()).isEqualTo(201); + assertThat(log.getTakenTime()).isNotNegative(); + } + + @Test + void logRequestStoresBusinessErrorStatusBeforeRethrowing() throws Throwable { + MockHttpServletRequest request = new MockHttpServletRequest("PATCH", "/alarm/settings"); + request.setRemoteAddr("203.0.113.20"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + when(joinPoint.proceed()).thenThrow(new GeneralException(ErrorCode.DEVICE_SESSION_NOT_ACTIVE)); + + assertThatThrownBy(() -> loggingAspect.logRequest(joinPoint)) + .isInstanceOf(GeneralException.class); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ApiLog.class); + verify(apiLogService).saveLog(captor.capture()); + assertThat(captor.getValue().getResponseStatus()).isEqualTo(ErrorCode.DEVICE_SESSION_NOT_ACTIVE.getCode()); + } + + @Test + void logRequestStoresServerErrorStatusForUnexpectedExceptions() throws Throwable { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/users/me"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + when(joinPoint.proceed()).thenThrow(new IllegalStateException("boom")); + + assertThatThrownBy(() -> loggingAspect.logRequest(joinPoint)) + .isInstanceOf(IllegalStateException.class); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ApiLog.class); + verify(apiLogService).saveLog(captor.capture()); + assertThat(captor.getValue().getResponseStatus()).isEqualTo(500); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/config/FirebaseInitializationTest.java b/ontime-back/src/test/java/devkor/ontime_back/config/FirebaseInitializationTest.java new file mode 100644 index 0000000..76e35aa --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/config/FirebaseInitializationTest.java @@ -0,0 +1,67 @@ +package devkor.ontime_back.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirebaseInitializationTest { + + @TempDir + private Path tempDir; + + @Test + void resolveCredentialsPrefersBase64EncodedCredentials() throws Exception { + FirebaseInitialization initialization = new FirebaseInitialization(); + ReflectionTestUtils.setField( + initialization, + "firebaseCredentialsBase64", + Base64.getEncoder().encodeToString("base64-json".getBytes(StandardCharsets.UTF_8)) + ); + ReflectionTestUtils.setField(initialization, "firebaseCredentialsJson", "inline-json"); + + try (InputStream credentials = ReflectionTestUtils.invokeMethod(initialization, "resolveCredentials")) { + assertThat(new String(credentials.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("base64-json"); + } + } + + @Test + void resolveCredentialsFallsBackToInlineJson() throws Exception { + FirebaseInitialization initialization = new FirebaseInitialization(); + ReflectionTestUtils.setField(initialization, "firebaseCredentialsJson", "inline-json"); + + try (InputStream credentials = ReflectionTestUtils.invokeMethod(initialization, "resolveCredentials")) { + assertThat(new String(credentials.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("inline-json"); + } + } + + @Test + void resolveCredentialsUsesConfiguredPathBeforeGoogleApplicationCredentials() throws Exception { + Path configuredPath = tempDir.resolve("firebase.json"); + Path googlePath = tempDir.resolve("google.json"); + java.nio.file.Files.writeString(configuredPath, "configured-path-json"); + java.nio.file.Files.writeString(googlePath, "google-path-json"); + FirebaseInitialization initialization = new FirebaseInitialization(); + ReflectionTestUtils.setField(initialization, "firebaseCredentialsPath", configuredPath.toString()); + ReflectionTestUtils.setField(initialization, "googleApplicationCredentials", googlePath.toString()); + + try (InputStream credentials = ReflectionTestUtils.invokeMethod(initialization, "resolveCredentials")) { + assertThat(new String(credentials.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("configured-path-json"); + } + } + + @Test + void resolveCredentialsReturnsNullWhenNoCredentialsAreConfigured() throws Exception { + FirebaseInitialization initialization = new FirebaseInitialization(); + + InputStream credentials = ReflectionTestUtils.invokeMethod(initialization, "resolveCredentials"); + + assertThat(credentials).isNull(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/AlarmControllerApiContractTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/AlarmControllerApiContractTest.java new file mode 100644 index 0000000..2dbb5ef --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/AlarmControllerApiContractTest.java @@ -0,0 +1,178 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.ControllerTestSupport; +import devkor.ontime_back.TestSecurityConfig; +import devkor.ontime_back.dto.AlarmDeviceCurrentRequestDto; +import devkor.ontime_back.dto.AlarmDeviceCurrentResponseDto; +import devkor.ontime_back.dto.AlarmDeviceUnregisterRequestDto; +import devkor.ontime_back.dto.AlarmDeviceUnregisterResponseDto; +import devkor.ontime_back.dto.AlarmSettingsResponseDto; +import devkor.ontime_back.dto.AlarmSettingsPatchDto; +import devkor.ontime_back.dto.AlarmStatusCurrentResponseDto; +import devkor.ontime_back.dto.AlarmStatusReportRequestDto; +import devkor.ontime_back.dto.AlarmStatusReportResponseDto; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestSecurityConfig.class) +class AlarmControllerApiContractTest extends ControllerTestSupport { + + @Test + void getAlarmSettingsReturnsCurrentUsersSettingsEnvelope() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(alarmService.getAlarmSettings(1L)).thenReturn(AlarmSettingsResponseDto.builder() + .alarmsEnabled(true) + .defaultAlarmOffsetMinutes(10) + .updatedAt(Instant.parse("2026-05-05T00:00:00Z")) + .build()); + + mockMvc.perform(get("/users/me/alarm-settings") + .header("Authorization", "Bearer access-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.alarmsEnabled").value(true)) + .andExpect(jsonPath("$.data.defaultAlarmOffsetMinutes").value(10)); + } + + @Test + void patchAlarmSettingsBindsPartialJsonAndReturnsUpdatedSettings() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(alarmService.patchAlarmSettings(eq(1L), any(AlarmSettingsPatchDto.class))).thenReturn(AlarmSettingsResponseDto.builder() + .alarmsEnabled(false) + .defaultAlarmOffsetMinutes(30) + .updatedAt(Instant.parse("2026-05-05T00:00:00Z")) + .build()); + + mockMvc.perform(patch("/users/me/alarm-settings") + .header("Authorization", "Bearer access-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":30}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.alarmsEnabled").value(false)) + .andExpect(jsonPath("$.data.defaultAlarmOffsetMinutes").value(30)); + } + + @Test + void registerCurrentDevicePassesSessionTokensAndReturnsDeviceBinding() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userAuthService.getAccessTokenFromRequest(any())).thenReturn("access-token"); + when(userAuthService.getRefreshTokenFromRequest(any())).thenReturn("refresh-token"); + when(alarmService.registerCurrentDevice(eq(1L), any(AlarmDeviceCurrentRequestDto.class), eq("access-token"), eq("refresh-token"))) + .thenReturn(AlarmDeviceCurrentResponseDto.builder() + .deviceId("ios-device-000001") + .active(true) + .lastSeenAt(Instant.parse("2026-05-05T00:00:00Z")) + .build()); + + mockMvc.perform(put("/users/me/devices/current") + .header("Authorization", "Bearer access-token") + .header("Authorization-refresh", "Bearer refresh-token") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "deviceId": "ios-device-000001", + "platform": "ios", + "appVersion": "1.2.3", + "osVersion": "18.0", + "supportsNativeAlarm": true, + "nativeAlarmProvider": "iosAlarmKit", + "fallbackProvider": "localNotification" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deviceId").value("ios-device-000001")) + .andExpect(jsonPath("$.data.active").value(true)); + + verify(alarmService).registerCurrentDevice(eq(1L), any(AlarmDeviceCurrentRequestDto.class), eq("access-token"), eq("refresh-token")); + } + + @Test + void unregisterCurrentDeviceAcceptsOptionalDeviceIdAndReturnsInactiveBinding() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userAuthService.getAccessTokenFromRequest(any())).thenReturn("access-token"); + when(alarmService.unregisterCurrentDevice(eq(1L), any(AlarmDeviceUnregisterRequestDto.class), eq("access-token"))) + .thenReturn(AlarmDeviceUnregisterResponseDto.builder() + .active(false) + .build()); + + mockMvc.perform(delete("/users/me/devices/current") + .header("Authorization", "Bearer access-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"deviceId\":\"ios-device-000001\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.active").value(false)); + } + + @Test + void reportAlarmStatusBindsReconciliationPayloadAndReturnsReceipt() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userAuthService.getAccessTokenFromRequest(any())).thenReturn("access-token"); + when(alarmService.reportAlarmStatus(eq(1L), any(AlarmStatusReportRequestDto.class), eq("access-token"))) + .thenReturn(AlarmStatusReportResponseDto.builder().received(true).build()); + + mockMvc.perform(post("/users/me/alarm-status") + .header("Authorization", "Bearer access-token") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "deviceId": "ios-device-000001", + "reconciledAt": "2026-05-05T09:00:00+09:00", + "scheduleWindowStart": "2026-05-05T00:00:00", + "scheduleWindowEnd": "2026-05-06T00:00:00", + "alarmCoverageStart": "2026-05-05T00:00:00", + "alarmCoverageEnd": "2026-05-06T00:00:00", + "status": "armed", + "nativeAlarmProvider": "iosAlarmKit", + "fallbackProvider": "localNotification", + "armedScheduleCount": 1, + "armedScheduleIds": ["schedule-1"], + "skippedScheduleCount": 0, + "failures": [] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.received").value(true)); + } + + @Test + void getCurrentAlarmStatusReturnsCurrentDeviceStatusEnvelope() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userAuthService.getAccessTokenFromRequest(any())).thenReturn("access-token"); + when(alarmService.getCurrentAlarmStatus(1L, "access-token")).thenReturn(AlarmStatusCurrentResponseDto.builder() + .deviceId("ios-device-000001") + .active(true) + .platform("ios") + .reconciledAt(Instant.parse("2026-05-05T00:00:00Z")) + .scheduleWindowStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 6, 0, 0)) + .status("armed") + .armedScheduleIds(List.of("schedule-1")) + .updatedAt(Instant.parse("2026-05-05T00:00:00Z")) + .build()); + + mockMvc.perform(get("/users/me/alarm-status") + .header("Authorization", "Bearer access-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deviceId").value("ios-device-000001")) + .andExpect(jsonPath("$.data.active").value(true)) + .andExpect(jsonPath("$.data.status").value("armed")); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java index df17fd2..afbf776 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/ScheduleControllerTest.java @@ -99,6 +99,56 @@ void getScheduleById_success() throws Exception { verify(scheduleService, times(1)).showScheduleByScheduleId(userId, scheduleId); } + @DisplayName("알람 윈도우 조회는 offset date-time을 local wall-clock으로 파싱해 반환한다.") + @Test + void getAlarmWindowSchedules_successWithOffsetDateTimes() throws Exception { + Long userId = 1L; + UUID scheduleId = UUID.randomUUID(); + AlarmWindowScheduleDto windowSchedule = AlarmWindowScheduleDto.builder() + .scheduleId(scheduleId) + .scheduleName("회의") + .place(new PlaceDto(UUID.randomUUID(), "사무실")) + .scheduleTime(LocalDateTime.of(2026, 5, 5, 9, 30)) + .moveTime(20) + .scheduleSpareTime(10) + .doneStatus(DoneStatus.NOT_ENDED) + .preparationStartTime(LocalDateTime.of(2026, 5, 5, 8, 50)) + .defaultAlarmTime(LocalDateTime.of(2026, 5, 5, 8, 40)) + .preparations(List.of()) + .build(); + when(userAuthService.getUserIdFromToken(any())).thenReturn(userId); + when(scheduleService.getAlarmWindowSchedules( + eq(userId), + eq(LocalDateTime.of(2026, 5, 5, 0, 0)), + eq(LocalDateTime.of(2026, 5, 6, 0, 0)))) + .thenReturn(List.of(windowSchedule)); + + mockMvc.perform(get("/schedules/alarm-window") + .param("startDate", "2026-05-05T00:00:00+09:00") + .param("endDate", "2026-05-06T00:00:00+09:00") + .header("Authorization", "Bearer test-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].scheduleId").value(scheduleId.toString())) + .andExpect(jsonPath("$.data[0].scheduleName").value("회의")) + .andExpect(jsonPath("$.data[0].defaultAlarmTime").value("2026-05-05T08:40:00")); + } + + @DisplayName("알람 윈도우 조회는 잘못된 날짜 형식을 400으로 반환한다.") + @Test + void getAlarmWindowSchedules_rejectsInvalidDateTime() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + + mockMvc.perform(get("/schedules/alarm-window") + .param("startDate", "not-a-date") + .param("endDate", "2026-05-06T00:00:00") + .header("Authorization", "Bearer test-token")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("error")) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.getCode())); + + verify(scheduleService, never()).getAlarmWindowSchedules(any(), any(), any()); + } + @DisplayName("약속 삭제를 성공한다.") @Test void deleteSchedule_success() throws Exception { diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java index c3ac933..cd0de90 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/UserControllerTest.java @@ -128,4 +128,34 @@ void onboarding() throws Exception { .andExpect(jsonPath("$.message").value("온보딩이 성공적으로 완료되었습니다!")); } + @DisplayName("현재 사용자 정보 조회는 프로필과 소셜 타입을 응답한다.") + @Test + void getUserInfo() throws Exception { + User user = User.builder() + .id(1L) + .email("user@example.com") + .name("junbeom") + .spareTime(10) + .note("note") + .punctualityScore(90.5f) + .role(Role.USER) + .socialType(devkor.ontime_back.entity.SocialType.GOOGLE) + .build(); + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userService.getUserInfo(1L)).thenReturn(user); + + mockMvc.perform( + get("/users/me") + .header("Authorization", "Bearer access-token") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.message").value("사용자 정보 조회 성공")) + .andExpect(jsonPath("$.data.userId").value(1)) + .andExpect(jsonPath("$.data.email").value("user@example.com")) + .andExpect(jsonPath("$.data.role").value("USER")) + .andExpect(jsonPath("$.data.socialType").value("GOOGLE")); + } + } diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/UtilityControllerApiContractTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/UtilityControllerApiContractTest.java new file mode 100644 index 0000000..6b949dd --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/UtilityControllerApiContractTest.java @@ -0,0 +1,127 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.ControllerTestSupport; +import devkor.ontime_back.TestSecurityConfig; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Import(TestSecurityConfig.class) +class UtilityControllerApiContractTest extends ControllerTestSupport { + + @Test + void firebaseTokenEndpointsExtractUserAndAccessToken() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userAuthService.getAccessTokenFromRequest(any())).thenReturn("access-token"); + doNothing().when(firebaseTokenService).registerFirebaseToken(eq(1L), any(), eq("access-token")); + + mockMvc.perform(post("/firebase-token") + .header("Authorization", "Bearer access-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firebaseToken\":\"firebase-token\",\"deviceId\":\"ios-device-000001\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("FCM 토큰이 성공적으로 User테이블에 저장되었습니다!")); + + mockMvc.perform(post("/firebase-token/push-test") + .header("Authorization", "Bearer access-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Firebase 푸시 메세지가 성공적으로 Firebase에 전달되었습니다!")); + + verify(firebaseTokenService).sendTestNotification(1L); + } + + @Test + void userSettingEndpointsUpdateAndResetCurrentUsersSettings() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + + mockMvc.perform(put("/users/me/settings") + .header("Authorization", "Bearer access-token") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "isNotificationsEnabled": true, + "soundVolume": 75, + "isPlayOnSpeaker": false, + "is24HourFormat": true + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("사용자 앱 설정이 성공적으로 업데이트되었습니다!")); + + mockMvc.perform(put("/users/me/settings/reset") + .header("Authorization", "Bearer access-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("사용자 앱 설정이 성공적으로 초기화되었습니다! (soundVolume 50, 나머지 모두 true)")); + + verify(userSettingService).resetSetting(1L); + } + + @Test + void feedbackEndpointBindsFeedbackPayloadForCurrentUser() throws Exception { + UUID feedbackId = UUID.randomUUID(); + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + + mockMvc.perform(post("/feedback") + .header("Authorization", "Bearer access-token") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"feedbackId":"%s","message":"앱 사용 피드백입니다."} + """.formatted(feedbackId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("피드백이 성공적으로 저장되었습니다!")); + + verify(feedbackService).saveFeedback(eq(1L), any()); + } + + @Test + void documentEndpointsReturnStaticLegalAndProductContent() throws Exception { + mockMvc.perform(get("/documents/terms")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("이용약관 조회 성공")); + + mockMvc.perform(get("/documents/privacy")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("개인정보처리방침 조회 성공")); + + mockMvc.perform(get("/documents/ontime-description")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("온타임 소개글 조회 성공")); + } + + @Test + void socialLogoutContinuesAccountDeletionWhenProviderRevokeFails() throws Exception { + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + org.mockito.Mockito.doThrow(new RuntimeException("provider down")) + .when(googleLoginService).revokeToken(1L); + org.mockito.Mockito.doThrow(new RuntimeException("provider down")) + .when(appleLoginService).revokeToken(1L); + + mockMvc.perform(delete("/oauth2/google/me") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"bye\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("구글 로그인 회원탈퇴 성공")); + + mockMvc.perform(delete("/oauth2/apple/me") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\":\"bye\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("애플 로그인 회원탈퇴 성공")); + + verify(userAuthService, org.mockito.Mockito.times(2)).deleteUser(eq(1L), any()); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/dto/AlarmSettingsPatchDtoTest.java b/ontime-back/src/test/java/devkor/ontime_back/dto/AlarmSettingsPatchDtoTest.java new file mode 100644 index 0000000..df29fe3 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/dto/AlarmSettingsPatchDtoTest.java @@ -0,0 +1,78 @@ +package devkor.ontime_back.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigInteger; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlarmSettingsPatchDtoTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void rejectsUnknownAlarmSettingFields() throws Exception { + AlarmSettingsPatchDto dto = objectMapper.readValue("{\"token\":\"secret\"}", AlarmSettingsPatchDto.class); + + assertThat(messages(dto)).contains("알 수 없는 알람 설정 필드입니다."); + } + + @Test + void rejectsEmptyPatchBody() throws Exception { + AlarmSettingsPatchDto dto = objectMapper.readValue("{}", AlarmSettingsPatchDto.class); + + assertThat(messages(dto)).contains("변경할 알람 설정을 하나 이상 입력해야 합니다."); + } + + @Test + void rejectsNonBooleanAlarmsEnabled() throws Exception { + AlarmSettingsPatchDto dto = objectMapper.readValue("{\"alarmsEnabled\":\"true\"}", AlarmSettingsPatchDto.class); + + assertThat(messages(dto)).contains("alarmsEnabled는 boolean 값이어야 합니다."); + } + + @Test + void rejectsNonIntegralDefaultAlarmOffset() throws Exception { + AlarmSettingsPatchDto dto = objectMapper.readValue("{\"defaultAlarmOffsetMinutes\":5.5}", AlarmSettingsPatchDto.class); + + assertThat(messages(dto)).contains("defaultAlarmOffsetMinutes는 0 이상 1440 이하의 정수여야 합니다."); + assertThat(dto.getDefaultAlarmOffsetMinutesValue()).isNull(); + } + + @Test + void rejectsOutOfRangeDefaultAlarmOffset() throws Exception { + AlarmSettingsPatchDto dto = objectMapper.readValue("{\"defaultAlarmOffsetMinutes\":1441}", AlarmSettingsPatchDto.class); + + assertThat(messages(dto)).contains("defaultAlarmOffsetMinutes는 0 이상 1440 이하의 정수여야 합니다."); + } + + @Test + void acceptsSupportedIntegralDefaultAlarmOffsetTypes() { + assertOffsetValue(10, 10); + assertOffsetValue(20L, 20); + assertOffsetValue((short) 30, 30); + assertOffsetValue((byte) 40, 40); + assertOffsetValue(BigInteger.valueOf(50), 50); + } + + private Set messages(AlarmSettingsPatchDto dto) { + return validator.validate(dto).stream() + .map(ConstraintViolation::getMessage) + .collect(java.util.stream.Collectors.toSet()); + } + + private void assertOffsetValue(Object rawValue, int expectedValue) { + AlarmSettingsPatchDto dto = new AlarmSettingsPatchDto(); + ReflectionTestUtils.setField(dto, "defaultAlarmOffsetMinutes", rawValue); + + assertThat(validator.validate(dto)).isEmpty(); + assertThat(dto.getDefaultAlarmOffsetMinutesValue()).isEqualTo(expectedValue); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/entity/ScheduleStatusTest.java b/ontime-back/src/test/java/devkor/ontime_back/entity/ScheduleStatusTest.java new file mode 100644 index 0000000..22b68b0 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/entity/ScheduleStatusTest.java @@ -0,0 +1,51 @@ +package devkor.ontime_back.entity; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScheduleStatusTest { + + @Test + void updateLatenessTimeKeepsScheduleOpenForNullOrSentinelValues() { + Schedule schedule = Schedule.builder().build(); + + schedule.updateLatenessTime(null); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.NOT_ENDED); + + schedule.updateLatenessTime(-1); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.NOT_ENDED); + } + + @Test + void updateLatenessTimeMapsArrivalOutcomeToDoneStatus() { + Schedule schedule = Schedule.builder().build(); + + schedule.updateLatenessTime(12); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.LATE); + + schedule.updateLatenessTime(0); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.NORMAL); + + schedule.updateLatenessTime(-2); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.ABNORMAL); + } + + @Test + void finishStoresCompletionTimeAndMapsLatenessToFinalDoneStatus() { + Instant finishedAt = Instant.parse("2026-05-05T09:00:00Z"); + Schedule schedule = Schedule.builder().build(); + + schedule.finish(null, finishedAt); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.ABNORMAL); + assertThat(schedule.getFinishedAt()).isEqualTo(finishedAt); + + schedule.finish(1, finishedAt.plusSeconds(60)); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.LATE); + + schedule.finish(0, finishedAt.plusSeconds(120)); + assertThat(schedule.getDoneStatus()).isEqualTo(DoneStatus.NORMAL); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginFailureHandlerTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginFailureHandlerTest.java new file mode 100644 index 0000000..2241131 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginFailureHandlerTest.java @@ -0,0 +1,27 @@ +package devkor.ontime_back.global.generallogin.handler; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginFailureHandlerTest { + + @Test + void authenticationFailureReturnsPlainKoreanLoginFailureMessage() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + new LoginFailureHandler().onAuthenticationFailure( + new MockHttpServletRequest(), + response, + new BadCredentialsException("bad credentials") + ); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8"); + assertThat(response.getContentType()).isEqualTo("text/plain;charset=UTF-8"); + assertThat(response.getContentAsString()).isEqualTo("로그인 실패! 이메일이나 비밀번호를 확인해주세요."); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginSuccessHandlerTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginSuccessHandlerTest.java new file mode 100644 index 0000000..cd66260 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/handler/LoginSuccessHandlerTest.java @@ -0,0 +1,92 @@ +package devkor.ontime_back.global.generallogin.handler; + +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoginSuccessHandlerTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private UserRepository userRepository; + + @Test + void successfulUserLoginRotatesTokensAndWritesUserResponse() throws Exception { + LoginSuccessHandler handler = new LoginSuccessHandler(jwtTokenProvider, userRepository); + User user = user(Role.USER); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(jwtTokenProvider.createAccessToken("user@example.com", 1L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + handler.onAuthenticationSuccess(new MockHttpServletRequest(), new MockHttpServletResponse(), authentication()); + + assertThat(user.getAccessToken()).isEqualTo("access-token"); + assertThat(user.getRefreshToken()).isEqualTo("refresh-token"); + verify(jwtTokenProvider).sendAccessAndRefreshToken(any(), eq("access-token"), eq("refresh-token")); + verify(userRepository).saveAndFlush(user); + } + + @Test + void successfulGuestLoginTellsClientToContinueOnboarding() throws Exception { + LoginSuccessHandler handler = new LoginSuccessHandler(jwtTokenProvider, userRepository); + User user = user(Role.GUEST); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(jwtTokenProvider.createAccessToken("user@example.com", 1L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.onAuthenticationSuccess(new MockHttpServletRequest(), response, authentication()); + + assertThat(response.getContentAsString()).contains("온보딩API를 호출해 온보딩을 진행해야합니다."); + assertThat(response.getContentAsString()).contains("\"role\": \"GUEST\""); + } + + @Test + void successfulAuthenticationDoesNothingWhenEmailNoLongerExists() throws Exception { + LoginSuccessHandler handler = new LoginSuccessHandler(jwtTokenProvider, userRepository); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.empty()); + + handler.onAuthenticationSuccess(new MockHttpServletRequest(), new MockHttpServletResponse(), authentication()); + + verifyNoInteractions(jwtTokenProvider); + verify(userRepository, never()).saveAndFlush(any()); + } + + private UsernamePasswordAuthenticationToken authentication() { + UserDetails userDetails = org.springframework.security.core.userdetails.User.builder() + .username("user@example.com") + .password("password") + .roles("USER") + .build(); + return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + } + + private User user(Role role) { + return User.builder() + .id(1L) + .email("user@example.com") + .name("User") + .spareTime(10) + .note("note") + .punctualityScore(95.0f) + .role(role) + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/service/LoginServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/service/LoginServiceTest.java new file mode 100644 index 0000000..be3a078 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/generallogin/service/LoginServiceTest.java @@ -0,0 +1,46 @@ +package devkor.ontime_back.global.generallogin.service; + +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoginServiceTest { + + @Test + void loadUserByUsernameReturnsSpringSecurityUserDetails() { + UserRepository userRepository = mock(UserRepository.class); + LoginService loginService = new LoginService(userRepository); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(User.builder() + .email("user@example.com") + .password("encoded-password") + .role(Role.USER) + .build())); + + org.springframework.security.core.userdetails.UserDetails userDetails = + loginService.loadUserByUsername("user@example.com"); + + assertThat(userDetails.getUsername()).isEqualTo("user@example.com"); + assertThat(userDetails.getPassword()).isEqualTo("encoded-password"); + assertThat(userDetails.getAuthorities()).extracting("authority").containsExactly("ROLE_USER"); + } + + @Test + void loadUserByUsernameFailsWhenEmailDoesNotExist() { + UserRepository userRepository = mock(UserRepository.class); + LoginService loginService = new LoginService(userRepository); + when(userRepository.findByEmail("missing@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> loginService.loadUserByUsername("missing@example.com")) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessage("해당 이메일이 존재하지 않습니다."); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java index 280ab03..550fc7c 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java @@ -1,19 +1,35 @@ package devkor.ontime_back.global.jwt; +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.User; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.InvalidAccessTokenException; +import devkor.ontime_back.response.InvalidRefreshTokenException; import jakarta.servlet.FilterChain; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class JwtAuthenticationFilterTest { + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + @DisplayName("공개 HTML 페이지는 액세스 토큰 없이 JWT 필터를 통과한다.") @ParameterizedTest @ValueSource(strings = {"/account-deletion", "/account-deletion/en", "/privacy-policy", "/privacy-policy/en"}) @@ -31,4 +47,133 @@ void skipsPublicHtmlPages(String path) throws Exception { verify(jwtTokenProvider, never()).extractAccessToken(request); verify(jwtTokenProvider, never()).extractRefreshToken(request); } + + @Test + void validAccessTokenAuthenticatesUserAndContinuesFilterChain() throws Exception { + JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + UserRepository userRepository = mock(UserRepository.class); + FilterChain filterChain = mock(FilterChain.class); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); + MockHttpServletResponse response = new MockHttpServletResponse(); + User user = user("user@example.com", "encoded-password"); + + when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.of("access-token")); + when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.empty()); + when(jwtTokenProvider.isAccessTokenValid("access-token")).thenReturn(true); + when(jwtTokenProvider.extractUserId("access-token")).thenReturn(Optional.of(1L)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("user@example.com"); + } + + @Test + void validRefreshTokenReissuesAccessTokenWithoutContinuingRequest() throws Exception { + JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + UserRepository userRepository = mock(UserRepository.class); + FilterChain filterChain = mock(FilterChain.class); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); + MockHttpServletResponse response = new MockHttpServletResponse(); + User user = user("user@example.com", "encoded-password"); + + when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.empty()); + when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.of("refresh-token")); + when(jwtTokenProvider.isRefreshTokenValid("refresh-token")).thenReturn(true); + when(userRepository.findByRefreshToken("refresh-token")).thenReturn(Optional.of(user)); + when(jwtTokenProvider.createAccessToken("user@example.com", 1L)).thenReturn("new-access-token"); + + filter.doFilter(request, response, filterChain); + + verify(jwtTokenProvider).sendAccessToken(response, "new-access-token"); + verify(userRepository).saveAndFlush(user); + verify(filterChain, never()).doFilter(request, response); + assertThat(user.getAccessToken()).isEqualTo("new-access-token"); + } + + @Test + void missingAccessTokenReturnsTokenEmptyErrorEnvelope() throws Exception { + JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + UserRepository userRepository = mock(UserRepository.class); + FilterChain filterChain = mock(FilterChain.class); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.empty()); + when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.empty()); + + filter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("\"status\":\"accessTokenEmpty\""); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void invalidRefreshTokenReturnsRefreshSpecificErrorEnvelope() throws Exception { + JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + UserRepository userRepository = mock(UserRepository.class); + FilterChain filterChain = mock(FilterChain.class); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.empty()); + when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.of("refresh-token")); + when(jwtTokenProvider.isRefreshTokenValid("refresh-token")) + .thenThrow(new InvalidRefreshTokenException("bad refresh")); + + filter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("\"status\":\"refreshTokenInvalid\""); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void invalidAccessTokenReturnsAccessSpecificErrorEnvelope() throws Exception { + JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + UserRepository userRepository = mock(UserRepository.class); + FilterChain filterChain = mock(FilterChain.class); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.of("access-token")); + when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.empty()); + when(jwtTokenProvider.isAccessTokenValid("access-token")) + .thenThrow(new InvalidAccessTokenException("bad access")); + + filter.doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("\"status\":\"accessTokenInvalid\""); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void socialLoginUserWithoutPasswordReceivesGeneratedAuthenticationPassword() { + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(UserRepository.class)); + User user = user("social@example.com", null); + + filter.saveAuthentication(user); + + assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("social@example.com"); + assertThat(SecurityContextHolder.getContext().getAuthentication().getAuthorities()) + .extracting("authority") + .containsExactly("ROLE_USER"); + } + + private User user(String email, String password) { + return User.builder() + .id(1L) + .email(email) + .password(password) + .role(Role.USER) + .build(); + } } diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java index 6b88b22..f88bb11 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java @@ -1,28 +1,163 @@ package devkor.ontime_back.global.jwt; +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.User; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.InvalidAccessTokenException; +import devkor.ontime_back.response.InvalidRefreshTokenException; +import devkor.ontime_back.response.InvalidTokenException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) class JwtTokenProviderTest { + @Mock + private UserRepository userRepository; + + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + jwtTokenProvider = new JwtTokenProvider(userRepository); + ReflectionTestUtils.setField(jwtTokenProvider, "secretKey", "test-secret-key-that-is-long-enough"); + ReflectionTestUtils.setField(jwtTokenProvider, "accessTokenExpirationPeriod", 3600000L); + ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenExpirationPeriod", 7200000L); + ReflectionTestUtils.setField(jwtTokenProvider, "accessHeader", "Authorization"); + ReflectionTestUtils.setField(jwtTokenProvider, "refreshHeader", "Authorization-Refresh"); + } + @DisplayName("동일한 시각에 발급한 리프레시 토큰도 서로 다르다") @Test void createRefreshTokenGeneratesUniqueTokens() { - // given - JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(mock(UserRepository.class)); - ReflectionTestUtils.setField(jwtTokenProvider, "secretKey", "test-secret-key-that-is-long-enough"); - ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenExpirationPeriod", 3600000L); - - // when String refreshToken1 = jwtTokenProvider.createRefreshToken(); String refreshToken2 = jwtTokenProvider.createRefreshToken(); - // then assertThat(refreshToken1).isNotEqualTo(refreshToken2); } + + @Test + void accessTokenCarriesEmailAndUserIdClaims() { + String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L); + + assertThat(jwtTokenProvider.extractEmail(accessToken)).contains("user@example.com"); + assertThat(jwtTokenProvider.extractUserId(accessToken)).contains(7L); + assertThat(jwtTokenProvider.isTokenValid(accessToken)).isTrue(); + } + + @Test + void bearerTokenExtractionRejectsHeadersWithoutBearerPrefix() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "raw-access-token"); + request.addHeader("Authorization-Refresh", "Bearer refresh-token"); + + assertThat(jwtTokenProvider.extractAccessToken(request)).isEmpty(); + assertThat(jwtTokenProvider.extractRefreshToken(request)).contains("refresh-token"); + } + + @Test + void sendAccessAndRefreshTokenWritesBothCredentialHeaders() { + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtTokenProvider.sendAccessAndRefreshToken(response, "access-token", "refresh-token"); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeader("Authorization")).isEqualTo("access-token"); + assertThat(response.getHeader("Authorization-Refresh")).isEqualTo("refresh-token"); + } + + @Test + void sendAccessTokenWritesOnlyTheAccessCredentialHeader() { + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtTokenProvider.sendAccessToken(response, "access-token"); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeader("Authorization")).isEqualTo("access-token"); + assertThat(response.getHeader("Authorization-Refresh")).isNull(); + } + + @Test + void updateRefreshTokenMutatesExistingUserRefreshToken() { + User user = user("user@example.com"); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + + jwtTokenProvider.updateRefreshToken("user@example.com", "new-refresh-token"); + + assertThat(user.getRefreshToken()).isEqualTo("new-refresh-token"); + } + + @Test + void updateRefreshTokenFailsWhenUserDoesNotExist() { + when(userRepository.findByEmail("missing@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> jwtTokenProvider.updateRefreshToken("missing@example.com", "token")) + .isInstanceOf(RuntimeException.class) + .hasMessage("일치하는 회원이 없습니다."); + } + + @Test + void accessTokenValidityRequiresTokenToBeStoredForAUser() { + String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L); + when(userRepository.findByAccessToken(accessToken)).thenReturn(Optional.of(user("user@example.com"))); + + assertThat(jwtTokenProvider.isAccessTokenValid(accessToken)).isTrue(); + } + + @Test + void accessTokenValidityRejectsValidJwtThatIsNotStored() { + String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L); + when(userRepository.findByAccessToken(accessToken)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> jwtTokenProvider.isAccessTokenValid(accessToken)) + .isInstanceOf(InvalidAccessTokenException.class); + } + + @Test + void refreshTokenValidityRejectsExpiredTokensWithRefreshSpecificException() { + String expiredAccessToken = jwtTokenProvider.createExpiredAccessToken("user@example.com"); + + assertThatThrownBy(() -> jwtTokenProvider.isRefreshTokenValid(expiredAccessToken)) + .isInstanceOf(InvalidRefreshTokenException.class); + } + + @Test + void genericTokenValidityRejectsMalformedCredentials() { + assertThatThrownBy(() -> jwtTokenProvider.isTokenValid("not-a-jwt")) + .isInstanceOf(InvalidTokenException.class); + } + + @Test + void claimExtractionReturnsEmptyForMalformedCredentials() { + assertThat(jwtTokenProvider.extractEmail("not-a-jwt")).isEmpty(); + assertThat(jwtTokenProvider.extractUserId("not-a-jwt")).isEmpty(); + } + + @Test + void refreshTokenValidityAcceptsValidRefreshCredential() { + String refreshToken = jwtTokenProvider.createRefreshToken(); + + assertThat(jwtTokenProvider.isRefreshTokenValid(refreshToken)).isTrue(); + } + + private User user(String email) { + return User.builder() + .id(7L) + .email(email) + .role(Role.USER) + .build(); + } } diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtUtilsTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtUtilsTest.java new file mode 100644 index 0000000..530de9f --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtUtilsTest.java @@ -0,0 +1,50 @@ +package devkor.ontime_back.global.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtUtilsTest { + + private final JwtUtils jwtUtils = new JwtUtils(); + + @Test + void parseHeadersDecodesJwtHeaderWithoutTrustingThePayload() throws Exception { + String encodedHeader = Base64.getEncoder().encodeToString("{\"kid\":\"apple-key\",\"alg\":\"RS256\"}".getBytes()); + String token = encodedHeader + ".payload.signature"; + + Map headers = jwtUtils.parseHeaders(token); + + assertThat(headers).containsEntry("kid", "apple-key"); + assertThat(headers).containsEntry("alg", "RS256"); + assertThat(jwtUtils.decodeHeader(encodedHeader)).contains("apple-key"); + } + + @Test + void getTokenClaimsVerifiesSignatureWithTheProvidedPublicKey() throws Exception { + KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + String token = Jwts.builder() + .setSubject("apple-user-id") + .setIssuer("https://appleid.apple.com") + .setAudience("com.ontime.service") + .setExpiration(Date.from(Instant.now().plusSeconds(60))) + .signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256) + .compact(); + + Claims claims = jwtUtils.getTokenClaims(token, keyPair.getPublic()); + + assertThat(claims.getSubject()).isEqualTo("apple-user-id"); + assertThat(claims.getIssuer()).isEqualTo("https://appleid.apple.com"); + assertThat(claims.getAudience()).isEqualTo("com.ontime.service"); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java index d188185..cedccfd 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java @@ -1,12 +1,18 @@ package devkor.ontime_back.global.oauth; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import devkor.ontime_back.dto.AppleTokenResponseDto; import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.global.oauth.apple.AppleLoginFilter; import devkor.ontime_back.global.oauth.apple.AppleLoginService; import devkor.ontime_back.global.oauth.google.GoogleLoginFilter; import devkor.ontime_back.global.oauth.google.GoogleLoginService; import devkor.ontime_back.global.oauth.kakao.KakaoLoginFilter; +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.SocialType; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import jakarta.validation.Validation; @@ -16,13 +22,21 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.AuthenticationException; +import org.springframework.test.util.ReflectionTestUtils; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -91,6 +105,125 @@ void googleLoginFilterRejectsUnverifiedIdToken() throws Exception { verifyNoInteractions(userRepository); } + @Test + @DisplayName("구글 로그인 필터가 subject 없는 검증 토큰을 인증 실패로 처리한다") + void googleLoginFilterRejectsVerifiedTokenWithoutSubject() throws Exception { + GoogleLoginFilter filter = new GoogleLoginFilter( + "/oauth2/google/login", + objectMapper, + validator, + googleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + GoogleIdToken.Payload payload = new GoogleIdToken.Payload(); + payload.setEmail("user@example.com"); + + when(googleLoginService.verifyIdentityToken("valid-token")).thenReturn(payload); + + assertThatThrownBy(() -> filter.attemptAuthentication( + request("/oauth2/google/login", "{\"idToken\":\"valid-token\"}"), + response)) + .isInstanceOf(BadCredentialsException.class) + .hasMessage("Google identity token has no subject"); + + verifyNoInteractions(userRepository); + } + + @Test + @DisplayName("구글 로그인 필터가 검증된 토큰의 기존 유저를 로그인 처리한다") + void googleLoginFilterLogsInExistingUser() throws Exception { + GoogleLoginFilter filter = new GoogleLoginFilter( + "/oauth2/google/login", + objectMapper, + validator, + googleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + GoogleIdToken.Payload payload = new GoogleIdToken.Payload(); + payload.setSubject("google-id"); + payload.setEmail("user@example.com"); + User existingUser = user(1L, "user@example.com", Role.USER); + + when(googleLoginService.verifyIdentityToken("valid-token")).thenReturn(payload); + when(userRepository.findAllBySocialTypeAndSocialIdOrderByIdDesc(SocialType.GOOGLE, "google-id")) + .thenReturn(java.util.List.of(existingUser)); + when(googleLoginService.handleLogin(any(), any(), any())).thenReturn( + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(existingUser, null) + ); + + assertThat(filter.attemptAuthentication( + request("/oauth2/google/login", "{\"idToken\":\"valid-token\",\"refreshToken\":\"google-refresh\"}"), + response).getPrincipal()).isSameAs(existingUser); + + verify(googleLoginService).handleLogin(any(), any(), any()); + } + + @Test + @DisplayName("구글 로그인 필터가 신규 유저 정보를 검증된 토큰에서 만들어 회원가입 처리한다") + void googleLoginFilterRegistersNewUserFromVerifiedPayload() throws Exception { + GoogleLoginFilter filter = new GoogleLoginFilter( + "/oauth2/google/login", + objectMapper, + validator, + googleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + GoogleIdToken.Payload payload = new GoogleIdToken.Payload(); + payload.setSubject("google-id"); + payload.setEmail("new@example.com"); + payload.put("name", "New User"); + payload.put("picture", "https://example.com/picture.png"); + User newUser = user(2L, "new@example.com", Role.GUEST); + + when(googleLoginService.verifyIdentityToken("valid-token")).thenReturn(payload); + when(userRepository.findAllBySocialTypeAndSocialIdOrderByIdDesc(SocialType.GOOGLE, "google-id")) + .thenReturn(java.util.List.of()); + when(googleLoginService.handleRegister(any(), any(), any())).thenReturn( + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(newUser, null) + ); + + assertThat(filter.attemptAuthentication( + request("/oauth2/google/login", "{\"idToken\":\"valid-token\",\"refreshToken\":\"google-refresh\"}"), + response).getPrincipal()).isSameAs(newUser); + + verify(googleLoginService).handleRegister(any(), any(), any()); + } + + @Test + @DisplayName("구글 회원가입 중 중복 socialId가 생기면 방금 생성된 기존 계정으로 로그인한다") + void googleLoginFilterFallsBackToLoginWhenConcurrentRegisterCreatesSameSocialUser() throws Exception { + GoogleLoginFilter filter = new GoogleLoginFilter( + "/oauth2/google/login", + objectMapper, + validator, + googleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + GoogleIdToken.Payload payload = new GoogleIdToken.Payload(); + payload.setSubject("google-id"); + payload.setEmail("new@example.com"); + payload.put("name", "New User"); + payload.put("picture", "https://example.com/picture.png"); + User concurrentlyCreatedUser = user(3L, "new@example.com", Role.GUEST); + + when(googleLoginService.verifyIdentityToken("valid-token")).thenReturn(payload); + when(userRepository.findAllBySocialTypeAndSocialIdOrderByIdDesc(SocialType.GOOGLE, "google-id")) + .thenReturn(java.util.List.of()) + .thenReturn(java.util.List.of(concurrentlyCreatedUser)); + when(googleLoginService.handleRegister(any(), any(), any())) + .thenThrow(new DataIntegrityViolationException("duplicate social id")); + when(googleLoginService.handleLogin(any(), any(), any())).thenReturn( + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(concurrentlyCreatedUser, null) + ); + + assertThat(filter.attemptAuthentication( + request("/oauth2/google/login", "{\"idToken\":\"valid-token\",\"refreshToken\":\"google-refresh\"}"), + response).getPrincipal()).isSameAs(concurrentlyCreatedUser); + + verify(googleLoginService).handleRegister(any(), any(), any()); + verify(googleLoginService).handleLogin(any(), any(), any()); + } + @Test @DisplayName("카카오 로그인 필터가 잘못된 요청을 400 validation 응답으로 처리한다") void kakaoLoginFilterRejectsInvalidRequest() throws Exception { @@ -112,6 +245,52 @@ void kakaoLoginFilterRejectsInvalidRequest() throws Exception { verifyNoInteractions(jwtTokenProvider, userRepository, userAlarmSettingRepository); } + @Test + @DisplayName("카카오 로그인 필터가 기존 소셜 유저의 애플리케이션 토큰을 재발급한다") + void kakaoLoginFilterLogsInExistingUser() throws Exception { + KakaoLoginFilter filter = kakaoLoginFilter(); + MockHttpServletResponse response = new MockHttpServletResponse(); + User existingUser = user(1L, "user@example.com", Role.USER); + when(userRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "kakao-id")) + .thenReturn(Optional.of(existingUser)); + when(jwtTokenProvider.createAccessToken("user@example.com", 1L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + assertThat(filter.attemptAuthentication( + request("/oauth2/kakao/login", validKakaoBody()), + response).getPrincipal()).isSameAs(existingUser); + + assertThat(existingUser.getAccessToken()).isEqualTo("access-token"); + assertThat(existingUser.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(response.getContentAsString()).contains("로그인에 성공하였습니다."); + verify(jwtTokenProvider).sendAccessAndRefreshToken(response, "access-token", "refresh-token"); + verify(userRepository).saveAndFlush(existingUser); + } + + @Test + @DisplayName("카카오 로그인 필터가 신규 소셜 유저와 기본 알람 설정을 생성한다") + void kakaoLoginFilterRegistersNewGuestUser() throws Exception { + KakaoLoginFilter filter = kakaoLoginFilter(); + MockHttpServletResponse response = new MockHttpServletResponse(); + User savedUser = user(2L, "kakao@example.com", Role.GUEST); + when(userRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "kakao-id")) + .thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + when(jwtTokenProvider.createAccessToken("kakao@example.com", 2L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + assertThat(filter.attemptAuthentication( + request("/oauth2/kakao/login", validKakaoBody()), + response).getPrincipal()).isNotNull(); + + assertThat(savedUser.getAccessToken()).isEqualTo("access-token"); + assertThat(savedUser.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(response.getContentAsString()).contains("온보딩이 필요합니다."); + verify(jwtTokenProvider).createAccessToken("kakao@example.com", 2L); + verify(userRepository, times(2)).save(any(User.class)); + verify(userAlarmSettingRepository).save(any(UserAlarmSetting.class)); + } + @Test @DisplayName("애플 로그인 필터가 잘못된 요청을 400 validation 응답으로 처리한다") void appleLoginFilterRejectsInvalidRequest() throws Exception { @@ -132,6 +311,75 @@ void appleLoginFilterRejectsInvalidRequest() throws Exception { verifyNoInteractions(appleLoginService, userRepository); } + @Test + @DisplayName("애플 로그인 필터가 기존 유저를 Apple refresh token으로 로그인 처리한다") + void appleLoginFilterLogsInExistingUser() throws Exception { + AppleLoginFilter filter = new AppleLoginFilter( + "/oauth2/apple/login", + objectMapper, + validator, + appleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + Claims claims = Jwts.claims().setSubject("apple-id"); + claims.put("email", "user@example.com"); + AppleTokenResponseDto tokenResponse = appleTokenResponse("apple-refresh-token"); + User existingUser = user(1L, "user@example.com", Role.USER); + + when(appleLoginService.verifyIdentityToken("apple-id-token")).thenReturn(claims); + when(appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code")).thenReturn(tokenResponse); + when(userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-id")) + .thenReturn(Optional.of(existingUser)); + when(appleLoginService.handleLogin("apple-refresh-token", existingUser, response)).thenReturn( + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(existingUser, null) + ); + + assertThat(filter.attemptAuthentication( + request("/oauth2/apple/login", validAppleBody()), + response).getPrincipal()).isSameAs(existingUser); + + verify(appleLoginService).handleLogin("apple-refresh-token", existingUser, response); + } + + @Test + @DisplayName("애플 로그인 필터가 신규 유저를 Apple identity token과 auth code로 회원가입 처리한다") + void appleLoginFilterRegistersNewUser() throws Exception { + AppleLoginFilter filter = new AppleLoginFilter( + "/oauth2/apple/login", + objectMapper, + validator, + appleLoginService, + userRepository); + MockHttpServletResponse response = new MockHttpServletResponse(); + Claims claims = Jwts.claims().setSubject("apple-id"); + AppleTokenResponseDto tokenResponse = appleTokenResponse("apple-refresh-token"); + User newUser = user(2L, "new@example.com", Role.GUEST); + + when(appleLoginService.verifyIdentityToken("apple-id-token")).thenReturn(claims); + when(appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code")).thenReturn(tokenResponse); + when(userRepository.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-id")) + .thenReturn(Optional.empty()); + when(appleLoginService.handleRegister(any(), any(), any())).thenReturn( + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(newUser, null) + ); + + assertThat(filter.attemptAuthentication( + request("/oauth2/apple/login", validAppleBody()), + response).getPrincipal()).isSameAs(newUser); + + verify(appleLoginService).handleRegister(any(), any(), any()); + } + + private KakaoLoginFilter kakaoLoginFilter() { + return new KakaoLoginFilter( + "/oauth2/kakao/login", + objectMapper, + validator, + jwtTokenProvider, + userRepository, + userAlarmSettingRepository); + } + private MockHttpServletRequest request(String uri, String body) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setContentType("application/json"); @@ -147,4 +395,47 @@ private void assertValidationResponse(MockHttpServletResponse response) throws E assertThat(response.getContentAsString()).contains("\"code\":1002"); assertThat(response.getContentAsString()).contains("\"errors\""); } + + private String validKakaoBody() { + return """ + { + "id": "kakao-id", + "profile": { + "nickname": "Kakao User", + "profile_image_url": "https://example.com/profile.png" + } + } + """; + } + + private String validAppleBody() { + return """ + { + "idToken": "apple-id-token", + "authCode": "auth-code", + "fullName": "Apple User", + "email": "new@example.com" + } + """; + } + + private AppleTokenResponseDto appleTokenResponse(String refreshToken) { + AppleTokenResponseDto response = new AppleTokenResponseDto(); + ReflectionTestUtils.setField(response, "refreshToken", refreshToken); + return response; + } + + private User user(Long id, String email, Role role) { + return User.builder() + .id(id) + .email(email) + .name("Kakao User") + .spareTime(10) + .note("note") + .punctualityScore(95.0f) + .role(role) + .socialType(SocialType.KAKAO) + .socialId("kakao-id") + .build(); + } } diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilterAuthenticationResultTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilterAuthenticationResultTest.java new file mode 100644 index 0000000..0449aec --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginFilterAuthenticationResultTest.java @@ -0,0 +1,52 @@ +package devkor.ontime_back.global.oauth.apple; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.repository.UserRepository; +import jakarta.validation.Validation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class AppleLoginFilterAuthenticationResultTest { + + private final TestableAppleLoginFilter filter = new TestableAppleLoginFilter(); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void unsuccessfulAuthenticationWritesUnauthorizedEnvelope() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.callUnsuccessfulAuthentication(new MockHttpServletRequest(), response, new BadCredentialsException("bad")); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("Authentication failed"); + } + + private static class TestableAppleLoginFilter extends AppleLoginFilter { + private TestableAppleLoginFilter() { + super( + "/oauth2/apple/login", + new ObjectMapper(), + Validation.buildDefaultValidatorFactory().getValidator(), + mock(AppleLoginService.class), + mock(UserRepository.class) + ); + } + + void callUnsuccessfulAuthentication(MockHttpServletRequest request, + MockHttpServletResponse response, + org.springframework.security.core.AuthenticationException failed) throws java.io.IOException, jakarta.servlet.ServletException { + super.unsuccessfulAuthentication(request, response, failed); + } + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java new file mode 100644 index 0000000..8a93f71 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java @@ -0,0 +1,315 @@ +package devkor.ontime_back.global.oauth.apple; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.dto.OAuthAppleUserDto; +import devkor.ontime_back.dto.AppleTokenResponseDto; +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.SocialType; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; +import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.global.jwt.JwtUtils; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.InvalidTokenException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.interfaces.ECPrivateKey; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AppleLoginServiceTest { + + @Mock + private ApplePublicKeyGenerator applePublicKeyGenerator; + + @Mock + private JwtUtils jwtUtils; + + @Mock + private UserRepository userRepository; + + @Mock + private UserAlarmSettingRepository userAlarmSettingRepository; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + private AppleLoginService appleLoginService; + + @BeforeEach + void setUp() { + appleLoginService = new AppleLoginService( + applePublicKeyGenerator, + jwtUtils, + userRepository, + userAlarmSettingRepository, + jwtTokenProvider + ); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void handleLoginRotatesAppleRefreshTokenAndApplicationTokens() throws Exception { + User user = user(1L, "user@example.com", "Existing User", Role.USER); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(jwtTokenProvider.createAccessToken("user@example.com", 1L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + Authentication authentication = appleLoginService.handleLogin("apple-refresh-token", user, response); + + assertThat(authentication.getPrincipal()).isSameAs(user); + assertThat(user.getSocialLoginToken()).isEqualTo("apple-refresh-token"); + assertThat(user.getAccessToken()).isEqualTo("access-token"); + assertThat(user.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(response.getContentAsString()).contains("\"message\": \"로그인에 성공하였습니다.\""); + verify(jwtTokenProvider).sendAccessAndRefreshToken(response, "access-token", "refresh-token"); + verify(userRepository).saveAndFlush(user); + } + + @Test + void handleRegisterCreatesGuestAppleUserAndDefaultAlarmSettings() throws Exception { + OAuthAppleUserDto appleUser = new OAuthAppleUserDto("apple-id", "new@example.com", "New User"); + User savedUser = user(2L, "new@example.com", "New User", Role.GUEST); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + when(jwtTokenProvider.createAccessToken("new@example.com", 2L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication authentication = appleLoginService.handleRegister("apple-refresh-token", appleUser, response); + + assertThat(authentication.getPrincipal()).isSameAs(savedUser); + assertThat(savedUser.getAccessToken()).isEqualTo("access-token"); + assertThat(savedUser.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(response.getContentAsString()).contains("회원가입에 성공하였습니다."); + verify(jwtTokenProvider).createAccessToken("new@example.com", 2L); + verify(userRepository, times(2)).save(any(User.class)); + verify(userAlarmSettingRepository).save(any(UserAlarmSetting.class)); + } + + @Test + void verifyIdentityTokenReturnsClaimsWhenIssuerAudienceAndExpirationAreValid() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + ApplePublicKeyResponse appleKeys = new ApplePublicKeyResponse(java.util.List.of()); + PublicKey publicKey = java.security.KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic(); + Claims claims = Jwts.claims() + .setIssuer("https://appleid.apple.com") + .setAudience("com.ontime.service") + .setExpiration(Date.from(Instant.now().plusSeconds(60))); + ReflectionTestUtils.setField(appleLoginService, "clientId", "com.ontime.service"); + when(jwtUtils.parseHeaders("identity-token")).thenReturn(Map.of("kid", "key-id", "alg", "RS256")); + when(restTemplate.getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class)) + .thenReturn(appleKeys); + when(applePublicKeyGenerator.generatePublicKey(Map.of("kid", "key-id", "alg", "RS256"), appleKeys)) + .thenReturn(publicKey); + when(jwtUtils.getTokenClaims("identity-token", publicKey)).thenReturn(claims); + + Claims verifiedClaims = appleLoginService.verifyIdentityToken("identity-token"); + + assertThat(verifiedClaims).isSameAs(claims); + } + + @Test + void verifyIdentityTokenRejectsTokenFromUnexpectedIssuer() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + PublicKey publicKey = java.security.KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic(); + Claims claims = validAppleClaims().setIssuer("https://attacker.example"); + ReflectionTestUtils.setField(appleLoginService, "clientId", "com.ontime.service"); + when(jwtUtils.parseHeaders("identity-token")).thenReturn(Map.of()); + when(restTemplate.getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class)) + .thenReturn(new ApplePublicKeyResponse(java.util.List.of())); + when(applePublicKeyGenerator.generatePublicKey(any(), any())).thenReturn(publicKey); + when(jwtUtils.getTokenClaims("identity-token", publicKey)).thenReturn(claims); + + assertThatThrownBy(() -> appleLoginService.verifyIdentityToken("identity-token")) + .isInstanceOf(InvalidTokenException.class) + .hasMessageContaining("issuer"); + } + + @Test + void verifyIdentityTokenRejectsTokenForUnexpectedAudience() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + PublicKey publicKey = java.security.KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic(); + Claims claims = validAppleClaims().setAudience("other-client-id"); + ReflectionTestUtils.setField(appleLoginService, "clientId", "com.ontime.service"); + when(jwtUtils.parseHeaders("identity-token")).thenReturn(Map.of()); + when(restTemplate.getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class)) + .thenReturn(new ApplePublicKeyResponse(java.util.List.of())); + when(applePublicKeyGenerator.generatePublicKey(any(), any())).thenReturn(publicKey); + when(jwtUtils.getTokenClaims("identity-token", publicKey)).thenReturn(claims); + + assertThatThrownBy(() -> appleLoginService.verifyIdentityToken("identity-token")) + .isInstanceOf(InvalidTokenException.class) + .hasMessageContaining("audience"); + } + + @Test + void verifyIdentityTokenRejectsExpiredToken() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + PublicKey publicKey = java.security.KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic(); + Claims claims = validAppleClaims().setExpiration(Date.from(Instant.now().minusSeconds(1))); + ReflectionTestUtils.setField(appleLoginService, "clientId", "com.ontime.service"); + when(jwtUtils.parseHeaders("identity-token")).thenReturn(Map.of()); + when(restTemplate.getForObject("https://appleid.apple.com/auth/keys", ApplePublicKeyResponse.class)) + .thenReturn(new ApplePublicKeyResponse(java.util.List.of())); + when(applePublicKeyGenerator.generatePublicKey(any(), any())).thenReturn(publicKey); + when(jwtUtils.getTokenClaims("identity-token", publicKey)).thenReturn(claims); + + assertThatThrownBy(() -> appleLoginService.verifyIdentityToken("identity-token")) + .isInstanceOf(InvalidTokenException.class) + .hasMessageContaining("만료"); + } + + @Test + void getAppleAccessTokenAndRefreshTokenExchangesAuthCodeWithClientSecret() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + configureAppleClientSecretInputs(); + JsonNode responseBody = new ObjectMapper().readTree(""" + { + "access_token": "apple-access", + "refresh_token": "apple-refresh", + "id_token": "identity-token", + "token_type": "Bearer", + "expires_in": 3600 + } + """); + when(restTemplate.exchange( + eq("https://appleid.apple.com/auth/token"), + eq(HttpMethod.POST), + any(), + eq(JsonNode.class) + )).thenReturn(ResponseEntity.ok(responseBody)); + + AppleTokenResponseDto response = appleLoginService.getAppleAccessTokenAndRefreshToken("auth-code"); + + assertThat(response.getAccessToken()).isEqualTo("apple-access"); + assertThat(response.getRefreshToken()).isEqualTo("apple-refresh"); + assertThat(response.getIdToken()).isEqualTo("identity-token"); + } + + @Test + void revokeTokenReturnsFalseWhenAppleAcceptsCurrentRefreshToken() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + configureAppleClientSecretInputs(); + User user = user(7L, "apple@example.com", "Apple User", Role.USER); + user.updateSocialLoginToken("apple-refresh-token"); + when(userRepository.findById(7L)).thenReturn(Optional.of(user)); + when(restTemplate.exchange( + eq("https://appleid.apple.com/auth/revoke"), + eq(HttpMethod.POST), + any(), + eq(String.class) + )).thenReturn(ResponseEntity.ok("")); + + boolean revoked = appleLoginService.revokeToken(7L); + + assertThat(revoked).isFalse(); + } + + @Test + void revokeTokenReturnsTrueWhenAppleRejectsTheRefreshToken() throws Exception { + RestTemplate restTemplate = mockRestTemplate(); + configureAppleClientSecretInputs(); + User user = user(7L, "apple@example.com", "Apple User", Role.USER); + user.updateSocialLoginToken("apple-refresh-token"); + when(userRepository.findById(7L)).thenReturn(Optional.of(user)); + when(restTemplate.exchange( + eq("https://appleid.apple.com/auth/revoke"), + eq(HttpMethod.POST), + any(), + eq(String.class) + )).thenThrow(new HttpClientErrorException(HttpStatus.BAD_REQUEST)); + + boolean revoked = appleLoginService.revokeToken(7L); + + assertThat(revoked).isTrue(); + } + + @Test + void revokeTokenFailsWhenUserDoesNotExist() { + when(userRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> appleLoginService.revokeToken(404L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User not found with id: 404"); + } + + private RestTemplate mockRestTemplate() { + RestTemplate restTemplate = org.mockito.Mockito.mock(RestTemplate.class); + ReflectionTestUtils.setField(appleLoginService, "restTemplate", restTemplate); + return restTemplate; + } + + private Claims validAppleClaims() { + return Jwts.claims() + .setIssuer("https://appleid.apple.com") + .setAudience("com.ontime.service") + .setExpiration(Date.from(Instant.now().plusSeconds(60))); + } + + private void configureAppleClientSecretInputs() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(256); + ECPrivateKey privateKey = (ECPrivateKey) generator.generateKeyPair().getPrivate(); + String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + String pem = "-----BEGIN PRIVATE KEY-----\n" + encodedKey + "\n-----END PRIVATE KEY-----"; + ReflectionTestUtils.setField(appleLoginService, "clientId", "com.ontime.service"); + ReflectionTestUtils.setField(appleLoginService, "teamId", "TEAM123456"); + ReflectionTestUtils.setField(appleLoginService, "keyId", "KEY123456"); + ReflectionTestUtils.setField( + appleLoginService, + "privateKeyBase64", + Base64.getEncoder().encodeToString(pem.getBytes(StandardCharsets.UTF_8)) + ); + } + + private User user(Long id, String email, String name, Role role) { + return User.builder() + .id(id) + .email(email) + .name(name) + .spareTime(10) + .note("note") + .punctualityScore(95.0f) + .role(role) + .socialType(SocialType.APPLE) + .socialId("apple-id") + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/ApplePublicKeyGeneratorTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/ApplePublicKeyGeneratorTest.java new file mode 100644 index 0000000..02a3356 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/ApplePublicKeyGeneratorTest.java @@ -0,0 +1,58 @@ +package devkor.ontime_back.global.oauth.apple; + +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApplePublicKeyGeneratorTest { + + private final ApplePublicKeyGenerator generator = new ApplePublicKeyGenerator(); + + @Test + void generatePublicKeySelectsMatchingAppleKeyAndReconstructsRsaPublicKey() throws Exception { + KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + RSAPublicKey expectedKey = (RSAPublicKey) keyPair.getPublic(); + ApplePublicKey matchingKey = applePublicKey("matched-key", "RS256", expectedKey); + ApplePublicKeyResponse response = new ApplePublicKeyResponse(List.of( + applePublicKey("other-key", "RS256", expectedKey), + matchingKey + )); + + RSAPublicKey actualKey = (RSAPublicKey) generator.generatePublicKey( + Map.of("kid", "matched-key", "alg", "RS256"), + response + ); + + assertThat(actualKey.getModulus()).isEqualTo(expectedKey.getModulus()); + assertThat(actualKey.getPublicExponent()).isEqualTo(expectedKey.getPublicExponent()); + } + + @Test + void applePublicKeyResponseRejectsTokensWithoutMatchingKidAndAlgorithm() { + ApplePublicKeyResponse response = new ApplePublicKeyResponse(List.of( + new ApplePublicKey("RSA", "key-1", "RS256", "AQAB", "AQAB") + )); + + assertThatThrownBy(() -> response.getMatchedKey("key-2", "RS256")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid JWT: No matching Apple Public Key found"); + } + + private ApplePublicKey applePublicKey(String kid, String alg, RSAPublicKey key) { + return new ApplePublicKey( + "RSA", + kid, + alg, + Base64.getUrlEncoder().withoutPadding().encodeToString(key.getModulus().toByteArray()), + Base64.getUrlEncoder().withoutPadding().encodeToString(key.getPublicExponent().toByteArray()) + ); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilterAuthenticationResultTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilterAuthenticationResultTest.java new file mode 100644 index 0000000..e40a568 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilterAuthenticationResultTest.java @@ -0,0 +1,84 @@ +package devkor.ontime_back.global.oauth.google; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.validation.Validation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class GoogleLoginFilterAuthenticationResultTest { + + private final TestableGoogleLoginFilter filter = new TestableGoogleLoginFilter(); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void successfulAuthenticationStoresAuthenticationAndReturnsOk() throws Exception { + User user = User.builder().id(1L).email("user@example.com").build(); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, null); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.callSuccessfulAuthentication( + new MockHttpServletRequest(), + response, + mock(FilterChain.class), + authentication + ); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(authentication); + } + + @Test + void unsuccessfulAuthenticationWritesUnauthorizedEnvelopeForCredentialFailures() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.callUnsuccessfulAuthentication( + new MockHttpServletRequest(), + response, + new BadCredentialsException("bad token") + ); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("Authentication failed"); + } + + private static class TestableGoogleLoginFilter extends GoogleLoginFilter { + private TestableGoogleLoginFilter() { + super( + "/oauth2/google/login", + new ObjectMapper(), + Validation.buildDefaultValidatorFactory().getValidator(), + mock(GoogleLoginService.class), + mock(UserRepository.class) + ); + } + + public void callSuccessfulAuthentication(MockHttpServletRequest request, + MockHttpServletResponse response, + FilterChain chain, + org.springframework.security.core.Authentication authResult) throws java.io.IOException, jakarta.servlet.ServletException { + super.successfulAuthentication(request, response, chain, authResult); + } + + public void callUnsuccessfulAuthentication(MockHttpServletRequest request, + MockHttpServletResponse response, + org.springframework.security.core.AuthenticationException failed) throws java.io.IOException, jakarta.servlet.ServletException { + super.unsuccessfulAuthentication(request, response, failed); + } + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginServiceTest.java new file mode 100644 index 0000000..c63c97d --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginServiceTest.java @@ -0,0 +1,222 @@ +package devkor.ontime_back.global.oauth.google; + +import devkor.ontime_back.dto.OAuthGoogleRequestDto; +import devkor.ontime_back.dto.OAuthGoogleUserDto; +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.SocialType; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; +import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GoogleLoginServiceTest { + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private UserAlarmSettingRepository userAlarmSettingRepository; + + private GoogleLoginService googleLoginService; + @Mock + private RestTemplate revokeRestTemplate; + + @BeforeEach + void setUp() { + googleLoginService = new GoogleLoginService( + jwtTokenProvider, + userRepository, + userAlarmSettingRepository, + "web-client.apps.googleusercontent.com", + "ios-client.apps.googleusercontent.com, android-client.apps.googleusercontent.com", + revokeRestTemplate + ); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void handleLoginRotatesApplicationTokensAndWritesLoginResponse() throws Exception { + User user = user(1L, "user@example.com", "Existing User", Role.USER); + OAuthGoogleRequestDto request = googleRequest("google-refresh-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(jwtTokenProvider.createAccessToken("user@example.com", 1L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + + Authentication authentication = googleLoginService.handleLogin(request, user, response); + + assertThat(authentication.getPrincipal()).isSameAs(user); + assertThat(user.getSocialLoginToken()).isEqualTo("google-refresh-token"); + assertThat(user.getAccessToken()).isEqualTo("access-token"); + assertThat(user.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(response.getContentAsString()).contains("\"message\": \"로그인에 성공하였습니다.\""); + verify(jwtTokenProvider).sendAccessAndRefreshToken(response, "access-token", "refresh-token"); + verify(userRepository).saveAndFlush(user); + } + + @Test + void handleRegisterCreatesGuestUserSettingsAndDefaultAlarmSettings() throws Exception { + OAuthGoogleRequestDto request = googleRequest("google-refresh-token"); + OAuthGoogleUserDto googleUser = new OAuthGoogleUserDto( + "google-id", + "New User", + "https://example.com/profile.png", + "new@example.com" + ); + User savedUser = user(2L, "new@example.com", "New User", Role.GUEST); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + when(jwtTokenProvider.createAccessToken("new@example.com", 2L)).thenReturn("access-token"); + when(jwtTokenProvider.createRefreshToken()).thenReturn("refresh-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication authentication = googleLoginService.handleRegister(request, googleUser, response); + + assertThat(authentication.getPrincipal()).isSameAs(savedUser); + assertThat(savedUser.getAccessToken()).isEqualTo("access-token"); + assertThat(savedUser.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(response.getContentAsString()).contains("회원가입에 성공하였습니다."); + verify(jwtTokenProvider).createAccessToken("new@example.com", 2L); + verify(userRepository, times(2)).save(any(User.class)); + verify(userAlarmSettingRepository).save(any(UserAlarmSetting.class)); + } + + @Test + void constructorTrimsAndFiltersAllowedGoogleAudienceClientIds() { + GoogleLoginService service = new GoogleLoginService( + jwtTokenProvider, + userRepository, + userAlarmSettingRepository, + "web-client.apps.googleusercontent.com", + " ios-client.apps.googleusercontent.com, ,android-client.apps.googleusercontent.com " + ); + + @SuppressWarnings("unchecked") + List validClientIds = (List) ReflectionTestUtils.getField(service, "validClientIds"); + + assertThat(validClientIds).containsExactly( + "web-client.apps.googleusercontent.com", + "ios-client.apps.googleusercontent.com", + "android-client.apps.googleusercontent.com" + ); + } + + @Test + void verifyIdentityTokenRejectsMalformedCredentialsBeforeAuthentication() { + assertThatThrownBy(() -> googleLoginService.verifyIdentityToken("not-a-google-jwt")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void revokeTokenPostsTheStoredGoogleRefreshTokenAndReturnsSuccessFor2xxResponse() { + User user = user(4L, "user@example.com", "Existing User", Role.USER); + user.updateSocialLoginToken("google-refresh-token"); + when(userRepository.findById(4L)).thenReturn(Optional.of(user)); + when(revokeRestTemplate.exchange( + eq("https://oauth2.googleapis.com/revoke?token=google-refresh-token"), + eq(HttpMethod.POST), + any(), + eq(String.class) + )).thenReturn(ResponseEntity.ok("")); + + boolean revoked = googleLoginService.revokeToken(4L); + + assertThat(revoked).isTrue(); + } + + @Test + void revokeTokenFailsWhenTheUserDoesNotExist() { + when(userRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> googleLoginService.revokeToken(404L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User not found with id: 404"); + } + + @Test + void googleClaimDiagnosticsHandleParseableAndMalformedTokensWithoutExposingFullClientId() { + String parseableToken = fakeGoogleToken(""" + { + "aud": "web-client.apps.googleusercontent.com", + "azp": "android-client.apps.googleusercontent.com", + "iss": "https://accounts.google.com", + "exp": %d + } + """.formatted(Instant.now().plusSeconds(60).getEpochSecond())); + + ReflectionTestUtils.invokeMethod(googleLoginService, "logGoogleIdentityTokenClaims", parseableToken); + ReflectionTestUtils.invokeMethod(googleLoginService, "logGoogleIdentityTokenClaims", "malformed-token"); + + assertThat((String) ReflectionTestUtils.invokeMethod(googleLoginService, "maskClientId", "invalidclient")) + .isEqualTo(""); + } + + private String fakeGoogleToken(String payloadJson) { + return base64Url("{\"alg\":\"RS256\",\"kid\":\"test\"}") + + "." + + base64Url(payloadJson) + + ".signature"; + } + + private String base64Url(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private OAuthGoogleRequestDto googleRequest(String refreshToken) { + OAuthGoogleRequestDto request = new OAuthGoogleRequestDto(); + ReflectionTestUtils.setField(request, "idToken", "id-token"); + ReflectionTestUtils.setField(request, "refreshToken", refreshToken); + return request; + } + + private User user(Long id, String email, String name, Role role) { + return User.builder() + .id(id) + .email(email) + .name(name) + .spareTime(10) + .note("note") + .punctualityScore(95.0f) + .role(role) + .socialType(SocialType.GOOGLE) + .socialId("google-id") + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilterAuthenticationResultTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilterAuthenticationResultTest.java new file mode 100644 index 0000000..6dc68f8 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilterAuthenticationResultTest.java @@ -0,0 +1,78 @@ +package devkor.ontime_back.global.oauth.kakao; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.validation.Validation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class KakaoLoginFilterAuthenticationResultTest { + + private final TestableKakaoLoginFilter filter = new TestableKakaoLoginFilter(); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void successfulAuthenticationStoresAuthenticationAndReturnsOk() throws Exception { + User user = User.builder().id(1L).email("kakao@example.com").build(); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, null); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.callSuccessfulAuthentication(new MockHttpServletRequest(), response, mock(FilterChain.class), authentication); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(authentication); + } + + @Test + void unsuccessfulAuthenticationWritesUnauthorizedEnvelope() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.callUnsuccessfulAuthentication(new MockHttpServletRequest(), response, new BadCredentialsException("bad")); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("Authentication failed"); + } + + private static class TestableKakaoLoginFilter extends KakaoLoginFilter { + private TestableKakaoLoginFilter() { + super( + "/oauth2/kakao/login", + new ObjectMapper(), + Validation.buildDefaultValidatorFactory().getValidator(), + mock(JwtTokenProvider.class), + mock(UserRepository.class), + mock(UserAlarmSettingRepository.class) + ); + } + + void callSuccessfulAuthentication(MockHttpServletRequest request, + MockHttpServletResponse response, + FilterChain chain, + org.springframework.security.core.Authentication authResult) throws java.io.IOException, jakarta.servlet.ServletException { + super.successfulAuthentication(request, response, chain, authResult); + } + + void callUnsuccessfulAuthentication(MockHttpServletRequest request, + MockHttpServletResponse response, + org.springframework.security.core.AuthenticationException failed) throws java.io.IOException, jakarta.servlet.ServletException { + super.unsuccessfulAuthentication(request, response, failed); + } + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/logging/RequestLogPolicyTest.java b/ontime-back/src/test/java/devkor/ontime_back/logging/RequestLogPolicyTest.java new file mode 100644 index 0000000..0edd0f7 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/logging/RequestLogPolicyTest.java @@ -0,0 +1,69 @@ +package devkor.ontime_back.logging; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.assertj.core.api.Assertions.assertThat; + +class RequestLogPolicyTest { + + @Test + void resolveRequestIdReusesExistingRequestAttribute() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RequestLogPolicy.REQUEST_ID_ATTRIBUTE, "existing-id"); + request.addHeader(RequestLogPolicy.REQUEST_ID_HEADER, "header-id"); + + String requestId = RequestLogPolicy.resolveRequestId(request); + + assertThat(requestId).isEqualTo("existing-id"); + } + + @Test + void resolveRequestIdAcceptsSafeHeaderAndStoresItForTheRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(RequestLogPolicy.REQUEST_ID_HEADER, "safe.id-123:abc"); + + String requestId = RequestLogPolicy.resolveRequestId(request); + + assertThat(requestId).isEqualTo("safe.id-123:abc"); + assertThat(request.getAttribute(RequestLogPolicy.REQUEST_ID_ATTRIBUTE)).isEqualTo(requestId); + } + + @Test + void resolveRequestIdReplacesUnsafeHeaderWithGeneratedUuid() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(RequestLogPolicy.REQUEST_ID_HEADER, "not safe/value"); + + String requestId = RequestLogPolicy.resolveRequestId(request); + + assertThat(requestId).isNotEqualTo("not safe/value"); + assertThat(requestId).matches("[0-9a-f-]{36}"); + assertThat(request.getAttribute(RequestLogPolicy.REQUEST_ID_ATTRIBUTE)).isEqualTo(requestId); + } + + @Test + void exposeRequestIdWritesResponseHeaderWhenResponseExists() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + RequestLogPolicy.exposeRequestId(new ServletRequestAttributes(request, response), "request-123"); + + assertThat(response.getHeader(RequestLogPolicy.REQUEST_ID_HEADER)).isEqualTo("request-123"); + } + + @Test + void fieldLoggingPolicyOnlyAllowsKnownNonSensitiveFields() { + assertThat(RequestLogPolicy.isSafeFieldForLogging("requestId")).isTrue(); + assertThat(RequestLogPolicy.isSafeFieldForLogging("password")).isFalse(); + assertThat(RequestLogPolicy.isSafeFieldForLogging("unknownField")).isFalse(); + } + + @Test + void sensitiveFieldNamesAreMatchedCaseInsensitively() { + assertThat(RequestLogPolicy.isSensitiveFieldName("Authorization")).isTrue(); + assertThat(RequestLogPolicy.isSensitiveFieldName("client_secret")).isTrue(); + assertThat(RequestLogPolicy.isSensitiveFieldName("requestId")).isFalse(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/response/ApiResponseFormTest.java b/ontime-back/src/test/java/devkor/ontime_back/response/ApiResponseFormTest.java new file mode 100644 index 0000000..364535c --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/response/ApiResponseFormTest.java @@ -0,0 +1,52 @@ +package devkor.ontime_back.response; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiResponseFormTest { + + @Test + void successEnvelopeUsesOkDefaultsWhenMessageIsOmitted() { + ApiResponseForm> response = ApiResponseForm.success(Map.of("id", "1")); + + assertThat(response.getStatus()).isEqualTo("success"); + assertThat(response.getCode()).isEqualTo(200); + assertThat(response.getMessage()).isEqualTo("OK"); + assertThat(response.getData()).containsEntry("id", "1"); + } + + @Test + void authErrorFactoriesExposeDistinctTokenStatuses() { + assertThat(ApiResponseForm.accessTokenEmpty(401, "missing").getStatus()).isEqualTo("accessTokenEmpty"); + assertThat(ApiResponseForm.accessTokenInvalid(401, "bad").getStatus()).isEqualTo("accessTokenInvalid"); + assertThat(ApiResponseForm.refreshTokenInvalid(401, "bad refresh").getStatus()).isEqualTo("refreshTokenInvalid"); + } + + @Test + void failAndErrorEnvelopesDoNotIncludeSuccessDataByDefault() { + ApiResponseForm fail = ApiResponseForm.fail(400, "bad request"); + ApiResponseForm numericError = ApiResponseForm.error(500, "server error"); + ApiResponseForm symbolicError = ApiResponseForm.error("SCHEDULE_ALREADY_STARTED", "already started"); + + assertThat(fail.getStatus()).isEqualTo("fail"); + assertThat(fail.getData()).isNull(); + assertThat(numericError.getStatus()).isEqualTo("error"); + assertThat(numericError.getCode()).isEqualTo(500); + assertThat(symbolicError.getCode()).isEqualTo("SCHEDULE_ALREADY_STARTED"); + } + + @Test + void errorEnvelopeCanCarryValidationDetails() { + ApiResponseForm> response = ApiResponseForm.error( + 400, + "validation failed", + Map.of("field", "email") + ); + + assertThat(response.getStatus()).isEqualTo("error"); + assertThat(response.getData()).containsEntry("field", "email"); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/response/GlobalExceptionHandlerTest.java b/ontime-back/src/test/java/devkor/ontime_back/response/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..ea50c1e --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/response/GlobalExceptionHandlerTest.java @@ -0,0 +1,163 @@ +package devkor.ontime_back.response; + +import devkor.ontime_back.logging.RequestLogPolicy; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.method.MethodValidationResult; +import org.springframework.validation.method.ParameterValidationResult; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void scheduleLifecycleErrorsUseSymbolicCodeForFrontendBranching() { + ResponseEntity> response = handler.handleGeneralException( + new GeneralException(ErrorCode.SCHEDULE_ALREADY_STARTED) + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody().getStatus()).isEqualTo("error"); + assertThat(response.getBody().getCode()).isEqualTo("SCHEDULE_ALREADY_STARTED"); + } + + @Test + void ordinaryBusinessErrorsUseNumericApplicationCode() { + ResponseEntity> response = handler.handleGeneralException( + new GeneralException(ErrorCode.USER_NOT_FOUND) + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getCode()).isEqualTo(ErrorCode.USER_NOT_FOUND.getCode()); + } + + @Test + void invalidTokenExceptionReturnsUnauthorizedEnvelope() { + ResponseEntity> response = handler.handleInvalidTokenException( + new InvalidTokenException("bad token"), + new MockHttpServletRequest() + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody().getStatus()).isEqualTo("error"); + assertThat(response.getBody().getCode()).isEqualTo(401); + assertThat(response.getBody().getMessage()).isEqualTo("bad token"); + } + + @Test + void unreadableJsonResponseIncludesRequestIdHeaderAndValidationBody() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/schedules"); + request.addHeader(RequestLogPolicy.REQUEST_ID_HEADER, "request-99"); + + ResponseEntity> response = + handler.handleHttpMessageNotReadableException( + new HttpMessageNotReadableException("bad json", emptyInputMessage()), + request + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getFirst(RequestLogPolicy.REQUEST_ID_HEADER)).isEqualTo("request-99"); + assertThat(response.getBody().getData().errors()) + .containsExactly(new ValidationErrorResponse.FieldError("request", "요청 형식이 올바르지 않습니다.")); + } + + @Test + void typeMismatchResponseNamesTheBadParameter() { + MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException( + "not-a-number", + Long.class, + "scheduleId", + (MethodParameter) null, + new NumberFormatException("bad") + ); + + ResponseEntity> response = + handler.handleMethodArgumentTypeMismatchException(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getData().errors()) + .containsExactly(new ValidationErrorResponse.FieldError("scheduleId", "요청 값의 형식이 올바르지 않습니다.")); + } + + @Test + void missingParameterResponseNamesTheRequiredParameter() { + ResponseEntity> response = + handler.handleMissingServletRequestParameterException( + new MissingServletRequestParameterException("from", "LocalDate") + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getData().errors()) + .containsExactly(new ValidationErrorResponse.FieldError("from", "필수 요청 값입니다.")); + } + + @Test + void handlerMethodValidationNamesIndexedAndKeyedParameters() throws Exception { + Method method = SampleController.class.getDeclaredMethod("sample", List.class, java.util.Map.class); + ParameterValidationResult indexedResult = new ParameterValidationResult( + new MethodParameter(method, 0), + List.of("bad"), + List.of(new DefaultMessageSourceResolvable(new String[]{"schedules[0]"}, "invalid item")), + List.of("bad"), + 0, + null + ); + ParameterValidationResult keyedResult = new ParameterValidationResult( + new MethodParameter(method, 1), + java.util.Map.of("from", "bad"), + List.of(new DefaultMessageSourceResolvable(new String[]{"range[from]"}, "invalid range")), + java.util.Map.of("from", "bad"), + null, + "from" + ); + HandlerMethodValidationException exception = new HandlerMethodValidationException( + MethodValidationResult.create(new SampleController(), method, List.of(indexedResult, keyedResult)) + ); + + ResponseEntity> response = + handler.handleHandlerMethodValidationException(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getData().errors()) + .containsExactly( + new ValidationErrorResponse.FieldError("request[0]", "invalid item"), + new ValidationErrorResponse.FieldError("request[from]", "invalid range") + ); + } + + private HttpInputMessage emptyInputMessage() { + return new HttpInputMessage() { + @Override + public InputStream getBody() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public org.springframework.http.HttpHeaders getHeaders() { + return new org.springframework.http.HttpHeaders(); + } + }; + } + + @SuppressWarnings("unused") + private static class SampleController { + void sample(List schedules, java.util.Map range) { + } + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/scheduler/NotificationSchedulerTest.java b/ontime-back/src/test/java/devkor/ontime_back/scheduler/NotificationSchedulerTest.java new file mode 100644 index 0000000..7c525f5 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/scheduler/NotificationSchedulerTest.java @@ -0,0 +1,70 @@ +package devkor.ontime_back.scheduler; + +import devkor.ontime_back.entity.Schedule; +import devkor.ontime_back.repository.ScheduleRepository; +import devkor.ontime_back.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +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 java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationSchedulerTest { + + @Mock + private NotificationService notificationService; + + @Mock + private ScheduleRepository scheduleRepository; + + private NotificationScheduler scheduler; + + @BeforeEach + void setUp() { + scheduler = new NotificationScheduler(notificationService, scheduleRepository); + } + + @Test + void sendEveningReminderLooksUpTomorrowSchedulesAndSendsTomorrowMessage() { + Schedule schedule = Schedule.builder().scheduleName("Tomorrow meeting").build(); + when(scheduleRepository.findSchedulesBetween(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(List.of(schedule)); + + scheduler.sendEveningReminder(); + + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(scheduleRepository).findSchedulesBetween(startCaptor.capture(), endCaptor.capture()); + assertThat(startCaptor.getValue().toLocalDate()).isEqualTo(LocalDateTime.now().plusDays(1).toLocalDate()); + assertThat(startCaptor.getValue().toLocalTime()).isEqualTo(LocalTime.MIDNIGHT); + assertThat(endCaptor.getValue().toLocalTime()).isEqualTo(LocalTime.MAX); + verify(notificationService).sendReminder(List.of(schedule), "내일 예정된 약속이 있습니다."); + } + + @Test + void sendMorningReminderLooksUpTodaySchedulesAndSendsTodayMessage() { + Schedule schedule = Schedule.builder().scheduleName("Today meeting").build(); + when(scheduleRepository.findSchedulesBetween(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(List.of(schedule)); + + scheduler.sendMorningReminder(); + + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(scheduleRepository).findSchedulesBetween(startCaptor.capture(), endCaptor.capture()); + assertThat(startCaptor.getValue().toLocalDate()).isEqualTo(LocalDateTime.now().toLocalDate()); + assertThat(startCaptor.getValue().toLocalTime()).isEqualTo(LocalTime.MIDNIGHT); + assertThat(endCaptor.getValue().toLocalTime()).isEqualTo(LocalTime.MAX); + verify(notificationService).sendReminder(List.of(schedule), "오늘 예정된 약속이 있습니다."); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java index 63519f4..342dafd 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java @@ -3,7 +3,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import devkor.ontime_back.dto.AlarmDeviceCurrentRequestDto; import devkor.ontime_back.dto.AlarmDeviceCurrentResponseDto; +import devkor.ontime_back.dto.AlarmDeviceUnregisterRequestDto; +import devkor.ontime_back.dto.AlarmSettingsResponseDto; +import devkor.ontime_back.dto.AlarmSettingsPatchDto; +import devkor.ontime_back.dto.AlarmStatusCurrentResponseDto; +import devkor.ontime_back.dto.AlarmStatusFailureDto; import devkor.ontime_back.dto.AlarmStatusReportRequestDto; +import devkor.ontime_back.dto.AlarmStatusReportResponseDto; import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.entity.UserAlarmStatus; @@ -18,9 +24,13 @@ 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 org.springframework.test.util.ReflectionTestUtils; +import java.math.BigInteger; +import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -31,6 +41,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentCaptor.forClass; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -108,6 +119,81 @@ void registerCurrentDeviceBindsSessionAndDeactivatesOlderDevices() { verify(userDeviceRepository).save(any(UserDevice.class)); } + @Test + @DisplayName("getAlarmSettings creates default settings when the user does not have them yet") + void getAlarmSettingsCreatesDefaultSettings() { + when(userAlarmSettingRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userAlarmSettingRepository.save(any(UserAlarmSetting.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + AlarmSettingsResponseDto response = alarmService.getAlarmSettings(USER_ID); + + assertThat(response.getAlarmsEnabled()).isTrue(); + assertThat(response.getDefaultAlarmOffsetMinutes()).isEqualTo(UserAlarmSetting.DEFAULT_ALARM_OFFSET_MINUTES); + verify(userAlarmSettingRepository).save(any(UserAlarmSetting.class)); + } + + @Test + @DisplayName("getAlarmSettings fails when defaults cannot be created for a missing user") + void getAlarmSettingsRejectsMissingUser() { + when(userAlarmSettingRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> alarmService.getAlarmSettings(USER_ID)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("patchAlarmSettings updates only the provided fields") + void patchAlarmSettingsUpdatesProvidedFields() { + UserAlarmSetting setting = UserAlarmSetting.defaultFor(user); + when(userAlarmSettingRepository.findByUserId(USER_ID)).thenReturn(Optional.of(setting)); + + AlarmSettingsResponseDto response = alarmService.patchAlarmSettings( + USER_ID, + Map.of("alarmsEnabled", false, "defaultAlarmOffsetMinutes", 30L) + ); + + assertThat(response.getAlarmsEnabled()).isFalse(); + assertThat(response.getDefaultAlarmOffsetMinutes()).isEqualTo(30); + } + + @Test + @DisplayName("patchAlarmSettings accepts the validated DTO shape used by the controller") + void patchAlarmSettingsDtoUpdatesProvidedFields() { + UserAlarmSetting setting = UserAlarmSetting.defaultFor(user); + when(userAlarmSettingRepository.findByUserId(USER_ID)).thenReturn(Optional.of(setting)); + AlarmSettingsPatchDto request = new AlarmSettingsPatchDto(); + ReflectionTestUtils.setField(request, "alarmsEnabled", false); + ReflectionTestUtils.setField(request, "defaultAlarmOffsetMinutes", BigInteger.valueOf(45)); + + AlarmSettingsResponseDto response = alarmService.patchAlarmSettings(USER_ID, request); + + assertThat(response.getAlarmsEnabled()).isFalse(); + assertThat(response.getDefaultAlarmOffsetMinutes()).isEqualTo(45); + } + + @Test + @DisplayName("patchAlarmSettings rejects an empty patch body") + void patchAlarmSettingsRejectsEmptyPatchBody() { + assertThatThrownBy(() -> alarmService.patchAlarmSettings(USER_ID, Map.of())) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("patchAlarmSettings rejects unknown fields to protect the API contract") + void patchAlarmSettingsRejectsUnknownFields() { + assertThatThrownBy(() -> alarmService.patchAlarmSettings(USER_ID, Map.of("token", "secret"))) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ALARM_SETTINGS_INVALID_FIELD); + } + @Test @DisplayName("reportAlarmStatus rejects a device whose session token is no longer current") void reportAlarmStatusRejectsWrongSession() { @@ -123,6 +209,222 @@ void reportAlarmStatusRejectsWrongSession() { .isEqualTo(ErrorCode.DEVICE_SESSION_NOT_ACTIVE); } + @Test + @DisplayName("reportAlarmStatus persists normalized counts and failure details for the active device session") + void reportAlarmStatusPersistsStatusForCurrentDeviceSession() { + UserDevice currentDevice = userDevice(DEVICE_ID, 11L); + currentDevice.bindSession(ACCESS_TOKEN, null); + AlarmStatusReportRequestDto request = AlarmStatusReportRequestDto.builder() + .deviceId(DEVICE_ID) + .reconciledAt(OffsetDateTime.parse("2026-05-05T09:00:00.000Z")) + .scheduleWindowStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 13, 0, 0)) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("armed") + .nativeAlarmProvider("iosAlarmKit") + .fallbackProvider("localNotification") + .armedScheduleCount(null) + .armedScheduleIds(null) + .skippedScheduleCount(null) + .failures(List.of(AlarmStatusFailureDto.builder() + .scheduleId("schedule-1") + .reason("platformError") + .build())) + .build(); + + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.of(currentDevice)); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userAlarmStatusRepository.findByUserDeviceUserDeviceId(11L)).thenReturn(Optional.empty()); + + AlarmStatusReportResponseDto response = alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN); + + assertThat(response.getReceived()).isTrue(); + ArgumentCaptor captor = forClass(UserAlarmStatus.class); + verify(userAlarmStatusRepository).save(captor.capture()); + UserAlarmStatus savedStatus = captor.getValue(); + assertThat(savedStatus.getDeviceId()).isEqualTo(DEVICE_ID); + assertThat(savedStatus.getArmedScheduleCount()).isZero(); + assertThat(savedStatus.getArmedScheduleIds()).isEqualTo("[]"); + assertThat(savedStatus.getSkippedScheduleCount()).isZero(); + assertThat(savedStatus.getFailures()).contains("platformError"); + } + + @Test + @DisplayName("reportAlarmStatus updates an existing status record for the active device") + void reportAlarmStatusUpdatesExistingDeviceStatus() { + UserDevice currentDevice = userDevice(DEVICE_ID, 11L); + currentDevice.bindSession(ACCESS_TOKEN, null); + UserAlarmStatus existingStatus = UserAlarmStatus.create(user, currentDevice); + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.of(currentDevice)); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userAlarmStatusRepository.findByUserDeviceUserDeviceId(11L)).thenReturn(Optional.of(existingStatus)); + + alarmService.reportAlarmStatus(USER_ID, validStatusReport(), ACCESS_TOKEN); + + assertThat(existingStatus.getStatus()).isEqualTo("armed"); + verify(userAlarmStatusRepository).save(existingStatus); + } + + @Test + @DisplayName("getCurrentAlarmStatus returns inactive when no active device belongs to the access token") + void getCurrentAlarmStatusReturnsInactiveWithoutCurrentSessionDevice() { + UserDevice otherDevice = userDevice(DEVICE_ID, 12L); + otherDevice.bindSession("other-token", null); + when(userDeviceRepository.findAllByUserIdAndActiveTrue(USER_ID)).thenReturn(List.of(otherDevice)); + + AlarmStatusCurrentResponseDto response = alarmService.getCurrentAlarmStatus(USER_ID, ACCESS_TOKEN); + + assertThat(response.getActive()).isFalse(); + } + + @Test + @DisplayName("getCurrentAlarmStatus returns the active device and parsed status payload") + void getCurrentAlarmStatusReturnsDeviceAndLatestStatus() { + UserDevice currentDevice = userDevice(DEVICE_ID, 12L); + currentDevice.bindSession(ACCESS_TOKEN, null); + UserAlarmStatus status = UserAlarmStatus.builder() + .user(user) + .userDevice(currentDevice) + .deviceId(DEVICE_ID) + .reconciledAt(Instant.parse("2026-05-05T09:00:00Z")) + .scheduleWindowStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 13, 0, 0)) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("partial") + .permissionIssue("nativePermissionDenied") + .nativeAlarmProvider("none") + .fallbackProvider("localNotification") + .armedScheduleCount(1) + .armedScheduleIds("[\"schedule-1\"]") + .skippedScheduleCount(2) + .failures("[{\"scheduleId\":\"schedule-2\",\"reason\":\"scheduleInvalid\"}]") + .updatedAt(Instant.parse("2026-05-05T09:01:00Z")) + .build(); + + when(userDeviceRepository.findAllByUserIdAndActiveTrue(USER_ID)).thenReturn(List.of(currentDevice)); + when(userAlarmStatusRepository.findByUserDeviceUserDeviceId(12L)).thenReturn(Optional.of(status)); + + AlarmStatusCurrentResponseDto response = alarmService.getCurrentAlarmStatus(USER_ID, ACCESS_TOKEN); + + assertThat(response.getActive()).isTrue(); + assertThat(response.getDeviceId()).isEqualTo(DEVICE_ID); + assertThat(response.getStatus()).isEqualTo("partial"); + assertThat(response.getArmedScheduleIds()).containsExactly("schedule-1"); + assertThat(response.getFailures()).extracting(AlarmStatusFailureDto::getReason).containsExactly("scheduleInvalid"); + } + + @Test + @DisplayName("getCurrentAlarmStatus treats malformed stored JSON as empty lists") + void getCurrentAlarmStatusIgnoresMalformedStoredJsonLists() { + UserDevice currentDevice = userDevice(DEVICE_ID, 12L); + currentDevice.bindSession(ACCESS_TOKEN, null); + UserAlarmStatus status = UserAlarmStatus.builder() + .user(user) + .userDevice(currentDevice) + .deviceId(DEVICE_ID) + .reconciledAt(Instant.parse("2026-05-05T09:00:00Z")) + .scheduleWindowStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 13, 0, 0)) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("partial") + .nativeAlarmProvider("none") + .fallbackProvider("localNotification") + .armedScheduleCount(1) + .armedScheduleIds("not-json") + .skippedScheduleCount(2) + .failures("not-json") + .updatedAt(Instant.parse("2026-05-05T09:01:00Z")) + .build(); + when(userDeviceRepository.findAllByUserIdAndActiveTrue(USER_ID)).thenReturn(List.of(currentDevice)); + when(userAlarmStatusRepository.findByUserDeviceUserDeviceId(12L)).thenReturn(Optional.of(status)); + + AlarmStatusCurrentResponseDto response = alarmService.getCurrentAlarmStatus(USER_ID, ACCESS_TOKEN); + + assertThat(response.getArmedScheduleIds()).isEmpty(); + assertThat(response.getFailures()).isEmpty(); + } + + @Test + @DisplayName("unregisterCurrentDevice deactivates the named current-session device") + void unregisterCurrentDeviceDeactivatesNamedSessionDevice() { + UserDevice currentDevice = userDevice(DEVICE_ID, 13L); + currentDevice.bindSession(ACCESS_TOKEN, null); + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.of(currentDevice)); + + alarmService.unregisterCurrentDevice( + USER_ID, + AlarmDeviceUnregisterRequestDto.builder().deviceId(DEVICE_ID).build(), + ACCESS_TOKEN + ); + + assertThat(currentDevice.getActive()).isFalse(); + } + + @Test + @DisplayName("unregisterCurrentDevice rejects a named device that is not active") + void unregisterCurrentDeviceRejectsMissingNamedDevice() { + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> alarmService.unregisterCurrentDevice( + USER_ID, + AlarmDeviceUnregisterRequestDto.builder().deviceId(DEVICE_ID).build(), + ACCESS_TOKEN + )) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.DEVICE_SESSION_NOT_ACTIVE); + } + + @Test + @DisplayName("unregisterCurrentDevice deactivates only devices bound to the current session when present") + void unregisterCurrentDeviceWithoutDeviceIdPrefersCurrentSessionDevices() { + UserDevice currentDevice = userDevice(DEVICE_ID, 13L); + currentDevice.bindSession(ACCESS_TOKEN, null); + UserDevice otherDevice = userDevice("other-device-id-1234", 14L); + otherDevice.bindSession("other-token", null); + when(userDeviceRepository.findAllByUserIdAndActiveTrue(USER_ID)).thenReturn(List.of(currentDevice, otherDevice)); + + alarmService.unregisterCurrentDevice(USER_ID, null, ACCESS_TOKEN); + + assertThat(currentDevice.getActive()).isFalse(); + assertThat(otherDevice.getActive()).isTrue(); + } + + @Test + @DisplayName("unregisterCurrentDevice deactivates all active devices when none belongs to the current session") + void unregisterCurrentDeviceWithoutDeviceIdDeactivatesAllDevicesWhenNoSessionDeviceMatches() { + UserDevice firstDevice = userDevice(DEVICE_ID, 13L); + firstDevice.bindSession("first-token", null); + UserDevice secondDevice = userDevice("other-device-id-1234", 14L); + secondDevice.bindSession("second-token", null); + when(userDeviceRepository.findAllByUserIdAndActiveTrue(USER_ID)).thenReturn(List.of(firstDevice, secondDevice)); + + alarmService.unregisterCurrentDevice(USER_ID, null, ACCESS_TOKEN); + + assertThat(firstDevice.getActive()).isFalse(); + assertThat(secondDevice.getActive()).isFalse(); + } + + @Test + @DisplayName("linkFirebaseToken writes the token only for the active device session") + void linkFirebaseTokenUpdatesCurrentSessionDevice() { + UserDevice currentDevice = userDevice(DEVICE_ID, 15L); + currentDevice.bindSession(ACCESS_TOKEN, null); + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.of(currentDevice)); + + alarmService.linkFirebaseToken(USER_ID, DEVICE_ID, "firebase-token", ACCESS_TOKEN); + + assertThat(currentDevice.getFirebaseToken()).isEqualTo("firebase-token"); + } + @Test @DisplayName("shouldSuppressLegacyReminder only suppresses fresh current-session armed schedules") void shouldSuppressLegacyReminderRequiresCurrentSessionAndArmedSchedule() { @@ -163,6 +465,46 @@ void shouldSuppressLegacyReminderRequiresCurrentSessionAndArmedSchedule() { assertThat(shouldSuppress).isTrue(); } + @Test + @DisplayName("shouldSuppressLegacyReminder does not trust stale native alarm reconciliation") + void shouldSuppressLegacyReminderReturnsFalseForStaleAlarmStatus() { + UUID scheduleId = UUID.fromString("123e4567-e89b-12d3-a456-426614170105"); + UserDevice currentDevice = userDevice(DEVICE_ID, 12L); + currentDevice.bindSession(ACCESS_TOKEN, null); + UserAlarmStatus alarmStatus = UserAlarmStatus.builder() + .user(user) + .userDevice(currentDevice) + .deviceId(DEVICE_ID) + .reconciledAt(Instant.now().minus(Duration.ofHours(25))) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("armed") + .nativeAlarmProvider("androidAlarmManager") + .fallbackProvider("none") + .armedScheduleCount(1) + .armedScheduleIds("[\"" + scheduleId + "\"]") + .skippedScheduleCount(0) + .failures("[]") + .updatedAt(Instant.now()) + .build(); + + when(userAlarmSettingRepository.findByUserId(USER_ID)) + .thenReturn(Optional.of(UserAlarmSetting.defaultFor(user))); + when(userDeviceRepository.findFirstByUserIdAndActiveTrueOrderByLastSeenAtDesc(USER_ID)) + .thenReturn(Optional.of(currentDevice)); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userAlarmStatusRepository.findByUserDeviceUserDeviceId(currentDevice.getUserDeviceId())) + .thenReturn(Optional.of(alarmStatus)); + + boolean shouldSuppress = alarmService.shouldSuppressLegacyReminder( + USER_ID, + scheduleId, + LocalDateTime.of(2026, 5, 5, 8, 50) + ); + + assertThat(shouldSuppress).isFalse(); + } + @Test @DisplayName("patchAlarmSettings rejects fractional defaultAlarmOffsetMinutes") void patchAlarmSettingsRejectsFractionalOffset() { @@ -175,6 +517,120 @@ void patchAlarmSettingsRejectsFractionalOffset() { .isEqualTo(ErrorCode.INVALID_INPUT); } + @Test + @DisplayName("patchAlarmSettings accepts small integer JSON numeric types") + void patchAlarmSettingsAcceptsShortAndByteOffsets() { + UserAlarmSetting setting = UserAlarmSetting.defaultFor(user); + when(userAlarmSettingRepository.findByUserId(USER_ID)).thenReturn(Optional.of(setting)); + + AlarmSettingsResponseDto shortResponse = alarmService.patchAlarmSettings( + USER_ID, + Map.of("defaultAlarmOffsetMinutes", Short.valueOf((short) 12)) + ); + AlarmSettingsResponseDto byteResponse = alarmService.patchAlarmSettings( + USER_ID, + Map.of("defaultAlarmOffsetMinutes", Byte.valueOf((byte) 5)) + ); + + assertThat(shortResponse.getDefaultAlarmOffsetMinutes()).isEqualTo(12); + assertThat(byteResponse.getDefaultAlarmOffsetMinutes()).isEqualTo(5); + } + + @Test + @DisplayName("patchAlarmSettings rejects integer values outside supported API range") + void patchAlarmSettingsRejectsOutOfRangeNumericTypes() { + assertThatThrownBy(() -> alarmService.patchAlarmSettings( + USER_ID, + Map.of("defaultAlarmOffsetMinutes", BigInteger.valueOf(Integer.MAX_VALUE).add(BigInteger.ONE)) + )) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("registerCurrentDevice rejects provider combinations that cannot run on the platform") + void registerCurrentDeviceRejectsWrongPlatformProvider() { + AlarmDeviceCurrentRequestDto request = AlarmDeviceCurrentRequestDto.builder() + .deviceId(DEVICE_ID) + .platform("ios") + .supportsNativeAlarm(true) + .nativeAlarmProvider("androidAlarmManager") + .fallbackProvider("localNotification") + .build(); + + assertThatThrownBy(() -> alarmService.registerCurrentDevice(USER_ID, request, ACCESS_TOKEN, REFRESH_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("registerCurrentDevice rejects missing request bodies") + void registerCurrentDeviceRejectsMissingRequestBody() { + assertThatThrownBy(() -> alarmService.registerCurrentDevice(USER_ID, null, ACCESS_TOKEN, REFRESH_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("registerCurrentDevice rejects blank access tokens") + void registerCurrentDeviceRejectsBlankAccessToken() { + assertThatThrownBy(() -> alarmService.registerCurrentDevice( + USER_ID, + validDeviceRegistration().build(), + " ", + REFRESH_TOKEN + )) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("registerCurrentDevice rejects app metadata that cannot fit the API contract") + void registerCurrentDeviceRejectsOverlongVersionMetadata() { + AlarmDeviceCurrentRequestDto request = validDeviceRegistration() + .appVersion("v".repeat(129)) + .build(); + + assertThatThrownBy(() -> alarmService.registerCurrentDevice(USER_ID, request, ACCESS_TOKEN, REFRESH_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("registerCurrentDevice rejects native providers when the device says native alarms are unsupported") + void registerCurrentDeviceRejectsNativeProviderWithoutNativeSupport() { + AlarmDeviceCurrentRequestDto request = validDeviceRegistration() + .supportsNativeAlarm(false) + .nativeAlarmProvider("iosAlarmKit") + .build(); + + assertThatThrownBy(() -> alarmService.registerCurrentDevice(USER_ID, request, ACCESS_TOKEN, REFRESH_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("registerCurrentDevice fails when the current user no longer exists") + void registerCurrentDeviceRejectsMissingUser() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> alarmService.registerCurrentDevice( + USER_ID, + validDeviceRegistration().build(), + ACCESS_TOKEN, + REFRESH_TOKEN + )) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + @Test @DisplayName("reportAlarmStatus rejects unsupported when local notification fallback is active") void reportAlarmStatusRejectsUnsupportedWithFallbackCoverage() { @@ -188,7 +644,250 @@ void reportAlarmStatusRejectsUnsupportedWithFallbackCoverage() { .isEqualTo(ErrorCode.INVALID_INPUT); } + @Test + @DisplayName("reportAlarmStatus rejects inverted schedule windows") + void reportAlarmStatusRejectsInvalidScheduleWindow() { + AlarmStatusReportRequestDto request = validStatusReportBuilder() + .scheduleWindowStart(LocalDateTime.of(2026, 5, 6, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 5, 0, 0)) + .build(); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects unknown permission issues") + void reportAlarmStatusRejectsInvalidPermissionIssue() { + AlarmStatusReportRequestDto request = validStatusReportBuilder() + .permissionIssue("cameraPermissionDenied") + .build(); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects unknown failure reasons") + void reportAlarmStatusRejectsInvalidFailureReason() { + AlarmStatusReportRequestDto request = validStatusReportBuilder() + .failures(List.of(AlarmStatusFailureDto.builder() + .scheduleId("schedule-1") + .reason("networkUnavailable") + .build())) + .build(); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects negative alarm counts") + void reportAlarmStatusRejectsNegativeCounts() { + AlarmStatusReportRequestDto request = validStatusReportBuilder() + .armedScheduleCount(-1) + .build(); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects negative skipped counts") + void reportAlarmStatusRejectsNegativeSkippedCounts() { + AlarmStatusReportRequestDto request = validStatusReportBuilder() + .skippedScheduleCount(-1) + .build(); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects inverted alarm coverage windows") + void reportAlarmStatusRejectsInvalidCoverageWindow() { + AlarmStatusReportRequestDto request = validStatusReportBuilder() + .alarmCoverageStart(LocalDateTime.of(2026, 5, 6, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 5, 0, 0)) + .build(); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects missing required status payloads") + void reportAlarmStatusRejectsMissingStatusPayload() { + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, null, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects null failure entries") + void reportAlarmStatusRejectsNullFailureEntry() { + AlarmStatusReportRequestDto request = validStatusReportBuilder() + .failures(java.util.Arrays.asList((AlarmStatusFailureDto) null)) + .build(); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, request, ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus fails when the owning user disappears before status persistence") + void reportAlarmStatusRejectsMissingUserAfterSessionValidation() { + UserDevice currentDevice = userDevice(DEVICE_ID, 11L); + currentDevice.bindSession(ACCESS_TOKEN, null); + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.of(currentDevice)); + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, validStatusReport(), ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("linkFirebaseToken rejects missing active device sessions") + void linkFirebaseTokenRejectsMissingCurrentDevice() { + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> alarmService.linkFirebaseToken(USER_ID, DEVICE_ID, "firebase-token", ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.DEVICE_SESSION_NOT_ACTIVE); + } + + @Test + @DisplayName("shouldSuppressLegacyReminder does not suppress when user disabled native alarms") + void shouldSuppressLegacyReminderReturnsFalseWhenAlarmsDisabled() { + UserAlarmSetting setting = UserAlarmSetting.defaultFor(user); + setting.update(false, null); + when(userAlarmSettingRepository.findByUserId(USER_ID)).thenReturn(Optional.of(setting)); + + boolean shouldSuppress = alarmService.shouldSuppressLegacyReminder( + USER_ID, + UUID.randomUUID(), + LocalDateTime.of(2026, 5, 5, 8, 50) + ); + + assertThat(shouldSuppress).isFalse(); + } + + @Test + @DisplayName("shouldSuppressLegacyReminder does not suppress partial reports with no armed schedules") + void shouldSuppressLegacyReminderReturnsFalseForPartialStatusWithoutArmedSchedules() { + UUID scheduleId = UUID.fromString("123e4567-e89b-12d3-a456-426614170105"); + UserAlarmStatus status = suppressibleStatus(scheduleId); + status.replace( + Instant.now(), + status.getScheduleWindowStart(), + status.getScheduleWindowEnd(), + status.getAlarmCoverageStart(), + status.getAlarmCoverageEnd(), + "partial", + null, + "iosAlarmKit", + "localNotification", + 0, + "[\"" + scheduleId + "\"]", + 0, + "[]" + ); + stubSuppressionContext(status); + + boolean shouldSuppress = alarmService.shouldSuppressLegacyReminder( + USER_ID, + scheduleId, + LocalDateTime.of(2026, 5, 5, 8, 50) + ); + + assertThat(shouldSuppress).isFalse(); + } + + @Test + @DisplayName("shouldSuppressLegacyReminder requires the reminder time to be inside native coverage") + void shouldSuppressLegacyReminderReturnsFalseOutsideCoverageWindow() { + UUID scheduleId = UUID.fromString("123e4567-e89b-12d3-a456-426614170105"); + stubSuppressionContext(suppressibleStatus(scheduleId)); + + boolean shouldSuppress = alarmService.shouldSuppressLegacyReminder( + USER_ID, + scheduleId, + LocalDateTime.of(2026, 5, 12, 0, 0) + ); + + assertThat(shouldSuppress).isFalse(); + } + + @Test + @DisplayName("shouldSuppressLegacyReminder requires the schedule id to be in the armed schedule list") + void shouldSuppressLegacyReminderReturnsFalseWhenScheduleWasNotArmed() { + UUID scheduleId = UUID.fromString("123e4567-e89b-12d3-a456-426614170105"); + UUID otherScheduleId = UUID.fromString("123e4567-e89b-12d3-a456-426614170106"); + stubSuppressionContext(suppressibleStatus(otherScheduleId)); + + boolean shouldSuppress = alarmService.shouldSuppressLegacyReminder( + USER_ID, + scheduleId, + LocalDateTime.of(2026, 5, 5, 8, 50) + ); + + assertThat(shouldSuppress).isFalse(); + } + + @Test + @DisplayName("shouldSuppressLegacyReminder does not suppress when no provider covers the schedule") + void shouldSuppressLegacyReminderReturnsFalseWithoutProviderCoverage() { + UUID scheduleId = UUID.fromString("123e4567-e89b-12d3-a456-426614170105"); + UserAlarmStatus status = suppressibleStatus(scheduleId); + status.replace( + Instant.now(), + status.getScheduleWindowStart(), + status.getScheduleWindowEnd(), + status.getAlarmCoverageStart(), + status.getAlarmCoverageEnd(), + "armed", + null, + "none", + "none", + 1, + "[\"" + scheduleId + "\"]", + 0, + "[]" + ); + stubSuppressionContext(status); + + assertThat(alarmService.shouldSuppressLegacyReminder( + USER_ID, + scheduleId, + LocalDateTime.of(2026, 5, 5, 8, 50) + )).isFalse(); + } + private AlarmStatusReportRequestDto validStatusReport() { + return validStatusReportBuilder().build(); + } + + private AlarmStatusReportRequestDto.AlarmStatusReportRequestDtoBuilder validStatusReportBuilder() { return AlarmStatusReportRequestDto.builder() .deviceId(DEVICE_ID) .reconciledAt(OffsetDateTime.parse("2026-05-05T09:00:00.000Z")) @@ -201,8 +900,7 @@ private AlarmStatusReportRequestDto validStatusReport() { .fallbackProvider("localNotification") .armedScheduleCount(0) .armedScheduleIds(List.of()) - .skippedScheduleCount(0) - .build(); + .skippedScheduleCount(0); } private AlarmStatusReportRequestDto unsupportedWithFallbackStatusReport() { @@ -222,6 +920,52 @@ private AlarmStatusReportRequestDto unsupportedWithFallbackStatusReport() { .build(); } + private AlarmDeviceCurrentRequestDto.AlarmDeviceCurrentRequestDtoBuilder validDeviceRegistration() { + return AlarmDeviceCurrentRequestDto.builder() + .deviceId(DEVICE_ID) + .platform("ios") + .appVersion("1.4.0") + .osVersion("26.0") + .supportsNativeAlarm(true) + .nativeAlarmProvider("iosAlarmKit") + .fallbackProvider("localNotification"); + } + + private void stubSuppressionContext(UserAlarmStatus status) { + UserDevice currentDevice = status.getUserDevice(); + currentDevice.bindSession(ACCESS_TOKEN, null); + when(userAlarmSettingRepository.findByUserId(USER_ID)) + .thenReturn(Optional.of(UserAlarmSetting.defaultFor(user))); + when(userDeviceRepository.findFirstByUserIdAndActiveTrueOrderByLastSeenAtDesc(USER_ID)) + .thenReturn(Optional.of(currentDevice)); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userAlarmStatusRepository.findByUserDeviceUserDeviceId(currentDevice.getUserDeviceId())) + .thenReturn(Optional.of(status)); + } + + private UserAlarmStatus suppressibleStatus(UUID scheduleId) { + UserDevice currentDevice = userDevice(DEVICE_ID, 12L); + currentDevice.bindSession(ACCESS_TOKEN, null); + return UserAlarmStatus.builder() + .user(user) + .userDevice(currentDevice) + .deviceId(DEVICE_ID) + .reconciledAt(Instant.now()) + .scheduleWindowStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 13, 0, 0)) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("armed") + .nativeAlarmProvider("iosAlarmKit") + .fallbackProvider("localNotification") + .armedScheduleCount(1) + .armedScheduleIds("[\"" + scheduleId + "\"]") + .skippedScheduleCount(0) + .failures("[]") + .updatedAt(Instant.now()) + .build(); + } + private UserDevice userDevice(String deviceId, Long userDeviceId) { return UserDevice.builder() .userDeviceId(userDeviceId) diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/FeedbackServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/FeedbackServiceTest.java new file mode 100644 index 0000000..7ab8507 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/FeedbackServiceTest.java @@ -0,0 +1,70 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.dto.FeedbackAddDto; +import devkor.ontime_back.entity.Feedback; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.FeedbackRepository; +import devkor.ontime_back.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +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 java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FeedbackServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private FeedbackRepository feedbackRepository; + + private FeedbackService feedbackService; + + @BeforeEach + void setUp() { + feedbackService = new FeedbackService(userRepository, feedbackRepository); + } + + @Test + void saveFeedbackPersistsFeedbackForTheCurrentUser() { + User user = User.builder().id(1L).email("user@example.com").build(); + UUID feedbackId = UUID.randomUUID(); + FeedbackAddDto dto = FeedbackAddDto.builder() + .feedbackId(feedbackId) + .message("사용자 피드백") + .build(); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + feedbackService.saveFeedback(1L, dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Feedback.class); + verify(feedbackRepository).save(captor.capture()); + assertThat(captor.getValue().getFeedbackId()).isEqualTo(feedbackId); + assertThat(captor.getValue().getUser()).isSameAs(user); + assertThat(captor.getValue().getMessage()).isEqualTo("사용자 피드백"); + assertThat(captor.getValue().getCreateAt()).isNotNull(); + } + + @Test + void saveFeedbackFailsWhenTheUserDoesNotExist() { + when(userRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> feedbackService.saveFeedback( + 404L, + FeedbackAddDto.builder().feedbackId(UUID.randomUUID()).message("message").build() + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User not found"); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/FirebaseTokenServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/FirebaseTokenServiceTest.java new file mode 100644 index 0000000..1b18502 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/FirebaseTokenServiceTest.java @@ -0,0 +1,89 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.dto.FirebaseTokenAddDto; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FirebaseTokenServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private AlarmService alarmService; + + private FirebaseTokenService firebaseTokenService; + + @BeforeEach + void setUp() { + firebaseTokenService = new FirebaseTokenService(userRepository, alarmService); + } + + @Test + void registerFirebaseTokenUpdatesUserAndLinksCurrentDeviceWhenDeviceIdIsPresent() { + User user = User.builder().id(1L).build(); + FirebaseTokenAddDto dto = firebaseTokenAddDto("firebase-token", "device-id-1234567"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + firebaseTokenService.registerFirebaseToken(1L, dto, "access-token"); + + assertThat(user.getFirebaseToken()).isEqualTo("firebase-token"); + verify(alarmService).linkFirebaseToken(1L, "device-id-1234567", "firebase-token", "access-token"); + verify(userRepository).save(user); + } + + @Test + void registerFirebaseTokenDoesNotLinkBlankDeviceId() { + User user = User.builder().id(1L).build(); + FirebaseTokenAddDto dto = firebaseTokenAddDto("firebase-token", " "); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + firebaseTokenService.registerFirebaseToken(1L, dto, "access-token"); + + assertThat(user.getFirebaseToken()).isEqualTo("firebase-token"); + verifyNoInteractions(alarmService); + verify(userRepository).save(user); + } + + @Test + void registerFirebaseTokenFailsWhenUserDoesNotExist() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> firebaseTokenService.registerFirebaseToken( + 1L, + firebaseTokenAddDto("firebase-token", "device-id-1234567"), + "access-token" + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User not found"); + } + + @Test + void sendTestNotificationFailsClearlyWhenUserHasNoFirebaseTokenRecord() { + when(userRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> firebaseTokenService.sendTestNotification(404L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("FirebaseToken not found"); + } + + private FirebaseTokenAddDto firebaseTokenAddDto(String firebaseToken, String deviceId) { + FirebaseTokenAddDto dto = new FirebaseTokenAddDto(); + ReflectionTestUtils.setField(dto, "firebaseToken", firebaseToken); + ReflectionTestUtils.setField(dto, "deviceId", deviceId); + return dto; + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/NotificationServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/NotificationServiceTest.java new file mode 100644 index 0000000..a6b311b --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/NotificationServiceTest.java @@ -0,0 +1,234 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.entity.NotificationSchedule; +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.Schedule; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserSetting; +import devkor.ontime_back.repository.NotificationScheduleRepository; +import devkor.ontime_back.repository.UserSettingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ScheduledFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private UserSettingRepository userSettingRepository; + + @Mock + private AlarmService alarmService; + + @Mock + private TaskScheduler taskScheduler; + + @Mock + private NotificationScheduleRepository notificationScheduleRepository; + + private NotificationService notificationService; + + @BeforeEach + void setUp() { + notificationService = new NotificationService( + userSettingRepository, + alarmService, + taskScheduler, + notificationScheduleRepository + ); + } + + @Test + void scheduleReminderIgnoresPastReminderTimes() { + NotificationSchedule notification = notificationSchedule(LocalDateTime.now().minusMinutes(1)); + + notificationService.scheduleReminder(notification); + + verifyNoInteractions(taskScheduler); + } + + @Test + void scheduleReminderRegistersFutureTaskAndCanCancelIt() { + NotificationSchedule notification = notificationSchedule(LocalDateTime.now().plusHours(1)); + ReflectionTestUtils.setField(notification, "id", 10L); + ScheduledFuture future = mock(ScheduledFuture.class); + doReturn(future).when(taskScheduler).schedule(any(Runnable.class), any(Date.class)); + when(future.isCancelled()).thenReturn(false); + + notificationService.scheduleReminder(notification); + notificationService.cancelScheduledNotification(10L); + + verify(taskScheduler).schedule(any(Runnable.class), any(Date.class)); + verify(future).cancel(true); + } + + @Test + void sendReminderDoesNothingWhenUserIdIsMissing() { + NotificationSchedule notification = NotificationSchedule.builder() + .notificationTime(LocalDateTime.now()) + .isSent(false) + .schedule(Schedule.builder() + .scheduleId(UUID.randomUUID()) + .scheduleName("No owner") + .user(User.builder().build()) + .build()) + .build(); + + notificationService.sendReminder(notification, "message"); + + verifyNoInteractions(userSettingRepository, alarmService, notificationScheduleRepository); + assertThat(notification.getIsSent()).isFalse(); + } + + @Test + void sendReminderDoesNotSendWhenUserDisabledNotifications() { + NotificationSchedule notification = notificationSchedule(LocalDateTime.now()); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.of(userSetting(false))); + + notificationService.sendReminder(notification, "message"); + + verifyNoInteractions(alarmService, notificationScheduleRepository); + assertThat(notification.getIsSent()).isFalse(); + } + + @Test + void sendReminderDoesNotSendWhenNativeAlarmAlreadyCoversSchedule() { + NotificationSchedule notification = notificationSchedule(LocalDateTime.now()); + UUID scheduleId = notification.getSchedule().getScheduleId(); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.of(userSetting(true))); + when(alarmService.shouldSuppressLegacyReminder(1L, scheduleId, notification.getNotificationTime())) + .thenReturn(true); + + notificationService.sendReminder(notification, "message"); + + verify(notificationScheduleRepository, never()).save(notification); + assertThat(notification.getIsSent()).isFalse(); + } + + @Test + void sendReminderMarksNotificationSentWhenUserEnabledNotificationsAndNativeAlarmDoesNotCoverIt() { + NotificationSchedule notification = notificationSchedule(LocalDateTime.now()); + UUID scheduleId = notification.getSchedule().getScheduleId(); + NotificationService spyService = spy(notificationService); + doNothing().when(spyService).sendNotificationToUser(notification.getSchedule(), "message"); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.of(userSetting(true))); + when(alarmService.shouldSuppressLegacyReminder(1L, scheduleId, notification.getNotificationTime())) + .thenReturn(false); + + spyService.sendReminder(notification, "message"); + + verify(spyService).sendNotificationToUser(notification.getSchedule(), "message"); + verify(notificationScheduleRepository).save(notification); + assertThat(notification.getIsSent()).isTrue(); + } + + @Test + void sendReminderFailsClearlyWhenUserSettingsAreMissing() { + NotificationSchedule notification = notificationSchedule(LocalDateTime.now()); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> notificationService.sendReminder(notification, "message")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No UserSetting found in schedule's user"); + } + + @Test + void listReminderSkipsSchedulesWithoutPersistedUsers() { + Schedule schedule = Schedule.builder() + .scheduleId(UUID.randomUUID()) + .scheduleName("Unsaved user") + .user(User.builder().build()) + .build(); + + notificationService.sendReminder(List.of(schedule), "message"); + + verifyNoInteractions(userSettingRepository, alarmService, notificationScheduleRepository); + } + + @Test + void listReminderSendsOnlyForSchedulesWhoseUsersAllowNotifications() { + Schedule enabledSchedule = scheduleForListReminder(1L, "Enabled"); + Schedule disabledSchedule = scheduleForListReminder(2L, "Disabled"); + NotificationService spyService = spy(notificationService); + doNothing().when(spyService).sendNotificationToUser(any(Schedule.class), eq("message")); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.of(userSetting(true))); + when(userSettingRepository.findByUserId(2L)).thenReturn(Optional.of(userSetting(false))); + + spyService.sendReminder(List.of(enabledSchedule, disabledSchedule), "message"); + + verify(spyService).sendNotificationToUser(enabledSchedule, "message"); + verify(spyService, never()).sendNotificationToUser(disabledSchedule, "message"); + } + + @Test + void listReminderFailsClearlyWhenPersistedUserHasNoSettings() { + Schedule schedule = scheduleForListReminder(1L, "Missing setting"); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> notificationService.sendReminder(List.of(schedule), "message")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No UserSetting found in schedule's user"); + } + + @Test + void sendNotificationToUserDoesNotPropagateFirebaseClientFailures() { + Schedule schedule = scheduleForListReminder(1L, "Firebase smoke test"); + + notificationService.sendNotificationToUser(schedule, "message"); + + assertThat(schedule.getUser().getFirebaseToken()).isEqualTo("firebase-token-1"); + } + + private NotificationSchedule notificationSchedule(LocalDateTime notificationTime) { + return NotificationSchedule.builder() + .notificationTime(notificationTime) + .isSent(false) + .schedule(Schedule.builder() + .scheduleId(UUID.randomUUID()) + .scheduleName("Morning meeting") + .user(User.builder() + .id(1L) + .name("User") + .firebaseToken("firebase-token") + .role(Role.USER) + .build()) + .build()) + .build(); + } + + private UserSetting userSetting(boolean notificationsEnabled) { + return UserSetting.builder() + .userSettingId(UUID.randomUUID()) + .isNotificationsEnabled(notificationsEnabled) + .build(); + } + + private Schedule scheduleForListReminder(Long userId, String scheduleName) { + return Schedule.builder() + .scheduleId(UUID.randomUUID()) + .scheduleName(scheduleName) + .user(User.builder() + .id(userId) + .name("User " + userId) + .firebaseToken("firebase-token-" + userId) + .role(Role.USER) + .build()) + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java index 48fde1b..934caf8 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java @@ -1875,6 +1875,56 @@ void getAlarmWindowSchedules_includesStartedAt() { assertThat(schedules.get(0).getStartedAt()).isEqualTo(startedAt); } + @Test + @DisplayName("알람 윈도우는 시작 전 스케줄의 현재 기본 준비과정을 포함한다.") + void getAlarmWindowSchedules_includesCurrentDefaultPreparationsBeforeStart() { + User user = saveUser("alarm-window-default-prep@example.com"); + user.updateAdditionalInfo(5, null); + userRepository.save(user); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, null); + saveDefaultPreparations(user, "세면", "옷입기"); + + List schedules = scheduleService.getAlarmWindowSchedules( + user.getId(), + schedule.getScheduleTime().minusHours(1), + schedule.getScheduleTime().plusHours(1) + ); + + assertThat(schedules).hasSize(1); + assertThat(schedules.get(0).getPreparations()) + .extracting(PreparationDto::getPreparationName) + .containsExactlyInAnyOrder("세면", "옷입기"); + } + + @Test + @DisplayName("알람 윈도우는 시작된 스케줄의 고정된 준비과정 스냅샷을 포함한다.") + void getAlarmWindowSchedules_includesFrozenPreparationSnapshotsAfterStart() { + User user = saveUser("alarm-window-snapshot-prep@example.com"); + user.updateAdditionalInfo(5, null); + userRepository.save(user); + Place place = savePlace(); + Schedule schedule = saveSchedule(user, place, DoneStatus.NOT_ENDED, null); + saveDefaultPreparations(user, "세면", "옷입기"); + scheduleService.startSchedule(user.getId(), schedule.getScheduleId()); + + List updatedDefaults = List.of( + new PreparationDto(UUID.randomUUID(), "운동하기", 5, null) + ); + preparationUserService.updatePreparationUsers(user.getId(), updatedDefaults); + + List schedules = scheduleService.getAlarmWindowSchedules( + user.getId(), + schedule.getScheduleTime().minusHours(1), + schedule.getScheduleTime().plusHours(1) + ); + + assertThat(schedules).hasSize(1); + assertThat(schedules.get(0).getPreparations()) + .extracting(PreparationDto::getPreparationName) + .containsExactlyInAnyOrder("세면", "옷입기"); + } + private User saveUser(String email) { User user = User.builder() .email(email) diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java index bee95a9..5c9c943 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java @@ -45,6 +45,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.mock.web.MockHttpServletRequest; import java.time.Instant; import java.time.LocalDateTime; @@ -145,6 +146,42 @@ void signUp() throws Exception { assertThat(user.getUserSetting()).isNotNull(); } + @DisplayName("Authorization 헤더의 Bearer access token에서 userId를 추출한다") + @Test + void getUserIdFromBearerAccessToken() throws Exception { + UserSignUpDto userSignUpDto = getUserSignUpDto("token-user@example.com", "password1234", "token-user"); + UserInfoResponse signUpResponse = userAuthService.signUp((HttpServletRequest) httpServletRequest, httpServletResponse, userSignUpDto); + User savedUser = userRepository.findById(signUpResponse.getUserId()).orElseThrow(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + savedUser.getAccessToken()); + + Long userId = userAuthService.getUserIdFromToken(request); + + assertThat(userId).isEqualTo(savedUser.getId()); + assertThat(userAuthService.getAccessTokenFromRequest(request)).isEqualTo(savedUser.getAccessToken()); + } + + @DisplayName("refresh token은 Authorization-refresh와 legacy refresh-token 헤더를 모두 지원한다") + @Test + void getRefreshTokenSupportsBothCurrentAndLegacyHeaders() { + MockHttpServletRequest currentHeaderRequest = new MockHttpServletRequest(); + currentHeaderRequest.addHeader("Authorization-refresh", "Bearer current-refresh"); + MockHttpServletRequest legacyHeaderRequest = new MockHttpServletRequest(); + legacyHeaderRequest.addHeader("refresh-token", "legacy-refresh"); + + assertThat(userAuthService.getRefreshTokenFromRequest(currentHeaderRequest)).isEqualTo("current-refresh"); + assertThat(userAuthService.getRefreshTokenFromRequest(legacyHeaderRequest)).isEqualTo("legacy-refresh"); + } + + @DisplayName("토큰 헤더가 없으면 null로 반환해 컨트롤러가 인증 실패를 처리하게 한다") + @Test + void tokenExtractionReturnsNullForMissingHeaders() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + assertThat(userAuthService.getAccessTokenFromRequest(request)).isNull(); + assertThat(userAuthService.getRefreshTokenFromRequest(request)).isNull(); + } + @DisplayName("이미 존재하는 이메일로 회원가입을 시도하는 경우 예외가 발생한다.") @Test void signUpWithExistingEmail() throws Exception { diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java index b214d01..bf5f2ac 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserServiceTest.java @@ -1,10 +1,13 @@ package devkor.ontime_back.service; import devkor.ontime_back.dto.UpdateSpareTimeDto; +import devkor.ontime_back.dto.PreparationDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserOnboardingDto; import devkor.ontime_back.entity.DoneStatus; +import devkor.ontime_back.entity.Role; import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.PreparationUserRepository; import devkor.ontime_back.repository.UserRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -16,6 +19,8 @@ import java.util.NoSuchElementException; import java.util.Optional; +import java.util.List; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -37,11 +42,15 @@ class UserServiceTest { @Autowired private UserRepository userRepository; + @Autowired + private PreparationUserRepository preparationUserRepository; + @Autowired private PasswordEncoder passwordEncoder; @AfterEach void tearDown() { + preparationUserRepository.deleteAllInBatch(); userRepository.deleteAllInBatch(); } @@ -339,6 +348,93 @@ void updateSpareTimeWithWrongUserId(){ .hasMessage("존재하지 않는 유저 id입니다."); } + @DisplayName("온보딩은 유저 추가정보, 권한, 최초 준비과정을 함께 저장한다") + @Test + void onboardingStoresAdditionalInfoAuthorizesUserAndCreatesPreparations() throws Exception { + User addedUser = User.builder() + .email("user@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .role(Role.GUEST) + .build(); + userRepository.save(addedUser); + UUID wakeUp = UUID.randomUUID(); + UUID shower = UUID.randomUUID(); + UserOnboardingDto onboardingDto = UserOnboardingDto.builder() + .spareTime(15) + .note("지하철 확인") + .preparationList(List.of( + PreparationDto.builder() + .preparationId(wakeUp) + .preparationName("기상") + .preparationTime(5) + .nextPreparationId(shower) + .build(), + PreparationDto.builder() + .preparationId(shower) + .preparationName("샤워") + .preparationTime(10) + .build() + )) + .build(); + + userService.onboarding(addedUser.getId(), onboardingDto); + + User onboardedUser = userRepository.findById(addedUser.getId()).orElseThrow(); + assertThat(onboardedUser.getSpareTime()).isEqualTo(15); + assertThat(onboardedUser.getNote()).isEqualTo("지하철 확인"); + assertThat(onboardedUser.getRole()).isEqualTo(Role.USER); + assertThat(preparationUserRepository.findAll()) + .extracting("preparationName") + .containsExactlyInAnyOrder("기상", "샤워"); + } + + @DisplayName("존재하지 않는 유저는 온보딩할 수 없다") + @Test + void onboardingRejectsMissingUser() { + UserOnboardingDto onboardingDto = UserOnboardingDto.builder() + .spareTime(15) + .note("note") + .preparationList(List.of(PreparationDto.builder() + .preparationId(UUID.randomUUID()) + .preparationName("기상") + .preparationTime(5) + .build())) + .build(); + + assertThatThrownBy(() -> userService.onboarding(404L, onboardingDto)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 유저 id입니다."); + } + + @DisplayName("유저 정보 조회는 저장된 사용자 프로필을 반환한다") + @Test + void getUserInfoReturnsPersistedUserProfile() { + User addedUser = User.builder() + .email("user@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .spareTime(10) + .note("note") + .build(); + userRepository.save(addedUser); + + User userInfo = userService.getUserInfo(addedUser.getId()); + + assertThat(userInfo.getId()).isEqualTo(addedUser.getId()); + assertThat(userInfo) + .extracting("email", "name", "spareTime", "note") + .contains("user@example.com", "junbeom", 10, "note"); + } + + @DisplayName("존재하지 않는 유저 정보 조회는 예외를 반환한다") + @Test + void getUserInfoRejectsMissingUser() { + assertThatThrownBy(() -> userService.getUserInfo(404L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 유저 id입니다."); + } + float calculatePunctualityScore(int totalSchedules, int lateSchedules){ return (1 - ((float) lateSchedules / totalSchedules)) * 100; diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserSettingServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserSettingServiceTest.java new file mode 100644 index 0000000..acde1e5 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserSettingServiceTest.java @@ -0,0 +1,107 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.dto.UserSettingUpdateDto; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserSetting; +import devkor.ontime_back.repository.UserSettingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserSettingServiceTest { + + @Mock + private UserSettingRepository userSettingRepository; + + private UserSettingService userSettingService; + + @BeforeEach + void setUp() { + userSettingService = new UserSettingService(userSettingRepository); + } + + @Test + void updateSettingPersistsAllUserNotificationPreferences() { + UserSetting setting = userSetting(true, 50, true, true); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.of(setting)); + UserSettingUpdateDto request = updateDto(false, 25, false, false); + + userSettingService.updateSetting(1L, request); + + assertThat(setting.getIsNotificationsEnabled()).isFalse(); + assertThat(setting.getSoundVolume()).isEqualTo(25); + assertThat(setting.getIsPlayOnSpeaker()).isFalse(); + assertThat(setting.getIs24HourFormat()).isFalse(); + verify(userSettingRepository).save(setting); + } + + @Test + void resetSettingRestoresDefaultNotificationPreferences() { + UserSetting setting = userSetting(false, 5, false, false); + when(userSettingRepository.findByUserId(1L)).thenReturn(Optional.of(setting)); + + userSettingService.resetSetting(1L); + + assertThat(setting.getIsNotificationsEnabled()).isTrue(); + assertThat(setting.getSoundVolume()).isEqualTo(50); + assertThat(setting.getIsPlayOnSpeaker()).isTrue(); + assertThat(setting.getIs24HourFormat()).isTrue(); + verify(userSettingRepository).save(setting); + } + + @Test + void updateSettingFailsClearlyWhenUserHasNoSettings() { + when(userSettingRepository.findByUserId(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userSettingService.updateSetting(404L, updateDto(true, 50, true, true))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("UserSetting not found with given userId"); + } + + @Test + void resetSettingFailsClearlyWhenUserHasNoSettings() { + when(userSettingRepository.findByUserId(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userSettingService.resetSetting(404L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("UserSetting not found with given userId"); + } + + private UserSettingUpdateDto updateDto(Boolean notificationsEnabled, + Integer soundVolume, + Boolean playOnSpeaker, + Boolean twentyFourHourFormat) { + UserSettingUpdateDto dto = new UserSettingUpdateDto(); + ReflectionTestUtils.setField(dto, "isNotificationsEnabled", notificationsEnabled); + ReflectionTestUtils.setField(dto, "soundVolume", soundVolume); + ReflectionTestUtils.setField(dto, "isPlayOnSpeaker", playOnSpeaker); + ReflectionTestUtils.setField(dto, "is24HourFormat", twentyFourHourFormat); + return dto; + } + + private UserSetting userSetting(Boolean notificationsEnabled, + Integer soundVolume, + Boolean playOnSpeaker, + Boolean twentyFourHourFormat) { + return UserSetting.builder() + .userSettingId(UUID.randomUUID()) + .user(User.builder().id(1L).build()) + .isNotificationsEnabled(notificationsEnabled) + .soundVolume(soundVolume) + .isPlayOnSpeaker(playOnSpeaker) + .is24HourFormat(twentyFourHourFormat) + .build(); + } +} diff --git a/plans/test-validity-mutation-plan.md b/plans/test-validity-mutation-plan.md new file mode 100644 index 0000000..9e17664 --- /dev/null +++ b/plans/test-validity-mutation-plan.md @@ -0,0 +1,347 @@ +# Test Validity Mutation Plan + +## Goal +Prove that the new coverage-raising tests protect real behavior by intentionally breaking the behavior locally and confirming the relevant tests fail for the expected reason. + +This is a validation plan only. Every mutation is temporary and must be reverted immediately after the expected failure is observed. + +## Context +- The project is the Spring/Gradle backend under `ontime-back/`. +- The coverage gate is JaCoCo instruction coverage `>= 80%`. +- The current full verification command is: + +```bash +cd ontime-back +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew clean check +``` + +- The highest-risk behaviors to validate are: + - OAuth register access tokens must use the persisted `savedUser.getId()`, not the unsaved `newUser.getId()`. + - Alarm status validation must reject invalid windows, permission issues, failure reasons, and unsupported fallback combinations. + - Legacy reminder suppression must ignore stale native alarm reconciliation status. +- Current relevant files: + - `ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java` + - `ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java` + - `ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java` + - `ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java` + - `ontime-back/src/test/java/devkor/ontime_back/global/oauth/google/GoogleLoginServiceTest.java` + - `ontime-back/src/test/java/devkor/ontime_back/global/oauth/apple/AppleLoginServiceTest.java` + - `ontime-back/src/test/java/devkor/ontime_back/global/oauth/OAuthLoginFilterValidationTest.java` + - `ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java` + +## Decisions +- Use mutation testing manually, not a mutation-testing framework, because the target is a small set of suspicious behaviors and we need readable proof. +- Mutate production code only, not tests. If a test still passes after the mutation, the test is weak or not covering the intended behavior. +- Run the narrowest focused test command after each mutation. Run full `clean check` only after all mutations are reverted. +- Treat any failure outside the intended focused test as noise unless it also directly proves the mutated behavior. +- Do not keep mutation commits. Mutations are local probes only. + +## Steps + +### 1. Capture Baseline State +1. Confirm the current dirty state so mutation changes are not confused with real work: + +```bash +git status --short +``` + +2. Run the full verification once: + +```bash +cd ontime-back +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew clean check +``` + +3. Record baseline coverage from `build/reports/jacoco/test/jacocoTestReport.xml`. + +Expected baseline: +- `BUILD SUCCESSFUL` +- Instruction coverage stays above `80%`. + +### 2. Mutation Safety Protocol +For every mutation below: + +1. Apply only one mutation at a time. +2. Run the listed focused test command. +3. Confirm the expected test fails. +4. Capture the failure reason in notes. +5. Revert the mutation before starting the next mutation. +6. Rerun the same focused test to confirm it passes again. + +Recommended revert workflow: + +```bash +git diff -- +``` + +Then manually revert the small mutation hunk, or save the hunk and reverse-apply it. Do not revert unrelated changes. + +### 3. OAuth Register Token ID Mutations + +#### 3.1 Google Register Uses Unsaved User ID +Temporary mutation: +- In `GoogleLoginService.handleRegister`, replace: + +```java +jwtTokenProvider.createAccessToken(savedUser.getEmail(), savedUser.getId()) +``` + +with: + +```java +jwtTokenProvider.createAccessToken(newUser.getEmail(), newUser.getId()) +``` + +Run: + +```bash +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew test --tests devkor.ontime_back.global.oauth.google.GoogleLoginServiceTest +``` + +Expected failure: +- `handleRegisterCreatesGuestUserSettingsAndDefaultAlarmSettings` fails because the test expects `createAccessToken("new@example.com", 2L)`. +- If it passes, the test is not protecting the persisted-ID token contract. + +Revert the mutation and rerun the same command. It must pass. + +#### 3.2 Apple Register Uses Unsaved User ID +Temporary mutation: +- In `AppleLoginService.handleRegister`, replace: + +```java +jwtTokenProvider.createAccessToken(savedUser.getEmail(), savedUser.getId()) +``` + +with: + +```java +jwtTokenProvider.createAccessToken(newUser.getEmail(), newUser.getId()) +``` + +Run: + +```bash +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew test --tests devkor.ontime_back.global.oauth.apple.AppleLoginServiceTest +``` + +Expected failure: +- `handleRegisterCreatesGuestAppleUserAndDefaultAlarmSettings` fails because the test expects `createAccessToken("new@example.com", 2L)`. + +Revert the mutation and rerun the same command. It must pass. + +#### 3.3 Kakao Register Uses Unsaved User ID +Temporary mutation: +- In `KakaoLoginFilter.handleRegister`, replace: + +```java +jwtTokenProvider.createAccessToken(savedUser.getEmail(), savedUser.getId()) +``` + +with: + +```java +jwtTokenProvider.createAccessToken(newUser.getEmail(), newUser.getId()) +``` + +Run: + +```bash +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew test --tests devkor.ontime_back.global.oauth.OAuthLoginFilterValidationTest +``` + +Expected failure: +- `kakaoLoginFilterRegistersNewGuestUser` fails because the test expects `createAccessToken("kakao@example.com", 2L)`. + +Revert the mutation and rerun the same command. It must pass. + +### 4. Alarm Status Validation Mutations + +#### 4.1 Inverted Schedule Window Is Accepted +Temporary mutation: +- In `AlarmService.validateAlarmStatusReport`, remove or invert the branch that rejects: + +```java +requestDto.getScheduleWindowEnd().isBefore(requestDto.getScheduleWindowStart()) +``` + +Run: + +```bash +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew test --tests devkor.ontime_back.service.AlarmServiceTest +``` + +Expected failure: +- `reportAlarmStatusRejectsInvalidScheduleWindow` fails because invalid schedule windows no longer throw `GeneralException(INVALID_INPUT)`. + +Revert and rerun. The focused suite must pass. + +#### 4.2 Unknown Permission Issue Is Accepted +Temporary mutation: +- In `AlarmService.validateAlarmStatusReport`, remove or loosen: + +```java +requestDto.getPermissionIssue() != null && !PERMISSION_ISSUES.contains(requestDto.getPermissionIssue()) +``` + +Run the same `AlarmServiceTest` command. + +Expected failure: +- `reportAlarmStatusRejectsInvalidPermissionIssue` fails. + +Revert and rerun. + +#### 4.3 Unknown Failure Reason Is Accepted +Temporary mutation: +- In `AlarmService.validateAlarmStatusReport`, remove or loosen the failure reason check: + +```java +failure == null || failure.getReason() == null || !FAILURE_REASONS.contains(failure.getReason()) +``` + +Run the same `AlarmServiceTest` command. + +Expected failure: +- `reportAlarmStatusRejectsInvalidFailureReason` fails. + +Revert and rerun. + +#### 4.4 Unsupported Status With Local Notification Fallback Is Accepted +Temporary mutation: +- In `AlarmService.validateAlarmStatusReport`, remove or loosen: + +```java +"unsupported".equals(requestDto.getStatus()) + && (!NATIVE_NONE.equals(requestDto.getNativeAlarmProvider()) + || FALLBACK_LOCAL_NOTIFICATION.equals(requestDto.getFallbackProvider())) +``` + +Run the same `AlarmServiceTest` command. + +Expected failure: +- `reportAlarmStatusRejectsUnsupportedWithFallbackCoverage` fails. + +Revert and rerun. + +### 5. Stale Native Alarm Suppression Mutation +Temporary mutation: +- In `AlarmService.shouldSuppressLegacyReminder`, remove or loosen: + +```java +status.getReconciledAt() == null || status.getReconciledAt().isBefore(Instant.now().minus(Duration.ofHours(24))) +``` + +Run: + +```bash +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew test --tests devkor.ontime_back.service.AlarmServiceTest +``` + +Expected failure: +- `shouldSuppressLegacyReminderReturnsFalseForStaleAlarmStatus` fails because stale native status incorrectly suppresses the legacy reminder. + +Revert and rerun. The focused suite must pass. + +### 6. Alarm Settings DTO Validation Mutations + +#### 6.1 Unknown Fields Are Accepted +Temporary mutation: +- In `AlarmSettingsPatchDto.isKnownFieldsOnly`, return `true` unconditionally. + +Run: + +```bash +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew test --tests devkor.ontime_back.dto.AlarmSettingsPatchDtoTest +``` + +Expected failure: +- `rejectsUnknownAlarmSettingFields` fails. + +Revert and rerun. + +#### 6.2 Non-Integral Offset Is Accepted +Temporary mutation: +- In `AlarmSettingsPatchDto.getDefaultAlarmOffsetMinutesValue`, accept `Double` by truncating or rounding. + +Run the same DTO test command. + +Expected failure: +- `rejectsNonIntegralDefaultAlarmOffset` fails. + +Revert and rerun. + +### 7. Final Verification +After every mutation has been reverted: + +1. Confirm no mutation leftovers: + +```bash +git diff -- ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java \ + ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java \ + ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java \ + ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java \ + ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsPatchDto.java +``` + +Only intentional non-mutation work should remain. + +2. Run full verification: + +```bash +cd ontime-back +JAVA_HOME=/Users/ejunpark/.sdkman/candidates/java/current \ +PATH=/Users/ejunpark/.sdkman/candidates/java/current/bin:$PATH \ +./gradlew clean check +``` + +3. Confirm coverage: + +```bash +python3 - <<'PY' +import xml.etree.ElementTree as ET +root = ET.parse('build/reports/jacoco/test/jacocoTestReport.xml').getroot() +counter = next(c for c in root.findall('counter') if c.attrib['type'] == 'INSTRUCTION') +missed = int(counter.attrib['missed']) +covered = int(counter.attrib['covered']) +total = missed + covered +print(f'instruction coverage: {covered}/{total} = {covered / total:.2%}') +PY +``` + +Expected final result: +- `BUILD SUCCESSFUL` +- Instruction coverage remains `>= 80%`. +- No temporary mutation remains in production code. + +## Validation +- Each targeted mutation causes at least one targeted test failure. +- Each targeted test passes again after reverting the mutation. +- Full `./gradlew clean check` passes after all mutations are reverted. +- The final JaCoCo report remains above the `0.80` gate. + +## Done Criteria +- A short mutation log exists in the implementer's notes or PR description with: + - Mutation name. + - Test command. + - Expected failing test. + - Confirmation that the test passed again after revert. +- No mutation code remains. +- The test suite still passes with the 80% coverage gate. + +## Open Questions +- None. From c05b33131a41fc6b40a3b5af6c9b122d87c8601c Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Fri, 15 May 2026 02:44:30 +0900 Subject: [PATCH 7/7] Add coverage headroom for CI --- .../entity/UserAlarmSettingTest.java | 41 +++++++++++++ .../entity/UserProfileMutationTest.java | 38 ++++++++++++ .../service/ApiLogServiceTest.java | 56 ++++++++++++++++++ .../NotificationRecoveryServiceTest.java | 59 +++++++++++++++++++ .../StartedSchedulePreparationRepairTest.java | 33 +++++++++++ 5 files changed, 227 insertions(+) create mode 100644 ontime-back/src/test/java/devkor/ontime_back/entity/UserAlarmSettingTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/entity/UserProfileMutationTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/ApiLogServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/NotificationRecoveryServiceTest.java create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/StartedSchedulePreparationRepairTest.java diff --git a/ontime-back/src/test/java/devkor/ontime_back/entity/UserAlarmSettingTest.java b/ontime-back/src/test/java/devkor/ontime_back/entity/UserAlarmSettingTest.java new file mode 100644 index 0000000..b7524bc --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/entity/UserAlarmSettingTest.java @@ -0,0 +1,41 @@ +package devkor.ontime_back.entity; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserAlarmSettingTest { + + @Test + void defaultForEnablesNativeAlarmBridgeWithDefaultOffset() { + User user = User.builder().id(1L).email("user@example.com").build(); + + UserAlarmSetting setting = UserAlarmSetting.defaultFor(user); + + assertThat(setting.getUser()).isSameAs(user); + assertThat(setting.getAlarmsEnabled()).isTrue(); + assertThat(setting.getDefaultAlarmOffsetMinutes()).isEqualTo(UserAlarmSetting.DEFAULT_ALARM_OFFSET_MINUTES); + assertThat(setting.getUpdatedAt()).isNotNull(); + } + + @Test + void updateChangesOnlyProvidedAlarmPreferencesAndRefreshesTimestamp() { + UserAlarmSetting setting = UserAlarmSetting.builder() + .user(User.builder().id(1L).build()) + .alarmsEnabled(true) + .defaultAlarmOffsetMinutes(5) + .updatedAt(Instant.parse("2026-05-05T00:00:00Z")) + .build(); + + setting.update(false, null); + Instant afterFirstUpdate = setting.getUpdatedAt(); + setting.update(null, 30); + + assertThat(setting.getAlarmsEnabled()).isFalse(); + assertThat(setting.getDefaultAlarmOffsetMinutes()).isEqualTo(30); + assertThat(afterFirstUpdate).isAfter(Instant.parse("2026-05-05T00:00:00Z")); + assertThat(setting.getUpdatedAt()).isAfterOrEqualTo(afterFirstUpdate); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/entity/UserProfileMutationTest.java b/ontime-back/src/test/java/devkor/ontime_back/entity/UserProfileMutationTest.java new file mode 100644 index 0000000..3c85aae --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/entity/UserProfileMutationTest.java @@ -0,0 +1,38 @@ +package devkor.ontime_back.entity; + +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserProfileMutationTest { + + @Test + void userProfileMutatorsUpdatePersistedAuthenticationAndProfileState() { + User user = User.builder() + .email("user@example.com") + .password("raw-password") + .role(Role.GUEST) + .build(); + BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + user.passwordEncode(passwordEncoder); + user.updatePassword("new-password", passwordEncoder); + user.updateAdditionalInfo(15, "leave early"); + user.authorizeUser(); + user.updateNote("bring laptop"); + user.updateFirebaseToken("firebase-token"); + user.updateAccessToken("access-token"); + user.updateRefreshToken("refresh-token"); + user.updateSocialLoginToken("provider-refresh-token"); + + assertThat(passwordEncoder.matches("new-password", user.getPassword())).isTrue(); + assertThat(user.getSpareTime()).isEqualTo(15); + assertThat(user.getNote()).isEqualTo("bring laptop"); + assertThat(user.getRole()).isEqualTo(Role.USER); + assertThat(user.getFirebaseToken()).isEqualTo("firebase-token"); + assertThat(user.getAccessToken()).isEqualTo("access-token"); + assertThat(user.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(user.getSocialLoginToken()).isEqualTo("provider-refresh-token"); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/ApiLogServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/ApiLogServiceTest.java new file mode 100644 index 0000000..a3f6d83 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/ApiLogServiceTest.java @@ -0,0 +1,56 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.entity.ApiLog; +import devkor.ontime_back.repository.ApiLogRepository; +import org.junit.jupiter.api.BeforeEach; +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.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ApiLogServiceTest { + + @Mock + private ApiLogRepository apiLogRepository; + + private ApiLogService apiLogService; + + @BeforeEach + void setUp() { + apiLogService = new ApiLogService(apiLogRepository); + } + + @Test + void saveLogPersistsStructuredRequestAuditRecord() { + ApiLog apiLog = apiLog(); + + apiLogService.saveLog(apiLog); + + verify(apiLogRepository).save(apiLog); + } + + @Test + void saveLogDoesNotBreakRequestFlowWhenAuditPersistenceFails() { + ApiLog apiLog = apiLog(); + doThrow(new RuntimeException("database unavailable")).when(apiLogRepository).save(apiLog); + + apiLogService.saveLog(apiLog); + + verify(apiLogRepository).save(apiLog); + } + + private ApiLog apiLog() { + return ApiLog.builder() + .requestUrl("/schedules") + .requestMethod("GET") + .userId("1") + .clientIp("127.0.0.1") + .responseStatus(200) + .takenTime(25L) + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/NotificationRecoveryServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/NotificationRecoveryServiceTest.java new file mode 100644 index 0000000..e3d751b --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/NotificationRecoveryServiceTest.java @@ -0,0 +1,59 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.entity.NotificationSchedule; +import devkor.ontime_back.entity.Schedule; +import devkor.ontime_back.repository.NotificationScheduleRepository; +import org.junit.jupiter.api.BeforeEach; +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 java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationRecoveryServiceTest { + + @Mock + private NotificationScheduleRepository notificationScheduleRepository; + + @Mock + private NotificationService notificationService; + + private NotificationRecoveryService recoveryService; + + @BeforeEach + void setUp() { + recoveryService = new NotificationRecoveryService(notificationScheduleRepository, notificationService); + } + + @Test + void recoverNotificationSchedulesReschedulesPendingNotificationsOnStartup() { + NotificationSchedule first = notification("Morning meeting"); + NotificationSchedule second = notification("Evening meeting"); + when(notificationScheduleRepository.findAllWithScheduleAndUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(List.of(first, second)); + + recoveryService.recoverNotificationSchedules(); + + ArgumentCaptor nowCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(notificationScheduleRepository).findAllWithScheduleAndUser(nowCaptor.capture()); + assertThat(nowCaptor.getValue()).isBeforeOrEqualTo(LocalDateTime.now()); + verify(notificationService).scheduleReminder(first); + verify(notificationService).scheduleReminder(second); + } + + private NotificationSchedule notification(String name) { + return NotificationSchedule.builder() + .notificationTime(LocalDateTime.now().plusHours(1)) + .isSent(false) + .schedule(Schedule.builder().scheduleName(name).build()) + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/StartedSchedulePreparationRepairTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/StartedSchedulePreparationRepairTest.java new file mode 100644 index 0000000..d1b7480 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/StartedSchedulePreparationRepairTest.java @@ -0,0 +1,33 @@ +package devkor.ontime_back.service; + +import org.junit.jupiter.api.BeforeEach; +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.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StartedSchedulePreparationRepairTest { + + @Mock + private ScheduleService scheduleService; + + private StartedSchedulePreparationRepair repair; + + @BeforeEach + void setUp() { + repair = new StartedSchedulePreparationRepair(scheduleService); + } + + @Test + void repairStartedSchedulesWithoutSnapshotsDelegatesStartupRepair() { + when(scheduleService.repairStartedSchedulePreparationSnapshots()).thenReturn(3); + + repair.repairStartedSchedulesWithoutSnapshots(); + + verify(scheduleService).repairStartedSchedulePreparationSnapshots(); + } +}