From ceae09b113e6937c29953c0c510282cd96adb847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Sat, 25 Apr 2026 14:02:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=EC=88=9C=EA=B3=B5=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 타이머 실행, 시간 검증, 날짜별 누적 정책을 코드 기준으로 문서화 - 랭킹 캐시 갱신과 초기화 스케줄러의 원본 데이터 보존 기준을 정리 - 문서 claim 중 시간 불일치 처리와 랭킹 노출/초기화 정책을 단위 테스트로 고정 --- .../gg/agit/konect/domain/studytime/AGENTS.md | 260 ++++++++++++++++++ .../domain/studytime/StudyTimeApiTest.java | 4 + .../dto/StudyTimeRankingResponseTest.java | 82 ++++++ .../StudyTimeSchedulerServiceTest.java | 71 +++++ .../service/StudyTimerServiceTest.java | 119 ++++++++ 5 files changed, 536 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/studytime/AGENTS.md create mode 100644 src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md b/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md new file mode 100644 index 000000000..f4d71cfa2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/studytime/AGENTS.md @@ -0,0 +1,260 @@ +# 순공 시간 도메인 가이드 + +## 이 도메인은 무엇을 하는가 + +순공 시간 도메인은 사용자의 공부 타이머, 일별 공부 시간 누적, 랭킹 캐시, 랭킹 초기화 스케줄러를 관리하는 도메인이다. + +이 도메인에서 중요한 것은 단순히 초 단위 시간을 더하는 것이 아니라 아래 상태가 같은 기준으로 움직이는 것이다. + +- 실행 중인 타이머(`StudyTimer`) +- 날짜별 누적 시간(`StudyTimeDaily`) +- 개인/동아리/학번 랭킹 캐시(`StudyTimeRanking`) +- 랭킹 타입(`RankingType`) +- 공부 시간 누적 이벤트(`StudyTimeAccumulatedEvent`) +- 일간/월간 랭킹 초기화 스케줄러 + +순공 시간 관련 작업을 할 때는 항상 "이 변경이 타이머 단일 실행, 서버/클라이언트 시간 검증, 자정 분할 누적, 랭킹 캐시 갱신, 초기화 스케줄러까지 같이 맞는가"를 먼저 확인해야 한다. + +## 주요 상태 + +### `StudyTimer` + +- 실행 중인 타이머를 나타낸다. +- 사용자당 동시에 하나만 존재할 수 있다. +- DB의 `study_timer.user_id`는 unique 제약을 가진다. +- `startedAt`은 마지막으로 누적 반영된 시각이다. +- `createdAt`은 현재 타이머 세션의 최초 시작 시각이며, 서버 기준 총 경과 시간 계산에 쓰인다. +- sync가 성공하면 `startedAt`만 현재 시각으로 갱신된다. +- stop이 성공하면 타이머 row는 삭제된다. + +### `StudyTimeDaily` + +- 사용자별, 날짜별 누적 공부 시간을 저장한다. +- DB의 `(user_id, study_date)`는 unique 제약을 가진다. +- 자정을 넘긴 세션은 날짜별 구간으로 나뉘어 각 날짜 row에 더해진다. +- 현재 구현은 일간 테이블을 기준으로 일간, 월간, 전체 누적 시간을 모두 계산한다. +- 마이그레이션에는 `study_time_monthly`, `study_time_total` 테이블도 있지만 현재 서비스 로직은 이 테이블들을 직접 사용하지 않는다. + +### `StudyTimeRanking` + +- 랭킹 조회를 빠르게 하기 위한 캐시성 테이블이다. +- 기본 키는 `(ranking_type_id, university_id, target_id)`다. +- 랭킹 타입은 `CLUB`, `STUDENT_NUMBER`, `PERSONAL` 세 가지다. +- `dailySeconds`와 `monthlySeconds`를 함께 저장한다. +- 랭킹 캐시는 공부 시간이 누적된 뒤 발행되는 이벤트 리스너에서 갱신된다. + +## 기능이 실제로 어떻게 동작해야 하는가 + +### 타이머 시작 + +- 사용자당 실행 중인 타이머는 하나만 허용된다. +- 서비스는 먼저 `existsByUserId`로 실행 중 타이머를 확인한다. +- 동시에 두 요청이 들어와도 DB unique 제약과 flush 시점의 `DataIntegrityViolationException`을 통해 `ALREADY_RUNNING_STUDY_TIMER`로 정리한다. +- 타이머 시작 시점에는 공부 시간이 누적되지 않고, 이벤트도 발행되지 않는다. + +### 타이머 sync + +- sync는 실행 중인 타이머가 있어야 가능하다. +- 서버 기준 경과 시간은 `createdAt`부터 현재 시각까지다. +- 클라이언트가 보낸 `totalSeconds`와 서버 기준 경과 시간이 3초 이상 차이나면 실패한다. +- 시간 불일치가 발생하면 타이머를 삭제하고 `STUDY_TIMER_TIME_MISMATCH`를 던진다. +- 시간 불일치 예외는 트랜잭션 rollback 대상에서 제외되어야 한다. 그래야 잘못된 타이머 삭제가 유지된다. +- sync 성공 시 마지막 sync 이후 구간만 `StudyTimeDaily`에 누적한다. +- sync 성공 후 `StudyTimeAccumulatedEvent`를 발행하고 `startedAt`을 현재 시각으로 갱신한다. + +### 타이머 stop + +- stop은 실행 중인 타이머가 있어야 가능하다. +- 서버/클라이언트 시간 차이가 3초 이상이면 sync와 동일하게 타이머를 삭제하고 실패한다. +- stop 성공 시 마지막 sync 이후 구간을 누적하고 `StudyTimeAccumulatedEvent`를 발행한다. +- stop 성공 후 실행 중인 타이머 row를 삭제한다. +- 응답의 `sessionSeconds`는 현재 세션의 서버 기준 총 경과 시간이다. +- 응답의 `dailySeconds`, `monthlySeconds`, `totalSeconds`는 누적 반영 후 다시 조회한 값이다. + +### 자정 분할 누적 + +- 공부 시간이 자정을 넘기면 하나의 총합으로 저장하지 않고 날짜별로 분할한다. +- 구간 시작일이 종료일보다 이전이면 해당 날짜의 자정까지를 먼저 누적한다. +- 마지막 날짜는 실제 종료 시각까지 누적한다. +- 0초 이하 구간은 저장하지 않는다. +- 이 정책을 바꾸면 일간 조회뿐 아니라 월간/전체 합계와 랭킹 갱신 결과도 함께 바뀐다. + +### 요약 조회 + +- 일간 공부 시간은 오늘 날짜의 `StudyTimeDaily` row를 조회한다. +- 월간 공부 시간은 이번 달 1일부터 오늘까지의 `StudyTimeDaily.totalSeconds` 합계다. +- 전체 공부 시간은 사용자의 모든 `StudyTimeDaily.totalSeconds` 합계다. +- 실행 중인 타이머의 아직 sync되지 않은 시간은 요약 조회에 포함되지 않는다. + +### 랭킹 갱신 + +- 공부 시간이 실제로 누적된 뒤 `StudyTimeAccumulatedEvent`가 발행된다. +- 랭킹 갱신 리스너는 `AFTER_COMMIT`에 실행된다. +- 리스너는 별도 트랜잭션(`REQUIRES_NEW`)으로 랭킹 캐시를 갱신한다. +- 즉 공부 시간 누적 트랜잭션이 rollback되면 랭킹 갱신도 실행되지 않는다. +- 랭킹 갱신은 개인, 사용자가 속한 동아리, 사용자의 학번 연도 랭킹을 함께 갱신한다. + +### 개인 랭킹 + +- 개인 랭킹의 target id는 user id다. +- target name은 사용자 이름이다. +- 랭킹 목록 응답에서 개인 이름은 마스킹된다. +- 한 글자 이름은 그대로, 두 글자 이름은 첫 글자와 `*`, 세 글자 이상은 첫 글자와 마지막 글자만 노출한다. + +### 동아리 랭킹 + +- 동아리 랭킹은 사용자가 속한 각 동아리에 대해 갱신된다. +- target id는 club id다. +- target name은 동아리 이름이다. +- 동아리 공부 시간은 현재 동아리 회원들의 일간/월간 공부 시간 합계다. +- 사용자의 공부 시간이 누적되면 사용자가 속한 동아리들의 랭킹만 갱신된다. +- 동아리 회원 구성 변경 자체가 순공 시간 이벤트를 발행하지는 않는다. 회원 변경 후 랭킹 정합성을 요구한다면 별도 갱신 지점을 확인해야 한다. + +### 학번 랭킹 + +- 학번 랭킹은 사용자의 `studentNumberYear` 기준으로 묶는다. +- target name은 학번 연도 문자열이다. +- 랭킹 목록 응답에서는 학번 연도의 뒤 두 자리만 노출한다. +- 학번 랭킹은 target name으로 기존 row를 찾는다. +- 새 학번 랭킹 row가 필요하면 같은 랭킹 타입과 대학 안에서 max target id를 찾아 다음 id를 부여한다. +- 따라서 학번 랭킹은 target id가 학번 값이 아니라 내부 순번이라는 점을 놓치면 안 된다. + +### 랭킹 조회 + +- 랭킹은 로그인한 사용자의 대학 기준으로만 조회된다. +- `type`은 `CLUB`, `STUDENT_NUMBER`, `PERSONAL`만 허용한다. +- `type`은 대소문자를 구분하지 않고 조회하지만, 요청 검증의 허용 값은 세 타입으로 제한된다. +- `page` 기본값은 1, `limit` 기본값은 20, `sort` 기본값은 `MONTHLY`다. +- `limit`은 1 이상 100 이하만 허용한다. +- 일간 정렬은 `dailySeconds DESC`, `monthlySeconds DESC`, `targetId ASC` 순서다. +- 월간 정렬은 `monthlySeconds DESC`, `dailySeconds DESC`, `targetId ASC` 순서다. +- 페이지 응답의 rank는 페이지 시작 번호 기준으로 계산된다. +- 내 랭킹 조회는 동아리 랭킹 목록, 학번 랭킹, 개인 랭킹을 함께 반환한다. +- 내 동아리 랭킹은 존재하는 랭킹 row만 반환하고 rank 오름차순으로 정렬한다. +- 학번/개인 랭킹 row가 아직 없으면 해당 응답 필드는 `null`일 수 있다. + +### 랭킹 초기화 스케줄러 + +- 매일 00:00에 모든 랭킹의 `dailySeconds`를 0으로 초기화한다. +- 매월 1일 00:00에 모든 랭킹의 `monthlySeconds`를 0으로 초기화한다. +- 초기화 대상은 `study_time_ranking` 캐시 테이블이다. +- `StudyTimeDaily` 원본 누적 데이터는 초기화하지 않는다. +- 스케줄러는 예외를 잡아 로그로 남기며, 예외를 외부로 다시 던지지 않는다. + +## 절대 놓치면 안 되는 정책 + +- 실행 중 타이머는 사용자당 하나뿐이다. 서비스 선검사와 DB unique 제약을 함께 봐야 한다. +- `startedAt`과 `createdAt`은 역할이 다르다. `startedAt`은 마지막 누적 지점, `createdAt`은 세션 전체 경과 시간 기준이다. +- 서버/클라이언트 시간 차이가 3초 이상이면 타이머를 삭제하고 실패한다. +- 시간 불일치 실패에서 타이머 삭제가 rollback되면 안 된다. +- sync/stop은 마지막 sync 이후 구간만 누적한다. +- 자정을 넘긴 세션은 날짜별로 분할 누적해야 한다. +- 요약 조회는 저장된 `StudyTimeDaily`만 본다. 실행 중 타이머의 미반영 시간은 포함하지 않는다. +- 랭킹 갱신은 공부 시간 누적 트랜잭션 commit 이후 별도 트랜잭션에서 실행된다. +- 랭킹 캐시는 원본 누적 데이터가 아니다. 일간/월간 초기화는 랭킹 캐시에만 적용된다. +- 개인 이름과 학번 연도는 랭킹 목록 응답에서 노출 정책이 다르다. +- 학번 랭킹 target id는 학번 자체가 아니라 내부 순번이다. +- 동아리 회원 변경은 순공 시간 랭킹 갱신 이벤트를 자동으로 만들지 않는다. + +## 수정 시 함께 확인해야 하는 것 + +### 타이머 시작/종료 정책을 바꿀 때 + +- `study_timer.user_id` unique 제약 +- 중복 시작 시 `ALREADY_RUNNING_STUDY_TIMER` +- `createdAt` 기준 서버 경과 시간 계산 +- `startedAt` 기준 마지막 누적 구간 계산 +- 시간 불일치 시 타이머 삭제 유지 +- sync/stop 성공 시 `StudyTimeAccumulatedEvent` 발행 + +### 시간 누적 로직을 바꿀 때 + +- 자정 분할 누적 +- `StudyTimeDaily`의 `(user_id, study_date)` unique 제약 +- 일간/월간/전체 조회 쿼리 +- 0초 이하 구간 무시 +- 랭킹 갱신에 쓰이는 일간/월간 집계 기준 + +### 랭킹 정책을 바꿀 때 + +- `RankingType` seed 값 (`CLUB`, `STUDENT_NUMBER`, `PERSONAL`) +- 대학별 랭킹 격리 +- 일간/월간 정렬 tie-breaker +- 개인 이름 마스킹 +- 학번 연도 뒤 두 자리 표시 +- 학번 랭킹 target id 생성 규칙 +- 내 랭킹 조회에서 null 허용 필드 + +### 스케줄러를 바꿀 때 + +- 매일 00:00 일간 랭킹 초기화 +- 매월 1일 00:00 월간 랭킹 초기화 +- 원본 `StudyTimeDaily`를 초기화하지 않는 정책 +- `scheduler.studytime` 로거 설정 +- 예외를 잡아 스케줄러 실행 흐름을 유지하는 정책 + +### 동아리/유저 도메인과 함께 바꿀 때 + +- 동아리 회원 목록 기반 동아리 랭킹 합산 +- 회원 탈퇴 또는 동아리 탈퇴 후 랭킹 캐시 정합성 +- 사용자 이름 변경 시 개인 랭킹 target name 갱신 여부 +- 학번 변경 가능성이 생길 경우 학번 랭킹 target name과 target id 정합성 +- 사용자 대학 변경 가능성이 생길 경우 대학별 랭킹 격리 + +## 주요 클래스와 책임 + +### `StudyTimerService` + +- 타이머 시작, sync, stop을 담당한다. +- 시간 불일치 검증, 날짜별 누적, 이벤트 발행이 모여 있는 중심 서비스다. + +### `StudyTimeQueryService` + +- 일간, 월간, 전체 누적 공부 시간 조회를 담당한다. +- 현재 구현은 `StudyTimeDaily` 합계만 사용한다. + +### `StudyTimeRankingUpdateService` + +- 공부 시간 누적 이후 개인/동아리/학번 랭킹 캐시를 갱신한다. +- 동아리 회원 합산과 학번 연도 합산 정책을 바꿀 때 가장 먼저 봐야 한다. + +### `StudyTimeRankingService` + +- 랭킹 목록 조회와 내 랭킹 조회를 담당한다. +- 정렬 기준, rank 계산, 이름/학번 노출 정책이 응답으로 나가는 지점이다. + +### `StudyTimeRankingUpdateListener` + +- `StudyTimeAccumulatedEvent`를 commit 이후 받아 랭킹 갱신을 실행한다. +- 트랜잭션 전파 방식을 바꾸면 누적 성공과 랭킹 갱신의 결합도가 달라진다. + +### `StudyTimeSchedulerService` / `StudyTimeScheduler` + +- 일간/월간 랭킹 캐시 초기화를 담당한다. +- 원본 누적 데이터가 아니라 랭킹 캐시만 초기화한다는 점을 유지해야 한다. + +## 테스트 전략 + +현재 `StudyTimeApiTest`는 비어 있으므로 API 통합 흐름은 아직 충분히 고정되어 있지 않다. 다만 핵심 단위 정책은 `gg.agit.konect.unit.domain.studytime` 아래에서 먼저 고정한다. + +이미 고정한 회귀 테스트는 아래와 같다. + +- 중복 타이머 시작은 `ALREADY_RUNNING_STUDY_TIMER`로 실패한다. +- 시간 불일치 sync/stop은 타이머를 삭제하고 공부 시간/랭킹 후속 효과를 만들지 않는다. +- 개인 랭킹 이름과 학번 랭킹 이름은 노출 정책에 맞게 마스킹된다. +- 랭킹 초기화 스케줄러는 `StudyTimeRanking`의 일간/월간 캐시만 초기화한다. + +이 도메인의 정책을 바꾸거나 가이드 claim을 강화한다면 추가로 아래 회귀 테스트를 보강하는 것이 좋다. + +- 자정을 넘긴 stop은 두 날짜의 `StudyTimeDaily`로 분할 누적한다. +- sync 후 stop은 이미 sync된 구간을 다시 더하지 않는다. +- 공부 시간 누적 commit 이후 개인/동아리/학번 랭킹 캐시가 갱신된다. +- 랭킹 목록은 일간/월간 정렬 tie-breaker와 이름/학번 노출 정책을 지킨다. + +검증할 때는 최소한 아래 테스트를 실행한다. + +```bash +CI=true ./gradlew test --tests 'gg.agit.konect.unit.domain.studytime.*' +``` + +API 통합 테스트를 추가한 뒤에는 `gg.agit.konect.integration.domain.studytime.*` 필터도 별도로 실행 가능하게 유지해야 한다. diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java index e69de29bb..899f06113 100644 --- a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java @@ -0,0 +1,4 @@ +package gg.agit.konect.integration.domain.studytime; + +class StudyTimeApiTest { +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java new file mode 100644 index 000000000..6a2e8cb78 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java @@ -0,0 +1,82 @@ +package gg.agit.konect.unit.domain.studytime.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.dto.StudyTimeRankingResponse; +import gg.agit.konect.domain.studytime.model.RankingType; +import gg.agit.konect.domain.studytime.model.StudyTimeRanking; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class StudyTimeRankingResponseTest extends ServiceTestSupport { + + @Test + @DisplayName("개인 랭킹 이름은 첫 글자와 마지막 글자만 남기고 마스킹한다") + void fromMasksPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("김민수"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("김*수"); + } + + @Test + @DisplayName("두 글자 개인 이름은 첫 글자만 남기고 마스킹한다") + void fromMasksTwoLetterPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("길동"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("길*"); + } + + @Test + @DisplayName("학번 랭킹 이름은 입학연도 뒤 두 자리만 노출한다") + void fromDisplaysLastTwoDigitsForStudentNumberRanking() { + // given + StudyTimeRanking ranking = createRanking("2024"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 3, "STUDENT_NUMBER"); + + // then + assertThat(response.name()).isEqualTo("24"); + assertThat(response.rank()).isEqualTo(3); + } + + @Test + @DisplayName("동아리 랭킹 이름은 원문을 유지한다") + void fromKeepsClubRankingName() { + // given + StudyTimeRanking ranking = createRanking("BCSD Lab"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 2, "CLUB"); + + // then + assertThat(response.name()).isEqualTo("BCSD Lab"); + } + + private StudyTimeRanking createRanking(String targetName) { + RankingType rankingType = new TestRankingType(); + ReflectionTestUtils.setField(rankingType, "id", 1); + + University university = UniversityFixture.createWithId(1); + + return StudyTimeRanking.of(rankingType, university, 10, targetName, 100L, 1000L); + } + + private static class TestRankingType extends RankingType { + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java new file mode 100644 index 000000000..072d62b16 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java @@ -0,0 +1,71 @@ +package gg.agit.konect.unit.domain.studytime.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.model.RankingType; +import gg.agit.konect.domain.studytime.model.StudyTimeRanking; +import gg.agit.konect.domain.studytime.repository.StudyTimeRankingRepository; +import gg.agit.konect.domain.studytime.service.StudyTimeSchedulerService; +import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UniversityFixture; + +class StudyTimeSchedulerServiceTest extends ServiceTestSupport { + + @Mock + private StudyTimeRankingRepository studyTimeRankingRepository; + + @InjectMocks + private StudyTimeSchedulerService studyTimeSchedulerService; + + @Test + @DisplayName("일간 랭킹 초기화는 dailySeconds만 0으로 만들고 monthlySeconds는 유지한다") + void resetStudyTimeRankingDailyKeepsMonthlySeconds() { + // given + StudyTimeRanking ranking = createRanking(120L, 3600L); + given(studyTimeRankingRepository.findAll()).willReturn(List.of(ranking)); + + // when + studyTimeSchedulerService.resetStudyTimeRankingDaily(); + + // then + assertThat(ranking.getDailySeconds()).isZero(); + assertThat(ranking.getMonthlySeconds()).isEqualTo(3600L); + } + + @Test + @DisplayName("월간 랭킹 초기화는 monthlySeconds만 0으로 만들고 dailySeconds는 유지한다") + void resetStudyTimeRankingMonthlyKeepsDailySeconds() { + // given + StudyTimeRanking ranking = createRanking(120L, 3600L); + given(studyTimeRankingRepository.findAll()).willReturn(List.of(ranking)); + + // when + studyTimeSchedulerService.resetStudyTimeRankingMonthly(); + + // then + assertThat(ranking.getDailySeconds()).isEqualTo(120L); + assertThat(ranking.getMonthlySeconds()).isZero(); + } + + private StudyTimeRanking createRanking(Long dailySeconds, Long monthlySeconds) { + RankingType rankingType = new TestRankingType(); + ReflectionTestUtils.setField(rankingType, "id", 1); + + University university = UniversityFixture.createWithId(1); + + return StudyTimeRanking.of(rankingType, university, 1, "BCSD Lab", dailySeconds, monthlySeconds); + } + + private static class TestRankingType extends RankingType { + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java new file mode 100644 index 000000000..2a6a9a383 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java @@ -0,0 +1,119 @@ +package gg.agit.konect.unit.domain.studytime.service; + +import static gg.agit.konect.global.code.ApiResponseCode.ALREADY_RUNNING_STUDY_TIMER; +import static gg.agit.konect.global.code.ApiResponseCode.STUDY_TIMER_TIME_MISMATCH; +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.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; +import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.studytime.repository.StudyTimeDailyRepository; +import gg.agit.konect.domain.studytime.repository.StudyTimerRepository; +import gg.agit.konect.domain.studytime.service.StudyTimeQueryService; +import gg.agit.konect.domain.studytime.service.StudyTimerService; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.UserFixture; +import jakarta.persistence.EntityManager; + +class StudyTimerServiceTest extends ServiceTestSupport { + + @Mock + private StudyTimeQueryService studyTimeQueryService; + + @Mock + private StudyTimerRepository studyTimerRepository; + + @Mock + private StudyTimeDailyRepository studyTimeDailyRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private EntityManager entityManager; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private StudyTimerService studyTimerService; + + @Test + @DisplayName("이미 실행 중인 타이머가 있으면 새 타이머를 시작하지 않는다") + void startRejectsAlreadyRunningTimer() { + // given + given(studyTimerRepository.existsByUserId(1)).willReturn(true); + + // when & then + assertThatThrownBy(() -> studyTimerService.start(1)) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(ALREADY_RUNNING_STUDY_TIMER)); + + verifyNoInteractions(userRepository, entityManager, eventPublisher); + verify(studyTimerRepository, never()).save(any()); + } + + @Test + @DisplayName("sync 시간 불일치가 3초 이상이면 타이머를 삭제하고 공부 시간을 누적하지 않는다") + void syncDeletesTimerWhenElapsedTimeMismatches() { + // given + StudyTimer studyTimer = createTimerStartedOneHourAgo(); + given(studyTimerRepository.getByUserId(1)).willReturn(studyTimer); + + // when & then + assertThatThrownBy(() -> studyTimerService.sync(1, new StudyTimerSyncRequest(0L))) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(STUDY_TIMER_TIME_MISMATCH)); + + verify(studyTimerRepository).delete(studyTimer); + verifyNoInteractions(studyTimeDailyRepository, studyTimeQueryService); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("stop 시간 불일치가 3초 이상이면 타이머를 삭제하고 요약을 만들지 않는다") + void stopDeletesTimerWhenElapsedTimeMismatches() { + // given + StudyTimer studyTimer = createTimerStartedOneHourAgo(); + given(studyTimerRepository.getByUserId(1)).willReturn(studyTimer); + + // when & then + assertThatThrownBy(() -> studyTimerService.stop(1, new StudyTimerStopRequest(0L))) + .isInstanceOf(CustomException.class) + .satisfies(exception -> assertThat(((CustomException)exception).getErrorCode()) + .isEqualTo(STUDY_TIMER_TIME_MISMATCH)); + + verify(studyTimerRepository).delete(studyTimer); + verifyNoInteractions(studyTimeDailyRepository, studyTimeQueryService); + verify(eventPublisher, never()).publishEvent(any()); + } + + private StudyTimer createTimerStartedOneHourAgo() { + User user = UserFixture.createUserWithId(1, "2021136001"); + LocalDateTime startedAt = LocalDateTime.now().minusHours(1); + StudyTimer studyTimer = StudyTimer.of(user, startedAt); + ReflectionTestUtils.setField(studyTimer, "createdAt", startedAt); + ReflectionTestUtils.setField(studyTimer, "updatedAt", startedAt); + return studyTimer; + } +} From e86a0708192e1398879615ed2e966958f48a6f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Sat, 25 Apr 2026 14:26:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20PR=20=EB=A6=AC=EB=B7=B0=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 순공 시간 통합 테스트 placeholder를 제거해 불필요한 테스트 클래스를 남기지 않음 - 개인 랭킹 한 글자 이름 노출 분기를 테스트로 고정하고 불필요한 Mockito 상속을 제거 - StudyTimer auditing 필드 설정을 fixture로 분리해 타이머 테스트 재사용성을 높임 --- .../domain/studytime/StudyTimeApiTest.java | 4 ---- .../support/fixture/StudyTimerFixture.java | 18 ++++++++++++++++++ .../dto/StudyTimeRankingResponseTest.java | 17 +++++++++++++++-- .../service/StudyTimerServiceTest.java | 7 ++----- 4 files changed, 35 insertions(+), 11 deletions(-) delete mode 100644 src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java create mode 100644 src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java diff --git a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java b/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java deleted file mode 100644 index 899f06113..000000000 --- a/src/test/java/gg/agit/konect/integration/domain/studytime/StudyTimeApiTest.java +++ /dev/null @@ -1,4 +0,0 @@ -package gg.agit.konect.integration.domain.studytime; - -class StudyTimeApiTest { -} diff --git a/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java b/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java new file mode 100644 index 000000000..4e35bcacc --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/StudyTimerFixture.java @@ -0,0 +1,18 @@ +package gg.agit.konect.support.fixture; + +import java.time.LocalDateTime; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.model.StudyTimer; +import gg.agit.konect.domain.user.model.User; + +public class StudyTimerFixture { + + public static StudyTimer createStartedTimer(User user, LocalDateTime startedAt) { + StudyTimer studyTimer = StudyTimer.of(user, startedAt); + ReflectionTestUtils.setField(studyTimer, "createdAt", startedAt); + ReflectionTestUtils.setField(studyTimer, "updatedAt", startedAt); + return studyTimer; + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java index 6a2e8cb78..82db63ccd 100644 --- a/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java @@ -10,10 +10,23 @@ import gg.agit.konect.domain.studytime.model.RankingType; import gg.agit.konect.domain.studytime.model.StudyTimeRanking; import gg.agit.konect.domain.university.model.University; -import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.UniversityFixture; -class StudyTimeRankingResponseTest extends ServiceTestSupport { +class StudyTimeRankingResponseTest { + + @Test + @DisplayName("한 글자 개인 이름은 마스킹하지 않는다") + void fromKeepsSingleLetterPersonalRankingName() { + // given + StudyTimeRanking ranking = createRanking("김"); + + // when + StudyTimeRankingResponse response = StudyTimeRankingResponse.from(ranking, 1, "PERSONAL"); + + // then + assertThat(response.name()).isEqualTo("김"); + assertThat(response.rank()).isEqualTo(1); + } @Test @DisplayName("개인 랭킹 이름은 첫 글자와 마지막 글자만 남기고 마스킹한다") diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java index 2a6a9a383..10dfa11c4 100644 --- a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimerServiceTest.java @@ -17,7 +17,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.test.util.ReflectionTestUtils; import gg.agit.konect.domain.studytime.dto.StudyTimerStopRequest; import gg.agit.konect.domain.studytime.dto.StudyTimerSyncRequest; @@ -30,6 +29,7 @@ import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.StudyTimerFixture; import gg.agit.konect.support.fixture.UserFixture; import jakarta.persistence.EntityManager; @@ -111,9 +111,6 @@ void stopDeletesTimerWhenElapsedTimeMismatches() { private StudyTimer createTimerStartedOneHourAgo() { User user = UserFixture.createUserWithId(1, "2021136001"); LocalDateTime startedAt = LocalDateTime.now().minusHours(1); - StudyTimer studyTimer = StudyTimer.of(user, startedAt); - ReflectionTestUtils.setField(studyTimer, "createdAt", startedAt); - ReflectionTestUtils.setField(studyTimer, "updatedAt", startedAt); - return studyTimer; + return StudyTimerFixture.createStartedTimer(user, startedAt); } } From 048d5b8728bebba95651e225b1e1961dedbd4fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Sat, 25 Apr 2026 14:56:05 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EC=88=9C=EA=B3=B5=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?fixture=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingType 생성 보일러플레이트를 fixture로 분리해 테스트 본문이 검증 의도에 집중하도록 정리 - DTO와 스케줄러 테스트에 반복되던 하위 클래스와 ReflectionTestUtils 사용을 제거 --- .../support/fixture/RankingTypeFixture.java | 17 +++++++++++++++++ .../dto/StudyTimeRankingResponseTest.java | 9 ++------- .../service/StudyTimeSchedulerServiceTest.java | 9 ++------- 3 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java diff --git a/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java b/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java new file mode 100644 index 000000000..e120a3300 --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/RankingTypeFixture.java @@ -0,0 +1,17 @@ +package gg.agit.konect.support.fixture; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.studytime.model.RankingType; + +public class RankingTypeFixture { + + public static RankingType createWithId(Integer id) { + RankingType rankingType = new TestRankingType(); + ReflectionTestUtils.setField(rankingType, "id", id); + return rankingType; + } + + private static class TestRankingType extends RankingType { + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java index 82db63ccd..6753caf32 100644 --- a/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/dto/StudyTimeRankingResponseTest.java @@ -4,12 +4,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; import gg.agit.konect.domain.studytime.dto.StudyTimeRankingResponse; import gg.agit.konect.domain.studytime.model.RankingType; import gg.agit.konect.domain.studytime.model.StudyTimeRanking; import gg.agit.konect.domain.university.model.University; +import gg.agit.konect.support.fixture.RankingTypeFixture; import gg.agit.konect.support.fixture.UniversityFixture; class StudyTimeRankingResponseTest { @@ -82,14 +82,9 @@ void fromKeepsClubRankingName() { } private StudyTimeRanking createRanking(String targetName) { - RankingType rankingType = new TestRankingType(); - ReflectionTestUtils.setField(rankingType, "id", 1); - + RankingType rankingType = RankingTypeFixture.createWithId(1); University university = UniversityFixture.createWithId(1); return StudyTimeRanking.of(rankingType, university, 10, targetName, 100L, 1000L); } - - private static class TestRankingType extends RankingType { - } } diff --git a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java index 072d62b16..63a7c9996 100644 --- a/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/studytime/service/StudyTimeSchedulerServiceTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.springframework.test.util.ReflectionTestUtils; import gg.agit.konect.domain.studytime.model.RankingType; import gg.agit.konect.domain.studytime.model.StudyTimeRanking; @@ -17,6 +16,7 @@ import gg.agit.konect.domain.studytime.service.StudyTimeSchedulerService; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.RankingTypeFixture; import gg.agit.konect.support.fixture.UniversityFixture; class StudyTimeSchedulerServiceTest extends ServiceTestSupport { @@ -58,14 +58,9 @@ void resetStudyTimeRankingMonthlyKeepsDailySeconds() { } private StudyTimeRanking createRanking(Long dailySeconds, Long monthlySeconds) { - RankingType rankingType = new TestRankingType(); - ReflectionTestUtils.setField(rankingType, "id", 1); - + RankingType rankingType = RankingTypeFixture.createWithId(1); University university = UniversityFixture.createWithId(1); return StudyTimeRanking.of(rankingType, university, 1, "BCSD Lab", dailySeconds, monthlySeconds); } - - private static class TestRankingType extends RankingType { - } }