From ead8fa52190aa4ff43cb853fb123dad4cedc7130 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 22 Sep 2026 20:37:18 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=EC=99=80=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=9C=20API=20=EA=B3=84=EC=95=BD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8,=20=EA=B0=80=EC=9E=85=20=EC=99=84=EB=A3=8C,=20?= =?UTF-8?q?=EB=8B=B5=EA=B8=80,=20=EC=98=88=EC=95=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오 로그인: 서버에서 지워진 /oauth2/kakao/auth-url 을 계속 불러 메인 화면의 로그인 버튼이 반응하지 않았다. /auth/kakao/login-url 과 응답 키 loginUrl 로 맞춘다. - 카카오 가입 완료: 응답 스키마가 서버가 보내지 않는 password 를 필수로 요구해 파싱이 항상 실패했고 사용자가 /signup 에 갇혔다. - 커뮤니티 답글: replies 를 문자열 배열로 받아 답글이 하나라도 있으면 글 상세가 열리지 않았다. 댓글 객체 트리로 받고 답글도 화면에 그린다. - 시설 예약: 서버가 필수로 요구하는 종료 시각을 보내지 않아 예약이 항상 실패했다. 방문 상담 1시간으로 채운다. - 건강 기록 유형을 서버 목록(질병·성장·치과·안과·응급 포함)과 맞춘다. - 호출처가 없는 API 함수 7개 제거. --- src/apis/auth.ts | 41 +----- src/apis/health.ts | 20 --- src/apis/hospital.ts | 7 -- src/apis/user.ts | 9 -- .../(without-tabs)/community/[id]/page.tsx | 23 +++- src/app/page.tsx | 2 +- .../features/facility/BookingDialog.tsx | 24 +++- .../features/health/HealthRecordCard.tsx | 7 +- .../__tests__/HealthRecordCard.test.tsx | 12 +- src/queries/auth.ts | 4 +- .../__tests__/contracts-alignment.test.ts | 118 ++++++++++++++++++ src/types/apis/auth.ts | 47 ++++--- src/types/apis/community.ts | 45 +++++-- src/types/apis/health.ts | 24 +++- 14 files changed, 262 insertions(+), 121 deletions(-) create mode 100644 src/types/apis/__tests__/contracts-alignment.test.ts diff --git a/src/apis/auth.ts b/src/apis/auth.ts index 3085d10..acd19f1 100644 --- a/src/apis/auth.ts +++ b/src/apis/auth.ts @@ -2,21 +2,10 @@ import { CareCode } from './interceptor' import { PostLoginBody, PostLoginResponse, - PostKakaoLoginBody, - PostKakaoLoginResponse, - PostRegisterBody, - PostRegisterResponse, - PostKakaoRegisterBody, PostRefreshTokenResponse, - postKakaoLoginBodySchema, - postKakaoLoginResponseSchema, - postKakaoRegisterBodySchema, - postKakaoRegisterResponseSchema, postLoginBodySchema, postLoginResponseSchema, postRefreshTokenResponseSchema, - postRegisterBodySchema, - postRegisterResponseSchema, getKakaoAuthUrlResponseSchema, GetKakaoAuthUrlResponse, postKakaoAuthBodySchema, @@ -36,29 +25,6 @@ export const postLogin = async (body: PostLoginBody): Promise return postLoginResponseSchema.parse(res.data) } -// /auth/kakao/login -export const postKakaoLogin = async (body: PostKakaoLoginBody): Promise => { - const parsedBody = postKakaoLoginBodySchema.parse(body) - const res = await CareCode.post('/auth/kakao/login', parsedBody) - return postKakaoLoginResponseSchema.parse(res.data) -} - -// /auth/register -export const PostRegister = async (body: PostRegisterBody): Promise => { - const parsedBody = postRegisterBodySchema.parse(body) - const res = await CareCode.post('/auth/register', parsedBody) - return postRegisterResponseSchema.parse(res.data) -} - -// /auth/kakao/register -export const PostKakaoRegister = async ( - body: PostKakaoRegisterBody, -): Promise => { - const parsedBody = postKakaoRegisterBodySchema.parse(body) - const res = await CareCode.post('/auth/kakao/register', parsedBody) - return postKakaoRegisterResponseSchema.parse(res.data) -} - let refreshTimer: NodeJS.Timeout | null = null /** @@ -139,10 +105,9 @@ export async function refreshAccessToken(): Promise { return parsed } -// GET /oauth2/kakao/auth-url - 카카오 인증 URL 요청 -export const getKakaoAuthUrl = async (redirectUri?: string): Promise => { - const params = redirectUri ? { redirectUri } : {} - const res = await CareCode.get('/oauth2/kakao/auth-url', { params }) +// GET /auth/kakao/login-url - 카카오 인증 URL 요청 +export const getKakaoAuthUrl = async (): Promise => { + const res = await CareCode.get('/auth/kakao/login-url') return getKakaoAuthUrlResponseSchema.parse(res.data) } diff --git a/src/apis/health.ts b/src/apis/health.ts index 2a60e28..7360767 100644 --- a/src/apis/health.ts +++ b/src/apis/health.ts @@ -12,8 +12,6 @@ import { HealthRecord, healthRecordListSchema, healthRecordSchema, - HealthStats, - healthStatsSchema, RecordType, UpdateHealthRecordBody, updateHealthRecordBodySchema, @@ -47,18 +45,6 @@ export const getHealthRecordsByType = async ( return healthRecordListSchema.parse(res.data) } -// GET /health/records/date-range-asc - 기간 조회 (성장 기록 확인용) -export const getHealthRecordsByDateRange = async ( - childId: number, - startDate: string, - endDate: string, -): Promise => { - const res = await CareCode.get('/health/records/date-range-asc', { - params: { childId, startDate, endDate }, - }) - return healthRecordListSchema.parse(res.data) -} - // PUT /health/records/{recordId} export const putHealthRecord = async ( recordId: number, @@ -125,12 +111,6 @@ export const getHealthAlerts = async (userId: string): Promise => return healthAlertListSchema.parse(res.data) } -// GET /health/statistics -export const getHealthStatistics = async (userId: string): Promise => { - const res = await CareCode.get('/health/statistics', { params: { userId } }) - return healthStatsSchema.parse(res.data) -} - // GET /health/recommendations - 아이 월령 기준 추천 export const getHealthRecommendations = async (): Promise => { const res = await CareCode.get('/health/recommendations') diff --git a/src/apis/hospital.ts b/src/apis/hospital.ts index 56a0f03..dc5bdb2 100644 --- a/src/apis/hospital.ts +++ b/src/apis/hospital.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { CareCode } from './interceptor' import { CreateHospitalReviewBody, @@ -56,12 +55,6 @@ export const getPopularHospitals = async (limit = 10): Promise => { // ==================== 찜 ==================== -// GET /health/hospitals/{id}/likes - 총 개수만 필요할 때 (비로그인 화면) -export const getHospitalLikeCount = async (id: number): Promise => { - const res = await CareCode.get(`/health/hospitals/${id}/likes`) - return z.number().parse(res.data) -} - // GET /health/hospitals/{id}/like-status - 내 찜 여부 + 총 개수 export const getHospitalLikeStatus = async (id: number): Promise => { const res = await CareCode.get(`/health/hospitals/${id}/like-status`) diff --git a/src/apis/user.ts b/src/apis/user.ts index b551991..2730630 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -47,15 +47,6 @@ export const getProfileCompletion = async (): Promise => { - await CareCode.put(`/users/${userId}/location`, null, { params: { latitude, longitude } }) -} - // POST /auth/logout - 서버의 리프레시 토큰 세션 폐기 export const postLogout = async (): Promise => { await CareCode.post('/auth/logout') diff --git a/src/app/(without-tabs)/community/[id]/page.tsx b/src/app/(without-tabs)/community/[id]/page.tsx index 8d04996..c2c0ac6 100644 --- a/src/app/(without-tabs)/community/[id]/page.tsx +++ b/src/app/(without-tabs)/community/[id]/page.tsx @@ -27,7 +27,7 @@ import { useToggleCommunityLike, } from '@/queries/community' import { useBlockUser, useReport } from '@/queries/moderation' -import { PostCommunityCommentBody } from '@/types/apis/community' +import { PostComment, PostCommunityCommentBody } from '@/types/apis/community' import { formatDate } from '@/utils/date' const CommunityDetail = (): JSX.Element => { @@ -184,11 +184,7 @@ const CommunityDetail = (): JSX.Element => { return ( { } export default CommunityDetail + +/** 답글까지 화면용 모양으로 바꾼다. 서버는 답글을 부모 댓글의 replies 에 트리로 담아 준다. */ +function toCommentData(comment: PostComment): { + author: string + content: string + timestamp: string + replies: ReturnType[] +} { + return { + author: comment.authorName, + content: comment.content, + timestamp: formatDate(comment.createdAt, 'MM/dd HH:mm'), + replies: comment.replies.map(toCommentData), + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 0166014..98341ee 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,7 +15,7 @@ export default function Home(): JSX.Element { const handleKakaoLogin = () => { getKakaoAuthUrl(undefined, { onSuccess: (data) => { - window.location.href = data.authUrl + window.location.href = data.loginUrl }, onError: (err) => { console.error('카카오 인증 URL을 가져오지 못했습니다:', err) diff --git a/src/components/features/facility/BookingDialog.tsx b/src/components/features/facility/BookingDialog.tsx index b23e7f5..2eec5d6 100644 --- a/src/components/features/facility/BookingDialog.tsx +++ b/src/components/features/facility/BookingDialog.tsx @@ -21,6 +21,23 @@ interface BookingDialogProps { /** date-time-local 값(yyyy-MM-ddTHH:mm)을 서버 LocalDateTime 형식으로 맞춘다. */ const toLocalDateTime = (value: string): string => (value.length === 16 ? `${value}:00` : value) +/** + * 방문 상담은 1시간으로 잡는다. 서버는 종료 시각이 필수(겹침 계산에 쓴다)인데 폼에는 종료 입력이 없어, + * 예약이 "예약 시작/종료 시간은 필수입니다" 로 항상 실패했다. + */ +const DEFAULT_VISIT_MINUTES = 60 + +/** 시간대 없는 LocalDateTime 문자열에 분을 더한다. 브라우저 로컬 시각 기준으로 계산하고 그대로 돌려준다. */ +const addMinutes = (localDateTime: string, minutes: number): string => { + const date = new Date(localDateTime) + date.setMinutes(date.getMinutes() + minutes) + const pad = (n: number) => String(n).padStart(2, '0') + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}:00` + ) +} + const BookingDialog = ({ isOpen, facilityName, @@ -49,11 +66,14 @@ const BookingDialog = ({ }) const submit = (values: PostFacilityBookBody) => { + const startTime = toLocalDateTime(values.startTime) onSubmit({ ...values, childAge: Number(values.childAge), - startTime: toLocalDateTime(values.startTime), - endTime: values.endTime ? toLocalDateTime(values.endTime) : undefined, + startTime, + endTime: values.endTime + ? toLocalDateTime(values.endTime) + : addMinutes(startTime, DEFAULT_VISIT_MINUTES), }) } diff --git a/src/components/features/health/HealthRecordCard.tsx b/src/components/features/health/HealthRecordCard.tsx index fa3c57f..d83c665 100644 --- a/src/components/features/health/HealthRecordCard.tsx +++ b/src/components/features/health/HealthRecordCard.tsx @@ -8,11 +8,16 @@ interface HealthRecordCardProps { onClick?: () => void } -const TYPE_COLOR: Record = { +const TYPE_COLOR: Record = { VACCINATION: 'green', CHECKUP: 'blue', + GROWTH: 'blue', + DENTAL: 'blue', + EYE: 'blue', MEDICATION: 'purple', + ILLNESS: 'yellow', SYMPTOM: 'yellow', + EMERGENCY: 'red', OTHER: 'white', } diff --git a/src/components/features/health/__tests__/HealthRecordCard.test.tsx b/src/components/features/health/__tests__/HealthRecordCard.test.tsx index 249f487..612f471 100644 --- a/src/components/features/health/__tests__/HealthRecordCard.test.tsx +++ b/src/components/features/health/__tests__/HealthRecordCard.test.tsx @@ -42,11 +42,17 @@ describe('HealthRecordCard', () => { expect(screen.getByText('예방접종')).toBeInTheDocument() }) - it('서버가 모르는 기록 종류는 원본 값이라도 보여준다', () => { - // 라벨 매핑에 없다고 빈칸이 되면 사용자가 무슨 기록인지 알 수 없다. + it('서버에만 있던 기록 종류(치과·응급 등)도 한글 라벨로 보여준다', () => { render() - expect(screen.getByText('DENTAL')).toBeInTheDocument() + expect(screen.getByText('치과')).toBeInTheDocument() + }) + + it('프런트가 모르는 새 기록 종류는 원본 값이라도 보여준다', () => { + // 라벨 매핑에 없다고 빈칸이 되면 사용자가 무슨 기록인지 알 수 없다. + render() + + expect(screen.getByText('SOMETHING_NEW')).toBeInTheDocument() }) it('측정값이 있으면 요약해서 보여준다', () => { diff --git a/src/queries/auth.ts b/src/queries/auth.ts index 24fc21d..22dd398 100644 --- a/src/queries/auth.ts +++ b/src/queries/auth.ts @@ -19,10 +19,10 @@ import { export const useGetKakaoAuthUrlMutation = (): UseMutationResult< GetKakaoAuthUrlResponse, Error, - string | undefined + void > => { return useMutation({ - mutationFn: (redirectUri?: string) => getKakaoAuthUrl(redirectUri), + mutationFn: () => getKakaoAuthUrl(), }) } diff --git a/src/types/apis/__tests__/contracts-alignment.test.ts b/src/types/apis/__tests__/contracts-alignment.test.ts new file mode 100644 index 0000000..cb8b31e --- /dev/null +++ b/src/types/apis/__tests__/contracts-alignment.test.ts @@ -0,0 +1,118 @@ +/** + * 서버와 어긋나 화면이 통째로 막혔던 응답들. 서버가 실제로 주는 모양을 그대로 넣어 고정한다. + */ +import { describe, expect, it } from 'vitest' +import { getKakaoAuthUrlResponseSchema, kakaoRegistrationResponseSchema } from '@/types/apis/auth' +import { postCommentSchema } from '@/types/apis/community' +import { policySearchResponseSchema } from '@/types/apis/policy' + +describe('getKakaoAuthUrlResponseSchema (GET /auth/kakao/login-url)', () => { + it('서버가 주는 loginUrl 을 읽는다', () => { + const parsed = getKakaoAuthUrlResponseSchema.parse({ + success: true, + loginUrl: + 'https://kauth.kakao.com/oauth/authorize?client_id=x&redirect_uri=y&response_type=code', + message: '카카오 로그인 URL이 생성되었습니다.', + }) + + expect(parsed.loginUrl).toContain('kauth.kakao.com') + }) +}) + +describe('kakaoRegistrationResponseSchema (UserDto)', () => { + it('password 키가 없는 응답(서버 WRITE_ONLY)도 통과한다', () => { + const parsed = kakaoRegistrationResponseSchema.parse({ + id: 7, + userId: 'user_abc', + email: 'kakao_1@kakao.com', + name: '새사용자', + role: 'PARENT', + provider: 'KAKAO', + isActive: true, + emailVerified: false, + registrationCompleted: true, + createdAt: '2026-09-22T10:00:00', + updatedAt: '2026-09-22T10:00:00', + }) + + expect(parsed.userId).toBe('user_abc') + }) +}) + +describe('postCommentSchema (CommunityCommentResponse)', () => { + it('답글을 댓글 객체 트리로 읽는다', () => { + const parsed = postCommentSchema.parse({ + commentId: 1, + content: '부모 댓글', + authorName: '가', + authorId: '10', + createdAt: '2026-09-22 10:00:00', + likeCount: 0, + isLiked: false, + parentCommentId: null, + replies: [ + { + commentId: 2, + content: '답글', + authorName: '나', + authorId: null, + createdAt: '2026-09-22 10:01:00', + likeCount: 0, + isLiked: false, + parentCommentId: 1, + replies: [], + }, + ], + }) + + expect(parsed.replies[0].content).toBe('답글') + expect(parsed.replies[0].replies).toEqual([]) + }) + + it('replies 가 없거나 null 이면 빈 배열로 둔다', () => { + const parsed = postCommentSchema.parse({ + commentId: 1, + content: '댓글', + authorName: '가', + createdAt: '2026-09-22 10:00:00', + replies: null, + }) + + expect(parsed.replies).toEqual([]) + }) +}) + +describe('policySearchResponseSchema (POST /policies/search)', () => { + it('서버 PolicyListResponse 를 그대로 읽는다', () => { + const parsed = policySearchResponseSchema.parse({ + policies: [ + { + id: 3, + title: '양육수당', + description: null, + category: null, + location: '서울특별시', + minAge: null, + maxAge: null, + supportAmount: 100000, + applicationPeriod: null, + contactInfo: null, + websiteUrl: null, + }, + ], + totalElements: 1, + totalCount: 1, + currentPage: 0, + pageSize: 100, + totalPages: 1, + hasNext: false, + hasPrevious: false, + category: null, + city: '서울', + district: null, + }) + + expect(parsed.totalElements).toBe(1) + expect(parsed.policies[0].id).toBe(3) + }) +}) diff --git a/src/types/apis/auth.ts b/src/types/apis/auth.ts index 6c0b5dd..ae32e98 100644 --- a/src/types/apis/auth.ts +++ b/src/types/apis/auth.ts @@ -102,12 +102,17 @@ export const postLogoutResponseSchema = z.object({ }) export type PostLogoutResponse = z.infer -// GET /oauth2/kakao/auth-url +/** + * GET /auth/kakao/login-url — 카카오 인가 페이지 주소. + * + * 예전 경로 /oauth2/kakao/auth-url 은 서버에서 2025-10 에 지워졌다. 프런트가 계속 그 경로를 불러 + * 메인 화면의 카카오 로그인 버튼이 404 로 아무 반응이 없었다. 응답 키도 authUrl 이 아니라 loginUrl 이다. + * redirect_uri 는 서버 설정(KAKAO_REDIRECT_URI)을 쓰므로 프런트가 보내지 않는다. + */ export const getKakaoAuthUrlResponseSchema = z.object({ success: z.boolean(), - redirectUri: z.string(), - authUrl: z.string().url(), - clientId: z.string(), + loginUrl: z.string().url(), + message: z.string().nullish(), }) export type GetKakaoAuthUrlResponse = z.infer @@ -150,24 +155,30 @@ export const kakaoRegistrationRequestSchema = z.object({ }) export type KakaoRegistrationRequest = z.infer +/** + * 서버 UserDto. password 는 서버에서 WRITE_ONLY 라 응답에 아예 없다. + * 예전 스키마는 password 를 (nullable 이지만) 필수 키로 요구해 파싱이 항상 실패했고, + * 카카오 가입 마지막 단계에서 사용자가 /signup 화면에 갇혔다. + * 화면이 쓰지 않는 값은 서버가 빼거나 null 로 줘도 가입이 막히지 않게 느슨하게 받는다. + */ export const kakaoRegistrationResponseSchema = z.object({ id: z.number(), userId: z.string(), email: z.string(), - password: z.string().nullable(), - name: z.string(), - phoneNumber: z.string().nullable(), - birthDate: z.string().nullable(), - gender: z.string().nullable(), - address: z.string().nullable(), - latitude: z.number().nullable(), - longitude: z.number().nullable(), - profileImageUrl: z.string().nullable(), + name: z.string().nullish(), + phoneNumber: z.string().nullish(), + birthDate: z.string().nullish(), + gender: z.string().nullish(), + address: z.string().nullish(), + latitude: z.number().nullish(), + longitude: z.number().nullish(), + profileImageUrl: z.string().nullish(), role: z.string(), - isActive: z.boolean(), - emailVerified: z.boolean(), - lastLoginAt: z.string().nullable(), - createdAt: z.string(), - updatedAt: z.string(), + isActive: z.boolean().nullish(), + emailVerified: z.boolean().nullish(), + registrationCompleted: z.boolean().nullish(), + lastLoginAt: z.string().nullish(), + createdAt: z.string().nullish(), + updatedAt: z.string().nullish(), }) export type KakaoRegistrationResponse = z.infer diff --git a/src/types/apis/community.ts b/src/types/apis/community.ts index 82cbb6f..2781935 100644 --- a/src/types/apis/community.ts +++ b/src/types/apis/community.ts @@ -7,18 +7,39 @@ export const postAuthorSchema = z.object({ }) export type PostAuthor = z.infer -export const postCommentSchema = z.object({ - commentId: z.number(), - content: z.string(), - authorName: z.string(), - authorId: z.string(), - createdAt: z.string(), - likeCount: z.number(), - isLiked: z.boolean(), - parentCommentId: z.number().nullable(), - replies: z.array(z.string()).default([]), -}) -export type PostComment = z.infer +/** + * 서버 CommunityCommentResponse. replies 는 같은 모양의 댓글 객체 트리다. + * + * 예전 스키마는 replies 를 문자열 배열로 받아, 답글이 하나라도 달린 글은 상세 파싱 전체가 실패해 + * 글이 열리지 않았다. 탈퇴한 작성자의 댓글은 authorId 가 없을 수 있다. + */ +export type PostComment = { + commentId: number + content: string + authorName: string + authorId?: string | null + createdAt: string + likeCount?: number | null + isLiked?: boolean | null + parentCommentId?: number | null + replies: PostComment[] +} +export const postCommentSchema: z.ZodType = z.lazy(() => + z.object({ + commentId: z.number(), + content: z.string(), + authorName: z.string(), + authorId: z.string().nullish(), + createdAt: z.string(), + likeCount: z.number().nullish(), + isLiked: z.boolean().nullish(), + parentCommentId: z.number().nullish(), + replies: z + .array(postCommentSchema) + .nullish() + .transform((v) => v ?? []), + }), +) export const postSchema = z.object({ postId: z.number(), diff --git a/src/types/apis/health.ts b/src/types/apis/health.ts index f7f759b..9deca17 100644 --- a/src/types/apis/health.ts +++ b/src/types/apis/health.ts @@ -1,13 +1,33 @@ import { z } from 'zod' -export const RecordType = ['VACCINATION', 'CHECKUP', 'MEDICATION', 'SYMPTOM', 'OTHER'] as const +/** + * 서버 HealthRecord.RecordType 과 같은 목록. 한쪽에만 있으면 저장이 실패하거나(프런트에만 있을 때) + * 코드가 그대로 찍히고 필터로 찾을 수 없다(서버에만 있을 때). 순서는 입력 칩에 보이는 순서다. + */ +export const RecordType = [ + 'VACCINATION', + 'CHECKUP', + 'ILLNESS', + 'SYMPTOM', + 'MEDICATION', + 'GROWTH', + 'DENTAL', + 'EYE', + 'EMERGENCY', + 'OTHER', +] as const export type RecordType = (typeof RecordType)[number] export const RECORD_TYPE_LABEL: Record = { VACCINATION: '예방접종', CHECKUP: '건강검진', - MEDICATION: '투약', + ILLNESS: '질병', SYMPTOM: '증상', + MEDICATION: '투약', + GROWTH: '성장', + DENTAL: '치과', + EYE: '안과', + EMERGENCY: '응급', OTHER: '기타', }