getTimeline(
+ @PathVariable Long childId,
+ @Parameter(description = "조회 기간(개월). 기본 12, 최대 36") @RequestParam(required = false) Integer months) {
+ return ResponseEntity.ok(timelineService.timeline(childId, months));
+ }
+
@GetMapping("/{childId}")
@LogExecutionTime
@Operation(summary = "아이 상세 조회")
diff --git a/src/main/java/com/carecode/domain/health/dto/response/ChildTimelineResponse.java b/src/main/java/com/carecode/domain/health/dto/response/ChildTimelineResponse.java
new file mode 100644
index 0000000..be222de
--- /dev/null
+++ b/src/main/java/com/carecode/domain/health/dto/response/ChildTimelineResponse.java
@@ -0,0 +1,55 @@
+package com.carecode.domain.health.dto.response;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 아이 한 명의 할 일을 시간 축 하나에 모은 것.
+ *
+ * 접종은 접종 화면, 검진은 기록 화면, 지원금 마감은 정책 화면에 흩어져 있었다. 부모가 "다음에 뭘
+ * 해야 하나" 를 알려면 화면 세 곳을 돌아야 했고, 그래서 놓쳤다. 데이터는 이미 다 있으므로 합쳐서 준다.
+ */
+@Getter
+@Builder
+public class ChildTimelineResponse {
+
+ private final Long childId;
+ private final String childName;
+ private final LocalDate birthDate;
+
+ /** 조회 구간. 기준일(오늘)부터 몇 개월까지 본 결과인지. */
+ private final LocalDate from;
+ private final LocalDate to;
+
+ /** 지난 항목 중 아직 하지 않은 것. 구간 앞이라도 놓친 건 보여 줘야 한다. */
+ private final int overdueCount;
+ private final int upcomingCount;
+
+ private final List items;
+
+ @Getter
+ @Builder
+ public static class TimelineItem {
+
+ /** 기준 날짜. 구간이 있는 항목(검진)은 시작일을 쓴다. */
+ private final LocalDate date;
+
+ /** VACCINATION, CHECKUP, POLICY_DEADLINE, NEW_TERM */
+ private final String type;
+
+ /** OVERDUE(지났는데 안 함), UPCOMING(앞으로), DONE(완료), INFO(참고) */
+ private final String status;
+
+ private final String title;
+ private final String description;
+
+ /** 해당 도메인 상세로 이어 주기 위한 식별자. 없으면 null. */
+ private final String referenceId;
+
+ /** 그 날짜의 아이 월령. 화면에서 "12개월 무렵" 처럼 쓴다. */
+ private final Integer ageMonths;
+ }
+}
diff --git a/src/main/java/com/carecode/domain/health/service/ChildService.java b/src/main/java/com/carecode/domain/health/service/ChildService.java
index 8563a67..f8a94c2 100644
--- a/src/main/java/com/carecode/domain/health/service/ChildService.java
+++ b/src/main/java/com/carecode/domain/health/service/ChildService.java
@@ -84,6 +84,14 @@ public void deleteChild(Long childId) {
childRepository.delete(requireOwnedChild(childId));
}
+ /**
+ * 소유권을 확인한 아이 엔티티. 다른 서비스(타임라인 등)가 같은 검증을 다시 구현하지 않도록 공개한다.
+ * 검증을 복사하면 한쪽만 고쳐져 남의 아이가 열리는 일이 생긴다.
+ */
+ public Child requireOwned(Long childId) {
+ return requireOwnedChild(childId);
+ }
+
/** 아이 조회 + 소유권 검증. 남의 아이 정보에 접근하지 못하도록 보호자 본인 것만 반환한다. */
private Child requireOwnedChild(Long childId) {
User parent = currentUserFacade.requireCurrentUser();
diff --git a/src/main/java/com/carecode/domain/health/timeline/CheckupStandard.java b/src/main/java/com/carecode/domain/health/timeline/CheckupStandard.java
new file mode 100644
index 0000000..3d302d5
--- /dev/null
+++ b/src/main/java/com/carecode/domain/health/timeline/CheckupStandard.java
@@ -0,0 +1,59 @@
+package com.carecode.domain.health.timeline;
+
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 국가 영유아 건강검진 시기(월령 구간).
+ *
+ * 이 앱에는 표준 검진 시기가 없었다. {@code getCheckupSchedule} 은 이름과 달리 이미 기록된 검진을
+ * 나열할 뿐이어서, 아직 받지 않은 검진은 화면에 나타나지 않았다 — 놓쳐도 아무도 알려주지 않는다.
+ * 예방접종 일정({@code VaccineType})과 같은 방식으로 시기를 코드에 담는다.
+ *
+ *
출처: 국민건강보험 영유아 건강검진(생후 14일~71개월, 8차). 구강검진은 별도 회차라 포함하지 않는다.
+ * 실제 대상 기간은 제도 개편으로 바뀔 수 있으므로 화면에는 "권장 시기" 로 표시한다.
+ */
+@Getter
+public enum CheckupStandard {
+
+ ROUND_1(1, 14, 35, "1차 건강검진", "생후 14~35일"),
+ ROUND_2(2, 4 * 30, 6 * 30 + 30, "2차 건강검진", "4~6개월"),
+ ROUND_3(3, 9 * 30, 12 * 30 + 30, "3차 건강검진", "9~12개월"),
+ ROUND_4(4, 18 * 30, 24 * 30 + 30, "4차 건강검진", "18~24개월"),
+ ROUND_5(5, 30 * 30, 36 * 30 + 30, "5차 건강검진", "30~36개월"),
+ ROUND_6(6, 42 * 30, 48 * 30 + 30, "6차 건강검진", "42~48개월"),
+ ROUND_7(7, 54 * 30, 60 * 30 + 30, "7차 건강검진", "54~60개월"),
+ ROUND_8(8, 66 * 30, 71 * 30 + 30, "8차 건강검진", "66~71개월");
+
+ private final int round;
+
+ /** 생후 일수 기준 시작·종료. 월령 구간을 일수로 환산해 둔다(월 길이 차이는 안내 문구로 흡수). */
+ private final int startDays;
+ private final int endDays;
+
+ private final String title;
+ private final String periodLabel;
+
+ CheckupStandard(int round, int startDays, int endDays, String title, String periodLabel) {
+ this.round = round;
+ this.startDays = startDays;
+ this.endDays = endDays;
+ this.title = title;
+ this.periodLabel = periodLabel;
+ }
+
+ public LocalDate windowStart(LocalDate birthDate) {
+ return birthDate.plusDays(startDays);
+ }
+
+ public LocalDate windowEnd(LocalDate birthDate) {
+ return birthDate.plusDays(endDays);
+ }
+
+ public static List all() {
+ return Arrays.asList(values());
+ }
+}
diff --git a/src/main/java/com/carecode/domain/health/timeline/ChildTimelineService.java b/src/main/java/com/carecode/domain/health/timeline/ChildTimelineService.java
new file mode 100644
index 0000000..b06a572
--- /dev/null
+++ b/src/main/java/com/carecode/domain/health/timeline/ChildTimelineService.java
@@ -0,0 +1,229 @@
+package com.carecode.domain.health.timeline;
+
+import com.carecode.domain.health.dto.response.ChildTimelineResponse;
+import com.carecode.domain.health.entity.HealthRecord;
+import com.carecode.domain.health.entity.VaccinationSchedule;
+import com.carecode.domain.health.repository.HealthRecordRepository;
+import com.carecode.domain.health.repository.VaccinationScheduleRepository;
+import com.carecode.domain.health.service.ChildService;
+import com.carecode.domain.policy.entity.Policy;
+import com.carecode.domain.policy.repository.PolicyRepository;
+import com.carecode.domain.user.entity.Child;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.Month;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 아이 한 명의 할 일을 시간 축 하나로 합친다.
+ *
+ * 접종·검진·지원금 마감이 화면 세 곳에 흩어져 있어서, 부모가 "다음에 뭘 해야 하나" 를 알려면
+ * 세 곳을 돌아야 했다. 새로 수집하는 데이터는 없다 — 이미 있는 것을 한 축에 놓을 뿐이다.
+ *
+ *
담는 것과 담지 않는 것:
+ *
+ * - 접종 — 자동 생성된 표준 일정. 완료된 건 제외하고 놓친 건 구간 앞이라도 포함한다.
+ * - 검진 — 국가 영유아 건강검진 권장 시기({@link CheckupStandard}). 완료 여부는 그 구간에 남은
+ * 검진 기록으로 판단한다.
+ * - 지원금 마감 — 아이 나이에 맞는 정책 중 신청 마감이 구간 안에 있는 것.
+ * - 3월 신학기 — 시설 입소·반 승급이 몰리는 시점. 신청 일정은 시설마다 달라 날짜를 만들지 않고
+ * 참고(INFO) 항목으로만 둔다.
+ *
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ChildTimelineService {
+
+ private static final int DEFAULT_MONTHS = 12;
+ private static final int MAX_MONTHS = 36;
+
+ /** 어린이집·유치원 입소와 반 승급이 몰리는 달. */
+ private static final Month NEW_TERM_MONTH = Month.MARCH;
+
+ /** 신학기 안내를 보여 줄 나이 상한(만). 초등 입학 이후는 이 앱의 범위가 아니다. */
+ private static final int NEW_TERM_MAX_AGE_YEARS = 7;
+
+ private final ChildService childService;
+ private final VaccinationScheduleRepository vaccinationScheduleRepository;
+ private final HealthRecordRepository healthRecordRepository;
+ private final PolicyRepository policyRepository;
+
+ public ChildTimelineResponse timeline(Long childId, Integer months) {
+ // 소유권 검증은 여기서 한 번만 한다. 남의 아이면 404 (존재 여부를 숨긴다).
+ Child child = childService.requireOwned(childId);
+
+ LocalDate today = LocalDate.now();
+ LocalDate to = today.plusMonths(normalizeMonths(months));
+ LocalDate birthDate = child.getBirthDate();
+
+ List items = new ArrayList<>();
+ items.addAll(vaccinationItems(childId, today, to, birthDate));
+ if (birthDate != null) {
+ items.addAll(checkupItems(childId, today, to, birthDate));
+ items.addAll(newTermItems(today, to, birthDate));
+ }
+ items.addAll(policyDeadlineItems(child, today, to, birthDate));
+
+ items.sort(Comparator.comparing(ChildTimelineResponse.TimelineItem::getDate));
+
+ return ChildTimelineResponse.builder()
+ .childId(childId)
+ .childName(child.getName())
+ .birthDate(birthDate)
+ .from(today)
+ .to(to)
+ .overdueCount(count(items, "OVERDUE"))
+ .upcomingCount(count(items, "UPCOMING"))
+ .items(items)
+ .build();
+ }
+
+ private int normalizeMonths(Integer months) {
+ if (months == null || months <= 0) {
+ return DEFAULT_MONTHS;
+ }
+ return Math.min(months, MAX_MONTHS);
+ }
+
+ /** 접종. 놓친 것은 구간 시작 전이라도 담는다 — 지난 일이라고 감추면 맞을 기회를 잃는다. */
+ private List vaccinationItems(
+ Long childId, LocalDate today, LocalDate to, LocalDate birthDate) {
+
+ List items = new ArrayList<>();
+ for (VaccinationSchedule schedule : vaccinationScheduleRepository.findByChildIdOrderByDueDateAsc(childId)) {
+ if (schedule.getStatus() == VaccinationSchedule.VaccinationStatus.COMPLETED
+ || schedule.getStatus() == VaccinationSchedule.VaccinationStatus.SKIPPED) {
+ continue;
+ }
+ LocalDate dueDate = schedule.getDueDate();
+ if (dueDate == null || dueDate.isAfter(to)) {
+ continue;
+ }
+ boolean overdue = dueDate.isBefore(today);
+ items.add(ChildTimelineResponse.TimelineItem.builder()
+ .date(dueDate)
+ .type("VACCINATION")
+ .status(overdue ? "OVERDUE" : "UPCOMING")
+ .title(schedule.getVaccineType().getDisplayName() + " " + schedule.getDoseNumber() + "차")
+ .description(overdue ? "권장 시기가 지났습니다. 병원에서 접종 가능 여부를 확인하세요." : "권장 접종 시기입니다.")
+ .referenceId(String.valueOf(schedule.getId()))
+ .ageMonths(ageMonths(birthDate, dueDate))
+ .build());
+ }
+ return items;
+ }
+
+ /**
+ * 검진. 표준 시기와 이미 남은 검진 기록을 맞춰 본다.
+ *
+ * 기록이 그 구간 안에 있으면 받은 것으로 본다. 검진 기록에 회차가 없으므로 날짜로 판단하는 것이
+ * 지금 데이터로 할 수 있는 최선이다 — 회차를 받게 되면 여기만 고치면 된다.
+ */
+ private List checkupItems(
+ Long childId, LocalDate today, LocalDate to, LocalDate birthDate) {
+
+ List checkupDates = healthRecordRepository
+ .findByChildIdAndRecordType(childId, HealthRecord.RecordType.CHECKUP).stream()
+ .map(HealthRecord::getRecordDate)
+ .filter(date -> date != null)
+ .toList();
+
+ List items = new ArrayList<>();
+ for (CheckupStandard standard : CheckupStandard.all()) {
+ LocalDate start = standard.windowStart(birthDate);
+ LocalDate end = standard.windowEnd(birthDate);
+ if (start.isAfter(to)) {
+ continue;
+ }
+
+ boolean done = checkupDates.stream()
+ .anyMatch(date -> !date.isBefore(start) && !date.isAfter(end));
+ boolean past = end.isBefore(today);
+ if (done && past) {
+ // 이미 받았고 시기도 지난 항목은 앞으로 할 일이 아니다.
+ continue;
+ }
+
+ items.add(ChildTimelineResponse.TimelineItem.builder()
+ .date(start.isBefore(today) && !past ? today : start)
+ .type("CHECKUP")
+ .status(done ? "DONE" : past ? "OVERDUE" : "UPCOMING")
+ .title(standard.getTitle())
+ .description("권장 시기 " + standard.getPeriodLabel()
+ + " (" + start + " ~ " + end + ")"
+ + (done ? " · 이 시기에 받은 기록이 있습니다" : ""))
+ .referenceId("checkup-" + standard.getRound())
+ .ageMonths(ageMonths(birthDate, start))
+ .build());
+ }
+ return items;
+ }
+
+ /** 지원금 신청 마감. 아이 나이에 해당하는 정책만. */
+ private List policyDeadlineItems(
+ Child child, LocalDate today, LocalDate to, LocalDate birthDate) {
+
+ // Child.getAge() 는 연 나이다. 정책 추천·검색이 같은 값을 쓰므로 여기서 다른 기준을 쓰면
+ // 같은 아이가 화면마다 다른 정책을 보게 된다.
+ int ageYears = child.getAge();
+
+ List items = new ArrayList<>();
+ for (Policy policy : policyRepository.findDeadlinesForChildAge(today, to, ageYears)) {
+ items.add(ChildTimelineResponse.TimelineItem.builder()
+ .date(policy.getApplicationEndDate())
+ .type("POLICY_DEADLINE")
+ .status("UPCOMING")
+ .title(policy.getTitle() + " 신청 마감")
+ .description(policy.getApplicationEndDate() + " 까지 신청해야 합니다."
+ + (policy.getVerifiedAt() == null ? " (금액·조건은 추정치이며 기관 확인이 필요합니다)" : ""))
+ .referenceId(String.valueOf(policy.getId()))
+ .ageMonths(ageMonths(birthDate, policy.getApplicationEndDate()))
+ .build());
+ }
+ return items;
+ }
+
+ /**
+ * 3월 신학기. 날짜를 만들어 내지 않는다 — 시설마다 신청 일정이 달라서, 구체적인 날짜를 적으면
+ * 그걸 믿고 놓치는 사람이 생긴다. "이 시점을 기억하라" 는 참고 항목으로만 둔다.
+ */
+ private List newTermItems(LocalDate today, LocalDate to, LocalDate birthDate) {
+ List items = new ArrayList<>();
+ LocalDate term = LocalDate.of(today.getYear(), NEW_TERM_MONTH, 1);
+ while (!term.isAfter(to)) {
+ if (!term.isBefore(today) && ChronoUnit.YEARS.between(birthDate, term) < NEW_TERM_MAX_AGE_YEARS) {
+ items.add(ChildTimelineResponse.TimelineItem.builder()
+ .date(term)
+ .type("NEW_TERM")
+ .status("INFO")
+ .title("3월 신학기")
+ .description("어린이집·유치원 입소와 반 승급이 몰리는 시점입니다. "
+ + "신청 일정은 시설마다 다르므로 관심 시설에 직접 확인하세요.")
+ .ageMonths(ageMonths(birthDate, term))
+ .build());
+ }
+ term = term.plusYears(1);
+ }
+ return items;
+ }
+
+ private static Integer ageMonths(LocalDate birthDate, LocalDate date) {
+ if (birthDate == null || date == null) {
+ return null;
+ }
+ return (int) ChronoUnit.MONTHS.between(birthDate, date);
+ }
+
+ private static int count(List items, String status) {
+ return (int) items.stream().filter(item -> status.equals(item.getStatus())).count();
+ }
+}
diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java
index 1161850..c038eee 100644
--- a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java
+++ b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java
@@ -52,6 +52,16 @@ public interface PolicyRepository extends JpaRepository {
"p.applicationStartDate <= :today AND p.applicationEndDate >= :today")
List findActivePoliciesByDate(@Param("today") LocalDate today);
+ /** 신청 마감이 구간 안이고 아이 나이에 해당하는 정책. 타임라인이 쓴다. */
+ @Query("SELECT p FROM Policy p WHERE p.isActive = true "
+ + "AND p.applicationEndDate IS NOT NULL AND p.applicationEndDate BETWEEN :from AND :to "
+ + "AND (p.targetAgeMin IS NULL OR p.targetAgeMin <= :childAge) "
+ + "AND (p.targetAgeMax IS NULL OR p.targetAgeMax >= :childAge) "
+ + "ORDER BY p.applicationEndDate ASC")
+ List findDeadlinesForChildAge(@Param("from") LocalDate from,
+ @Param("to") LocalDate to,
+ @Param("childAge") Integer childAge);
+
// 키워드로 정책 검색
@Query("SELECT p FROM Policy p WHERE p.isActive = true AND " +
"(p.title LIKE %:keyword% OR p.description LIKE %:keyword%)")
diff --git a/src/test/java/com/carecode/domain/health/timeline/ChildTimelineServiceTest.java b/src/test/java/com/carecode/domain/health/timeline/ChildTimelineServiceTest.java
new file mode 100644
index 0000000..f5318a7
--- /dev/null
+++ b/src/test/java/com/carecode/domain/health/timeline/ChildTimelineServiceTest.java
@@ -0,0 +1,228 @@
+package com.carecode.domain.health.timeline;
+
+import com.carecode.domain.health.dto.response.ChildTimelineResponse;
+import com.carecode.domain.health.entity.HealthRecord;
+import com.carecode.domain.health.entity.VaccinationSchedule;
+import com.carecode.domain.health.entity.VaccineType;
+import com.carecode.domain.health.repository.HealthRecordRepository;
+import com.carecode.domain.health.repository.VaccinationScheduleRepository;
+import com.carecode.domain.health.service.ChildService;
+import com.carecode.domain.policy.entity.Policy;
+import com.carecode.domain.policy.repository.PolicyRepository;
+import com.carecode.domain.user.entity.Child;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+/**
+ * 타임라인은 "다음에 뭘 해야 하나" 에 답하는 화면이다. 그래서 놓친 항목을 감추지 않는 것이
+ * 가장 중요한 성질이고, 없는 날짜를 만들어 내지 않는 것이 그다음이다.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("아이 할 일 타임라인")
+class ChildTimelineServiceTest {
+
+ private static final Long CHILD_ID = 7L;
+
+ @Mock ChildService childService;
+ @Mock VaccinationScheduleRepository vaccinationScheduleRepository;
+ @Mock HealthRecordRepository healthRecordRepository;
+ @Mock PolicyRepository policyRepository;
+
+ private ChildTimelineService service;
+ private LocalDate today;
+
+ @BeforeEach
+ void setUp() {
+ service = new ChildTimelineService(childService, vaccinationScheduleRepository,
+ healthRecordRepository, policyRepository);
+ today = LocalDate.now();
+ givenChild(today.minusMonths(13));
+ when(vaccinationScheduleRepository.findByChildIdOrderByDueDateAsc(anyLong())).thenReturn(List.of());
+ when(healthRecordRepository.findByChildIdAndRecordType(anyLong(), any())).thenReturn(List.of());
+ when(policyRepository.findDeadlinesForChildAge(any(), any(), anyInt())).thenReturn(List.of());
+ }
+
+ @Test
+ @DisplayName("놓친 접종은 구간 시작 전이라도 담고 OVERDUE 로 표시한다")
+ void overdueVaccinationIsKept() {
+ when(vaccinationScheduleRepository.findByChildIdOrderByDueDateAsc(CHILD_ID))
+ .thenReturn(List.of(schedule(1L, VaccineType.HEP_B, 1, today.minusMonths(6),
+ VaccinationSchedule.VaccinationStatus.SCHEDULED)));
+
+ ChildTimelineResponse timeline = service.timeline(CHILD_ID, 12);
+
+ assertThat(items(timeline, "VACCINATION")).hasSize(1);
+ assertThat(items(timeline, "VACCINATION").get(0).getStatus()).isEqualTo("OVERDUE");
+ // 13개월 아이는 받지 않은 검진(1~3차)도 함께 놓친 항목으로 잡힌다. 합계는 상태별 개수와 같다.
+ assertThat(timeline.getOverdueCount())
+ .isEqualTo(timeline.getItems().stream().filter(item -> "OVERDUE".equals(item.getStatus())).count())
+ .isGreaterThanOrEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("완료·건너뜀 접종은 할 일이 아니라 담지 않는다")
+ void completedVaccinationIsExcluded() {
+ when(vaccinationScheduleRepository.findByChildIdOrderByDueDateAsc(CHILD_ID))
+ .thenReturn(List.of(
+ schedule(1L, VaccineType.HEP_B, 1, today.plusDays(10),
+ VaccinationSchedule.VaccinationStatus.COMPLETED),
+ schedule(2L, VaccineType.HEP_B, 2, today.plusDays(20),
+ VaccinationSchedule.VaccinationStatus.SKIPPED)));
+
+ assertThat(items(service.timeline(CHILD_ID, 12), "VACCINATION")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("구간 밖의 접종은 담지 않는다")
+ void vaccinationBeyondWindowIsExcluded() {
+ when(vaccinationScheduleRepository.findByChildIdOrderByDueDateAsc(CHILD_ID))
+ .thenReturn(List.of(schedule(1L, VaccineType.HEP_B, 3, today.plusMonths(18),
+ VaccinationSchedule.VaccinationStatus.SCHEDULED)));
+
+ assertThat(items(service.timeline(CHILD_ID, 6), "VACCINATION")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("표준 검진 시기를 담고, 그 시기에 받은 기록이 있으면 DONE 으로 본다")
+ void checkupUsesExistingRecords() {
+ // 13개월 아이: 3차(9~12개월)는 시기가 지났고, 4차(18~24개월)는 앞으로다.
+ LocalDate thirdRoundVisit = today.minusMonths(2);
+ when(healthRecordRepository.findByChildIdAndRecordType(CHILD_ID, HealthRecord.RecordType.CHECKUP))
+ .thenReturn(List.of(checkupRecord(thirdRoundVisit)));
+
+ List checkups = items(service.timeline(CHILD_ID, 24), "CHECKUP");
+
+ assertThat(checkups).isNotEmpty();
+ assertThat(checkups).anyMatch(item -> "UPCOMING".equals(item.getStatus()));
+ assertThat(checkups).noneMatch(item -> item.getTitle().contains("3차"));
+ }
+
+ @Test
+ @DisplayName("받지 않고 시기가 지난 검진은 OVERDUE 로 남긴다 — 지났다고 감추면 놓친 걸 모른다")
+ void missedCheckupStaysVisible() {
+ List checkups = items(service.timeline(CHILD_ID, 12), "CHECKUP");
+
+ assertThat(checkups).anyMatch(item -> "OVERDUE".equals(item.getStatus()));
+ }
+
+ @Test
+ @DisplayName("지원금은 아이 나이에 맞는 것만, 마감일에 놓는다")
+ void policyDeadlineItems() {
+ when(policyRepository.findDeadlinesForChildAge(any(), any(), anyInt()))
+ .thenReturn(List.of(policy(11L, "첫만남이용권", today.plusMonths(2))));
+
+ List policies = items(service.timeline(CHILD_ID, 12), "POLICY_DEADLINE");
+
+ assertThat(policies).hasSize(1);
+ assertThat(policies.get(0).getDate()).isEqualTo(today.plusMonths(2));
+ assertThat(policies.get(0).getTitle()).contains("첫만남이용권");
+ // 검증되지 않은 금액은 추정치라는 사실을 함께 알린다.
+ assertThat(policies.get(0).getDescription()).contains("추정치");
+ }
+
+ @Test
+ @DisplayName("3월 신학기는 참고 항목이고 날짜를 만들어 내지 않는다")
+ void newTermIsInfoOnly() {
+ List terms = items(service.timeline(CHILD_ID, 24), "NEW_TERM");
+
+ assertThat(terms).isNotEmpty();
+ assertThat(terms).allMatch(item -> "INFO".equals(item.getStatus()));
+ assertThat(terms).allMatch(item -> item.getDate().getMonthValue() == 3);
+ assertThat(terms.get(0).getDescription()).contains("시설마다 다르므로");
+ }
+
+ @Test
+ @DisplayName("항목은 날짜순으로 정렬된다")
+ void itemsAreSorted() {
+ when(vaccinationScheduleRepository.findByChildIdOrderByDueDateAsc(CHILD_ID))
+ .thenReturn(List.of(
+ schedule(1L, VaccineType.HEP_B, 2, today.plusMonths(5),
+ VaccinationSchedule.VaccinationStatus.SCHEDULED),
+ schedule(2L, VaccineType.HEP_B, 1, today.minusMonths(1),
+ VaccinationSchedule.VaccinationStatus.SCHEDULED)));
+ when(policyRepository.findDeadlinesForChildAge(any(), any(), anyInt()))
+ .thenReturn(List.of(policy(11L, "양육수당", today.plusMonths(1))));
+
+ List dates = service.timeline(CHILD_ID, 12).getItems().stream()
+ .map(ChildTimelineResponse.TimelineItem::getDate)
+ .toList();
+
+ assertThat(dates).isSorted();
+ }
+
+ @Test
+ @DisplayName("조회 기간은 상한을 넘지 않는다")
+ void windowIsCapped() {
+ assertThat(service.timeline(CHILD_ID, 999).getTo()).isEqualTo(today.plusMonths(36));
+ assertThat(service.timeline(CHILD_ID, null).getTo()).isEqualTo(today.plusMonths(12));
+ assertThat(service.timeline(CHILD_ID, 0).getTo()).isEqualTo(today.plusMonths(12));
+ }
+
+ @Test
+ @DisplayName("생년월일이 없으면 월령 기반 항목 없이도 동작한다")
+ void worksWithoutBirthDate() {
+ givenChild(null);
+
+ ChildTimelineResponse timeline = service.timeline(CHILD_ID, 12);
+
+ assertThat(items(timeline, "CHECKUP")).isEmpty();
+ assertThat(items(timeline, "NEW_TERM")).isEmpty();
+ assertThat(timeline.getBirthDate()).isNull();
+ }
+
+ private void givenChild(LocalDate birthDate) {
+ when(childService.requireOwned(CHILD_ID)).thenReturn(Child.builder()
+ .id(CHILD_ID)
+ .name("아이")
+ .birthDate(birthDate)
+ .build());
+ }
+
+ private static List items(ChildTimelineResponse timeline, String type) {
+ return timeline.getItems().stream().filter(item -> type.equals(item.getType())).toList();
+ }
+
+ private static VaccinationSchedule schedule(Long id, VaccineType type, int dose, LocalDate dueDate,
+ VaccinationSchedule.VaccinationStatus status) {
+ return VaccinationSchedule.builder()
+ .id(id)
+ .child(Child.builder().id(CHILD_ID).build())
+ .vaccineType(type)
+ .doseNumber(dose)
+ .dueDate(dueDate)
+ .status(status)
+ .build();
+ }
+
+ private static HealthRecord checkupRecord(LocalDate date) {
+ HealthRecord record = new HealthRecord();
+ record.setRecordType(HealthRecord.RecordType.CHECKUP);
+ record.setRecordDate(date);
+ return record;
+ }
+
+ private static Policy policy(Long id, String title, LocalDate deadline) {
+ return Policy.builder()
+ .id(id)
+ .title(title)
+ .applicationEndDate(deadline)
+ .isActive(true)
+ .build();
+ }
+}
diff --git a/src/test/java/com/carecode/integration/ChildTimelineContractTest.java b/src/test/java/com/carecode/integration/ChildTimelineContractTest.java
new file mode 100644
index 0000000..7e0cede
--- /dev/null
+++ b/src/test/java/com/carecode/integration/ChildTimelineContractTest.java
@@ -0,0 +1,176 @@
+package com.carecode.integration;
+
+import com.carecode.CareCodeApplication;
+import com.carecode.domain.health.entity.VaccinationSchedule;
+import com.carecode.domain.health.entity.VaccineType;
+import com.carecode.domain.health.repository.VaccinationScheduleRepository;
+import com.carecode.domain.user.entity.Child;
+import com.carecode.domain.user.entity.User;
+import com.carecode.domain.user.entity.UserRole;
+import com.carecode.domain.user.repository.ChildRepository;
+import com.carecode.domain.user.repository.UserRepository;
+import com.carecode.domain.user.service.JwtService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+
+/** 타임라인은 아이 개인정보다. 소유권과 응답 모양을 실제 필터 체인으로 고정한다. */
+@SpringBootTest(
+ classes = CareCodeApplication.class,
+ properties = {
+ "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,"
+ + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration,"
+ + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration",
+ "spring.cache.type=none",
+ "spring.datasource.url=jdbc:h2:mem:carecode_child_timeline;MODE=MySQL;DB_CLOSE_DELAY=-1",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password=",
+ "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
+ "spring.jpa.hibernate.ddl-auto=create-drop",
+ "spring.flyway.enabled=false",
+ "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789",
+ "springdoc.api-docs.enabled=false",
+ "springdoc.swagger-ui.enabled=false",
+ "public.data.api.key=dummy",
+ "KAKAO_CLIENT_ID=dummy-kakao-client",
+ "KAKAO_CLIENT_SECRET=dummy-kakao-secret",
+ "MAIL_USERNAME=dummy",
+ "MAIL_PASSWORD=dummy"
+ }
+)
+@AutoConfigureMockMvc
+@DisplayName("아이 타임라인 계약")
+class ChildTimelineContractTest {
+
+ @MockBean RedisConnectionFactory redisConnectionFactory;
+ @MockBean StringRedisTemplate stringRedisTemplate;
+ @MockBean JavaMailSender javaMailSender;
+
+ @Autowired MockMvc mockMvc;
+ @Autowired ObjectMapper objectMapper;
+ @Autowired JwtService jwtService;
+ @Autowired UserRepository userRepository;
+ @Autowired ChildRepository childRepository;
+ @Autowired VaccinationScheduleRepository vaccinationScheduleRepository;
+
+ private User parent;
+ private User stranger;
+ private Child child;
+
+ @BeforeEach
+ void setUp() {
+ parent = saveUser();
+ stranger = saveUser();
+ child = childRepository.save(Child.builder()
+ .user(parent)
+ .name("아이")
+ .birthDate(LocalDate.now().minusMonths(13))
+ .gender("FEMALE")
+ .createdAt(LocalDateTime.now())
+ .build());
+ }
+
+ @Test
+ @DisplayName("로그인 없이는 볼 수 없다")
+ void requiresLogin() throws Exception {
+ assertThat(mockMvc.perform(get("/children/{id}/timeline", child.getId()))
+ .andReturn().getResponse().getStatus()).isEqualTo(401);
+ }
+
+ @Test
+ @DisplayName("남의 아이 타임라인은 404 — 존재 여부도 알려주지 않는다")
+ void otherParentCannotSee() throws Exception {
+ assertThat(mockMvc.perform(get("/children/{id}/timeline", child.getId())
+ .header("Authorization", "Bearer " + token(stranger)))
+ .andReturn().getResponse().getStatus()).isEqualTo(404);
+ }
+
+ @Test
+ @DisplayName("접종·검진·신학기를 한 축에 날짜순으로 준다")
+ void mergesSourcesInOneAxis() throws Exception {
+ vaccinationScheduleRepository.save(VaccinationSchedule.builder()
+ .child(child)
+ .vaccineType(VaccineType.HEP_B)
+ .doseNumber(3)
+ .dueDate(LocalDate.now().plusMonths(2))
+ .status(VaccinationSchedule.VaccinationStatus.SCHEDULED)
+ .build());
+
+ JsonNode timeline = json(mockMvc.perform(get("/children/{id}/timeline", child.getId())
+ .param("months", "24")
+ .header("Authorization", "Bearer " + token(parent))).andReturn());
+
+ assertThat(timeline.path("childId").asLong()).isEqualTo(child.getId());
+ assertThat(timeline.path("items").isArray()).isTrue();
+ assertThat(timeline.path("items")).isNotEmpty();
+
+ var types = timeline.path("items").findValuesAsText("type");
+ assertThat(types).contains("VACCINATION", "CHECKUP", "NEW_TERM");
+
+ // 날짜순인지 확인. 화면이 그대로 그리므로 정렬이 계약이다.
+ var dates = timeline.path("items").findValuesAsText("date");
+ assertThat(dates).isSorted();
+
+ // 놓친 항목 수가 함께 온다 (배지용).
+ assertThat(timeline.path("overdueCount").isNumber()).isTrue();
+ assertThat(timeline.path("upcomingCount").isNumber()).isTrue();
+ }
+
+ @Test
+ @DisplayName("기간을 안 주면 12개월, 상한은 36개월")
+ void windowDefaultsAndCap() throws Exception {
+ JsonNode defaultWindow = json(mockMvc.perform(get("/children/{id}/timeline", child.getId())
+ .header("Authorization", "Bearer " + token(parent))).andReturn());
+ assertThat(defaultWindow.path("to").asText()).isEqualTo(LocalDate.now().plusMonths(12).toString());
+
+ JsonNode capped = json(mockMvc.perform(get("/children/{id}/timeline", child.getId())
+ .param("months", "120")
+ .header("Authorization", "Bearer " + token(parent))).andReturn());
+ assertThat(capped.path("to").asText()).isEqualTo(LocalDate.now().plusMonths(36).toString());
+ }
+
+ private JsonNode json(MvcResult result) throws Exception {
+ String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
+ assertThat(result.getResponse().getStatus()).as(body).isEqualTo(200);
+ return objectMapper.readTree(body);
+ }
+
+ private String token(User user) {
+ return jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name());
+ }
+
+ private User saveUser() {
+ String id = UUID.randomUUID().toString().substring(0, 8);
+ return userRepository.save(User.builder()
+ .userId("user_" + id)
+ .email(id + "@example.com")
+ .password("{noop}unused")
+ .name("보호자" + id)
+ .role(UserRole.PARENT)
+ .isActive(true)
+ .emailVerified(true)
+ .registrationCompleted(true)
+ .createdAt(LocalDateTime.now())
+ .build());
+ }
+}