From 160053f0a9f1b3e5e45ff5529afdb762c0089f2a Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 25 Sep 2026 03:52:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=20=ED=95=A0=20?= =?UTF-8?q?=EC=9D=BC=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=91=EC=A2=85=C2=B7=EA=B2=80=EC=A7=84=C2=B7=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EA=B8=88=20=EB=A7=88=EA=B0=90=EC=9D=84=20=ED=95=9C=20?= =?UTF-8?q?=EC=B6=95=EC=97=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부모가 "다음에 뭘 해야 하나" 를 알려면 화면 세 곳을 돌아야 했다. 특히 검진은 /health/checkups/schedule 이 이름과 달리 이미 기록된 검진만 나열해서, 아직 받지 않은 검진은 화면에 나타나지 않았다 — 놓쳐도 아무도 알려주지 않는다. 새로 수집하는 데이터는 없다. 흩어져 있던 것을 날짜 하나로 정렬해 준다. - GET /children/{childId}/timeline?months=12 (기본 12, 최대 36) - 접종: 자동 생성된 표준 일정에서 완료·건너뜀 제외, 기한 지난 것은 OVERDUE - 검진: 국가 영유아 건강검진 8차 시기(CheckupStandard)를 코드에 담고, 그 구간에 남은 검진 기록이 있으면 DONE. 검진 기록에 회차가 없어 날짜로 맞춘다 - 지원금: 아이 나이에 해당하고 마감이 구간 안인 정책. 미검증 금액은 추정치임을 밝힌다 - 3월 신학기: 시설마다 신청 일정이 달라 날짜를 만들지 않고 참고(INFO) 항목으로만 둔다 놓친 항목은 구간 앞이라도 담고 overdueCount 로 함께 준다. 지난 일이라고 감추면 사용자는 놓친 사실 자체를 모른다. 소유권은 ChildService.requireOwned 하나만 쓴다(검증을 복사하면 한쪽만 고쳐져 구멍이 남는다). 남의 아이는 403 이 아니라 404 다 — 403 은 "그 아이가 존재한다" 를 알려 준다. --- docs/api/openapi.json | 113 +++++++++ docs/features/child-timeline.md | 81 +++++++ docs/reference/access-control-matrix.md | 2 +- .../health/controller/ChildController.java | 12 + .../dto/response/ChildTimelineResponse.java | 55 +++++ .../domain/health/service/ChildService.java | 8 + .../health/timeline/CheckupStandard.java | 59 +++++ .../health/timeline/ChildTimelineService.java | 229 ++++++++++++++++++ .../policy/repository/PolicyRepository.java | 10 + .../timeline/ChildTimelineServiceTest.java | 228 +++++++++++++++++ .../ChildTimelineContractTest.java | 176 ++++++++++++++ 11 files changed, 972 insertions(+), 1 deletion(-) create mode 100644 docs/features/child-timeline.md create mode 100644 src/main/java/com/carecode/domain/health/dto/response/ChildTimelineResponse.java create mode 100644 src/main/java/com/carecode/domain/health/timeline/CheckupStandard.java create mode 100644 src/main/java/com/carecode/domain/health/timeline/ChildTimelineService.java create mode 100644 src/test/java/com/carecode/domain/health/timeline/ChildTimelineServiceTest.java create mode 100644 src/test/java/com/carecode/integration/ChildTimelineContractTest.java diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 63804299..59eea87a 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1269,6 +1269,44 @@ }, "type" : "object" }, + "ChildTimelineResponse" : { + "properties" : { + "birthDate" : { + "format" : "date", + "type" : "string" + }, + "childId" : { + "format" : "int64", + "type" : "integer" + }, + "childName" : { + "type" : "string" + }, + "from" : { + "format" : "date", + "type" : "string" + }, + "items" : { + "items" : { + "$ref" : "#/components/schemas/TimelineItem" + }, + "type" : "array" + }, + "overdueCount" : { + "format" : "int32", + "type" : "integer" + }, + "to" : { + "format" : "date", + "type" : "string" + }, + "upcomingCount" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, "Cohort" : { "properties" : { "day1" : { @@ -3846,6 +3884,34 @@ }, "type" : "object" }, + "TimelineItem" : { + "properties" : { + "ageMonths" : { + "format" : "int32", + "type" : "integer" + }, + "date" : { + "format" : "date", + "type" : "string" + }, + "description" : { + "type" : "string" + }, + "referenceId" : { + "type" : "string" + }, + "status" : { + "type" : "string" + }, + "title" : { + "type" : "string" + }, + "type" : { + "type" : "string" + } + }, + "type" : "object" + }, "TokenDto" : { "properties" : { "accessToken" : { @@ -8484,6 +8550,53 @@ "tags" : [ "아이 관리" ] } }, + "/children/{childId}/timeline" : { + "get" : { + "description" : "접종·검진 권장 시기·지원금 신청 마감·신학기를 한 축에 모아 날짜순으로 준다. 놓친 항목(OVERDUE)은 구간 앞이라도 포함한다. 기본 12개월, 최대 36개월.", + "operationId" : "getTimeline", + "parameters" : [ { + "in" : "path", + "name" : "childId", + "required" : true, + "schema" : { + "format" : "int64", + "type" : "integer" + } + }, { + "description" : "조회 기간(개월). 기본 12, 최대 36", + "in" : "query", + "name" : "months", + "required" : false, + "schema" : { + "format" : "int32", + "type" : "integer" + } + }, { + "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", + "in" : "header", + "name" : "X-API-Version", + "required" : false, + "schema" : { + "default" : "1", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ChildTimelineResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "아이 할 일 타임라인", + "tags" : [ "아이 관리" ] + } + }, "/children/{childId}/vaccinations" : { "get" : { "description" : "표준 일정에 따른 접종 예정일과 완료 여부 반환", diff --git a/docs/features/child-timeline.md b/docs/features/child-timeline.md new file mode 100644 index 00000000..cd50348a --- /dev/null +++ b/docs/features/child-timeline.md @@ -0,0 +1,81 @@ +# 아이 할 일 타임라인 + +> 관련 이슈: #128 + +## 문제 + +부모가 "다음에 뭘 해야 하나" 를 알려면 화면 세 곳을 돌아야 했습니다. + +| 할 일 | 있던 곳 | +|-------|---------| +| 예방접종 | 아이 상세 > 접종 일정 | +| 건강검진 | 건강 기록 (그나마 **받은 것만** 보였습니다) | +| 지원금 신청 마감 | 정책 화면 | +| 시설 입소 시기 | 어디에도 없음 | + +특히 검진이 문제였습니다. `GET /health/checkups/schedule` 은 이름과 달리 **이미 기록된 검진을 나열**할 +뿐이어서, 아직 받지 않은 검진은 화면에 나타나지 않았습니다. 놓쳐도 아무도 알려주지 않습니다. + +## 해결 — 이미 있는 데이터를 한 축에 놓는다 + +`GET /children/{childId}/timeline?months=12` + +새로 수집하는 데이터는 없습니다. 흩어져 있던 것을 날짜 하나로 정렬해 돌려줍니다. + +```mermaid +flowchart LR + V["접종 일정
(자동 생성된 표준 일정)"] --> T + C["검진 권장 시기
(표준 8차) + 검진 기록"] --> T + P["지원금 마감
(아이 나이에 맞는 정책)"] --> T + N["3월 신학기"] --> T + T["날짜순 타임라인
OVERDUE / UPCOMING / DONE / INFO"] + + style T fill:#d4edda,stroke:#28a745 +``` + +| 항목 | 출처 | 상태 판단 | +|------|------|-----------| +| `VACCINATION` | `TBL_VACCINATION_SCHEDULE` | 완료·건너뜀은 제외. 기한이 지났으면 `OVERDUE` | +| `CHECKUP` | 표준 시기(`CheckupStandard`) + `TBL_HEALTH_RECORD` 의 검진 기록 | 그 시기에 기록이 있으면 `DONE`, 없이 지났으면 `OVERDUE` | +| `POLICY_DEADLINE` | `TBL_POLICIES` 의 신청 마감 | 마감일에 `UPCOMING` | +| `NEW_TERM` | 달력(3월) | 참고(`INFO`) | + +## 판단 세 가지 + +### 놓친 항목을 감추지 않는다 + +지난 일이라고 목록에서 빼면 사용자는 놓친 사실 자체를 모릅니다. 조회 구간이 오늘부터여도 +**기한이 지난 접종·검진은 담아** `OVERDUE` 로 표시하고, 개수를 `overdueCount` 로 함께 줍니다. + +### 없는 날짜를 만들지 않는다 + +3월 신학기는 시설 입소가 몰리는 시점이지만 **신청 일정은 시설마다 다릅니다.** 그럴듯한 날짜를 적으면 +그걸 믿고 놓치는 사람이 생기므로, 참고 항목으로만 두고 "시설에 직접 확인하세요" 라고 밝힙니다. + +검증되지 않은 지원금 금액에도 같은 원칙을 적용합니다 — `verifiedAt` 이 없으면 설명에 추정치임을 붙입니다. + +### 검진 회차는 날짜로 맞춘다 + +검진 기록에 회차 정보가 없어서, 권장 시기 구간 안에 기록이 있으면 받은 것으로 봅니다. +지금 데이터로 할 수 있는 최선이고, 회차를 받게 되면 `ChildTimelineService` 한 곳만 고치면 됩니다. + +## 표준 검진 시기 + +국민건강보험 영유아 건강검진(생후 14일~71개월, 8차)을 `CheckupStandard` 에 담았습니다. +예방접종 일정(`VaccineType`)과 같은 방식입니다. 구강검진은 별도 회차라 넣지 않았습니다. +제도가 바뀔 수 있으므로 화면에는 "권장 시기" 로 표시합니다. + +## 접근제어 + +아이 개인정보이므로 `/children/**` 인증 규칙을 따르고, 서비스에서 **보호자 본인 것만** 반환합니다. +남의 아이면 403 이 아니라 **404** 입니다 — 403 은 "그 아이가 존재한다" 는 사실을 알려 줍니다. + +소유권 검증은 `ChildService.requireOwned` 하나만 씁니다. 검증을 복사하면 한쪽만 고쳐져 구멍이 남습니다. + +## 한계 + +| 항목 | 내용 | +|------|------| +| 조회 기간 | 기본 12개월, 최대 36개월. 더 길게 보면 정책 마감이 의미 없어집니다 | +| 지원금 대상 판정 | 나이만 봅니다. 소득·다자녀 조건은 [지원금 지능화](benefit-intelligence.md)의 추천 API 가 봅니다 | +| 시설 신청 일정 | 공공데이터에 없습니다. 시설별 신청 일정을 받으면 `NEW_TERM` 을 실제 날짜로 바꿀 수 있습니다 | diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md index 0c8c08ff..8460c211 100644 --- a/docs/reference/access-control-matrix.md +++ b/docs/reference/access-control-matrix.md @@ -138,7 +138,7 @@ flowchart TD | `/auth/user/**`, `/auth/logout` | — | | `/users/**` | **본인 계정 전용.** 경로 변수가 있는 구 경로는 서비스 진입 전에 본인인지 확인한다 | | `/users/privacy/**` | 열람·동의·탈퇴 | -| `/children/**` | 자녀 정보 | +| `/children/**` | 자녀 정보. 서비스에서 보호자 본인 것만 반환하고, 남의 아이는 404 (존재 여부를 숨긴다) | | `/notifications/**` | — | | `POST /facilities/{id}/bookings`, `/facilities/bookings/user`, `/facilities/bookings/{bookingId}` | 본인 예약. 남의 예약은 403 | | `/facilities/{id}/reviews` (POST), `/facilities/reviews/{reviewId}` | 리뷰 작성·수정·삭제 (본인 것만) | diff --git a/src/main/java/com/carecode/domain/health/controller/ChildController.java b/src/main/java/com/carecode/domain/health/controller/ChildController.java index c3a33f04..b0f17071 100644 --- a/src/main/java/com/carecode/domain/health/controller/ChildController.java +++ b/src/main/java/com/carecode/domain/health/controller/ChildController.java @@ -36,6 +36,7 @@ public class ChildController { private final SiblingOverviewService siblingOverviewService; private final VaccinationScheduleService vaccinationScheduleService; private final GrowthChartService growthChartService; + private final com.carecode.domain.health.timeline.ChildTimelineService timelineService; @PostMapping @LogExecutionTime @@ -52,6 +53,17 @@ public ResponseEntity> getMyChildren() { return ResponseEntity.ok(childService.getMyChildren()); } + @GetMapping("/{childId}/timeline") + @LogExecutionTime + @Operation(summary = "아이 할 일 타임라인", + description = "접종·검진 권장 시기·지원금 신청 마감·신학기를 한 축에 모아 날짜순으로 준다. " + + "놓친 항목(OVERDUE)은 구간 앞이라도 포함한다. 기본 12개월, 최대 36개월.") + public ResponseEntity 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 00000000..be222ded --- /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 8563a677..f8a94c21 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 00000000..3d302d5b --- /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 00000000..b06a5721 --- /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 1161850c..c038eee5 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 00000000..f5318a73 --- /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 00000000..7e0cede6 --- /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()); + } +}