diff --git a/backend/openapi.json b/backend/openapi.json new file mode 100644 index 00000000..5d9bccbd --- /dev/null +++ b/backend/openapi.json @@ -0,0 +1,3175 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "tags" : [ { + "name" : "Internal: Analysis Triggers", + "description" : "X-Internal-API-Key 필요. e2e 검증/디버그용 강제 트리거. 실제 사용자 흐름은 POST /api/resumes, POST /api/repositories 가 자동 트리거." + }, { + "name" : "Internal: GitHub Token Delegation", + "description" : "X-Internal-API-Key 필요. AI 서버가 레포 분석 시점에 사용자 GitHub 토큰을 짧게 위임받기 위해 호출." + }, { + "name" : "Users", + "description" : "Authenticated user APIs" + }, { + "name" : "Internal: AI Request Logs", + "description" : "X-Internal-API-Key 필요. AI 가 LLM 호출별 토큰/지연시간을 기록." + }, { + "name" : "Voice Answer", + "description" : "음성 답변 업로드 (Phase 2 - 음성 면접)" + }, { + "name" : "Auth", + "description" : "GitHub OAuth login and authentication token APIs" + }, { + "name" : "Documents", + "description" : "분석 문서(이력서/레포 공통) 조회. 상세 조회 시 분석 마크다운의 presigned S3 URL 포함." + }, { + "name" : "Users (Stats)", + "description" : "사용자 면접 통계 — 총/완료 세션 수, 평균 점수, 최근 점수 추이." + }, { + "name" : "Internal: Session Messages", + "description" : "X-Internal-API-Key 필요. RealTime WS 답변 위임." + }, { + "name" : "Sessions", + "description" : "면접 세션 생성·조회·상태 전환. 컨텍스트(이력서/레포 분석 문서) 연결 포함." + }, { + "name" : "Session Feedback", + "description" : "세션 종합 피드백 (US-24)" + }, { + "name" : "Internal: Document Embeddings", + "description" : "X-Internal-API-Key 필요. AI 서버가 분석 결과 청크/임베딩을 pgvector 에 idempotent upsert 할 때 호출." + }, { + "name" : "User Consents", + "description" : "개인정보처리/이용약관/마케팅 동의 제출·이력·철회 (append-only audit)." + }, { + "name" : "Resumes", + "description" : "이력서 업로드/관리. 업로드 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (DOC_STATE) 로 통지됨." + }, { + "name" : "Repositories", + "description" : "GitHub 레포 등록/관리. 등록 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (REPO_STATE) 로 통지됨." + }, { + "name" : "Session Messages", + "description" : "면접 메시지 시퀀스 — 질문/답변 트리." + }, { + "name" : "Internal: Embedding Search", + "description" : "X-Internal-API-Key 필요. AI 서버가 질문/피드백 생성 시 컨텍스트 청크 검색 (pgvector cosine)." + } ], + "paths" : { + "/api/internal/documents/{documentId}/embeddings" : { + "put" : { + "tags" : [ "Internal: Document Embeddings" ], + "summary" : "분석 문서 청크 + 임베딩 upsert", + "description" : "(document_id, chunk_index) 기준 idempotent. 벡터 차원이 dim 과 다르면 400.", + "operationId" : "internalUpsertDocumentEmbeddings", + "parameters" : [ { + "name" : "documentId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "upsert 성공 (응답에 upsert 된 개수 포함)", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + }, + "400" : { + "description" : "임베딩 페이로드 검증 실패 (dim 불일치 등)", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + }, + "404" : { + "description" : "documentId 에 해당하는 analyzed_documents 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UpsertResponse" + } + } + } + } + } + } + }, + "/api/users/me/consents" : { + "get" : { + "tags" : [ "User Consents" ], + "summary" : "내 동의 이력 (최신순)", + "operationId" : "listConsents", + "responses" : { + "200" : { + "description" : "이력 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "User Consents" ], + "summary" : "동의 제출 (새 row 생성)", + "operationId" : "submitConsent", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentSubmitRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "동의 row 생성", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + }, + "400" : { + "description" : "요청 검증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ConsentResponse" + } + } + } + } + } + } + }, + "/api/sessions" : { + "get" : { + "tags" : [ "Sessions" ], + "summary" : "내 세션 히스토리 (US-15)", + "operationId" : "listSessions", + "responses" : { + "200" : { + "description" : "세션 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Sessions" ], + "summary" : "세션 생성 (US-13/14)", + "description" : "mode/jobCategory 필수. contextDocumentIds 로 분석 문서 N개 연결 (US-14). 분석 미완료 문서는 422.", + "operationId" : "createSession", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/SessionCreateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "세션 생성 완료", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "400" : { + "description" : "요청 검증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "지정된 문서 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "분석 미완료 문서를 컨텍스트로 지정", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/stream-token" : { + "post" : { + "tags" : [ "Sessions" ], + "summary" : "세션 채널 SSE/WS 용 리소스 스코프 stream token", + "operationId" : "createSessionStreamToken", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "토큰 발급", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음 / 소유자 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/messages" : { + "get" : { + "tags" : [ "Session Messages" ], + "summary" : "세션 메시지 시퀀스 (US-20)", + "operationId" : "listSessionMessages", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "메시지 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Session Messages" ], + "summary" : "답변 제출 (US-19) + 꼬리질문 자동 트리거", + "description" : "직전 INTERVIEWER 메시지에 대한 답변을 INTERVIEWEE 로 INSERT. commit 후 generate.followup 발행.", + "operationId" : "submitAnswer", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "Idempotency-Key", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/MessageSubmitRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "답변 저장 + 꼬리질문 발행", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "400" : { + "description" : "검증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "422" : { + "description" : "세션이 IN_PROGRESS 아니거나 직전 메시지가 질문 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/messages/voice" : { + "post" : { + "tags" : [ "Voice Answer" ], + "summary" : "음성 답변 업로드 + STT/분석 트리거", + "description" : "multipart/form-data 로 audio 파일 업로드. 응답은 transcribing 상태 메시지. STT 완료 후 SESSION_MESSAGE SSE 로 transcript 가 도착합니다.", + "operationId" : "submitVoiceAnswer", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "Idempotency-Key", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "multipart/form-data" : { + "schema" : { + "type" : "object", + "properties" : { + "audio" : { + "type" : "string", + "format" : "binary" + } + }, + "required" : [ "audio" ] + } + } + } + }, + "responses" : { + "201" : { + "description" : "메시지 INSERT + analyze.voice 발행", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "400" : { + "description" : "파일 형식/크기 오류", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "422" : { + "description" : "세션이 IN_PROGRESS 아니거나 직전 메시지가 질문 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/messages/voice/stream-begin" : { + "post" : { + "tags" : [ "Voice Answer" ], + "summary" : "실시간 음성 답변 시작 (placeholder 생성)", + "description" : "WS 오디오 스트림 전에 호출. placeholder 메시지를 만들고 messageId 를 반환. 이 messageId 를 WS 쿼리로 넘긴다.", + "operationId" : "beginVoiceStream", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "Idempotency-Key", + "in" : "header", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "201" : { + "description" : "placeholder 생성", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/VoiceStreamBeginResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/VoiceStreamBeginResponse" + } + } + } + }, + "422" : { + "description" : "세션 상태/직전 메시지 조건 불만족", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/VoiceStreamBeginResponse" + } + } + } + } + } + } + }, + "/api/resumes" : { + "get" : { + "tags" : [ "Resumes" ], + "summary" : "내 이력서 목록", + "operationId" : "listResumes", + "responses" : { + "200" : { + "description" : "사용자 소유 이력서 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Resumes" ], + "summary" : "이력서 PDF 업로드 + 분석 트리거", + "description" : "multipart/form-data 로 PDF 파일을 받아 S3 저장 → DB row 생성(status=PENDING) → AI 분석 자동 발행. 최대 20MB, application/pdf 만 허용.", + "operationId" : "uploadResume", + "requestBody" : { + "content" : { + "multipart/form-data" : { + "schema" : { + "type" : "object", + "properties" : { + "file" : { + "type" : "string", + "format" : "binary" + } + }, + "required" : [ "file" ] + } + } + } + }, + "responses" : { + "201" : { + "description" : "업로드 + 분석 트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "400" : { + "description" : "빈 파일 / 사이즈 초과 / 비-PDF", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + } + } + }, + "/api/repositories" : { + "get" : { + "tags" : [ "Repositories" ], + "summary" : "등록된 레포 목록", + "operationId" : "listRegisteredRepositories", + "responses" : { + "200" : { + "description" : "사용자가 등록한 레포 목록", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "Repositories" ], + "summary" : "GitHub 레포 등록 + 분석 트리거", + "description" : "사용자의 GitHub 토큰으로 메타를 재확인 후 INSERT. id/fullName 불일치(VALIDATION_ERROR), 비공개 접근 불가(REPO_PRIVATE_NO_ACCESS), 존재하지 않음(REPO_NOT_FOUND), 중복(REPO_ALREADY_REGISTERED) 분기.", + "operationId" : "registerRepository", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/RegisterRepositoryRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "등록 + 분석 트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "400" : { + "description" : "id ↔ fullName 불일치 / 잘못된 요청", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "403" : { + "description" : "비공개 레포 접근 불가", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "404" : { + "description" : "GitHub에 해당 레포 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "409" : { + "description" : "이미 등록된 레포", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + } + }, + "/api/repositories/{repositoryId}/sync" : { + "post" : { + "tags" : [ "Repositories" ], + "summary" : "GitHub 메타 재동기화 (US-08)", + "description" : "GitHub API 로 default_branch/name/url 등을 다시 fetch 해 DB 갱신. last_synced_at 갱신. 분석은 재트리거하지 않음.", + "operationId" : "syncRepository", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "메타 갱신 완료", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "404" : { + "description" : "등록된 레포 또는 GitHub 상 레포 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "403" : { + "description" : "비공개 레포 접근 불가", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "502" : { + "description" : "GitHub API 호출 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + } + }, + "/api/internal/sessions/{sessionId}/messages" : { + "post" : { + "tags" : [ "Internal: Session Messages" ], + "summary" : "RealTime WS 답변 위임 → 답변 INSERT + 꼬리질문 발행", + "operationId" : "internalSubmitAnswer", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/InternalAnswerSubmitRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "답변 저장", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음 / 소유자 불일치", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + }, + "422" : { + "description" : "세션 IN_PROGRESS 아님 또는 직전이 질문 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "/api/internal/embeddings/search" : { + "post" : { + "tags" : [ "Internal: Embedding Search" ], + "summary" : "pgvector cosine topK 검색", + "description" : "queryEmbedding 으로 가장 가까운 청크 topK 반환. documentIds 가 비어 있으면 전체.", + "operationId" : "internalSearchEmbeddings", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/SearchRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "검색 결과", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SearchResponse" + } + } + } + }, + "400" : { + "description" : "queryEmbedding 누락", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SearchResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SearchResponse" + } + } + } + } + } + } + }, + "/api/internal/analyses/resume/{resumeId}" : { + "post" : { + "tags" : [ "Internal: Analysis Triggers" ], + "summary" : "이력서 분석 강제 트리거", + "operationId" : "internalTriggerResumeAnalysis", + "parameters" : [ { + "name" : "resumeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/TriggerRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + } + } + } + }, + "/api/internal/analyses/repository/{repositoryId}" : { + "post" : { + "tags" : [ "Internal: Analysis Triggers" ], + "summary" : "GitHub 레포 분석 강제 트리거", + "operationId" : "internalTriggerRepositoryAnalysis", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/TriggerRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalysisHandle" + } + } + } + } + } + } + }, + "/api/internal/ai-logs" : { + "post" : { + "tags" : [ "Internal: AI Request Logs" ], + "summary" : "AI 요청 로그 기록", + "operationId" : "internalRecordAiRequestLog", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Request" + } + } + }, + "required" : true + }, + "responses" : { + "202" : { + "description" : "기록 큐잉 (fire-and-forget)" + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패" + } + } + } + }, + "/api/auth/stream-token" : { + "post" : { + "tags" : [ "Auth" ], + "summary" : "Create SSE stream token", + "operationId" : "createStreamToken", + "responses" : { + "200" : { + "description" : "Stream token created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + }, + "401" : { + "description" : "Authentication is required", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/StreamTokenResponse" + } + } + } + } + } + } + }, + "/api/auth/refresh" : { + "post" : { + "tags" : [ "Auth" ], + "summary" : "Refresh StackUp access token", + "operationId" : "refreshAccessToken", + "parameters" : [ { + "name" : "refresh_token", + "in" : "cookie", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "Access token refreshed", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RefreshTokenResponse" + } + } + } + }, + "401" : { + "description" : "Refresh token is missing or invalid", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RefreshTokenResponse" + } + } + } + } + } + } + }, + "/api/auth/github" : { + "post" : { + "tags" : [ "Auth" ], + "summary" : "Create GitHub OAuth authorization URL", + "operationId" : "startGithubLogin", + "responses" : { + "200" : { + "description" : "GitHub authorization URL created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubLoginResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}" : { + "get" : { + "tags" : [ "Sessions" ], + "summary" : "세션 상세 (US-16)", + "operationId" : "getSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "세션 상세", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Sessions" ], + "summary" : "세션 soft delete", + "operationId" : "deleteSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "삭제됨" + }, + "401" : { + "description" : "인증 실패" + }, + "404" : { + "description" : "세션 없음" + } + } + }, + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 제목/메모 수정", + "operationId" : "updateSessionMeta", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/SessionUpdateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "갱신 완료", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/start" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 시작 (READY→IN_PROGRESS) (US-17)", + "operationId" : "startSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "시작됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "READY 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/interrupt" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 중단 (IN_PROGRESS→INTERRUPTED) (US-17)", + "operationId" : "interruptSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "중단됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "IN_PROGRESS 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/end" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 종료 (IN_PROGRESS→COMPLETED) (US-17)", + "operationId" : "endSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "종료됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "IN_PROGRESS 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/cancel" : { + "patch" : { + "tags" : [ "Sessions" ], + "summary" : "세션 취소 (READY→CANCELLED)", + "operationId" : "cancelSession", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "취소됨", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "404" : { + "description" : "세션 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + }, + "422" : { + "description" : "READY 아님", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SessionResponse" + } + } + } + } + } + } + }, + "/api/users/me" : { + "get" : { + "tags" : [ "Users" ], + "summary" : "Get current authenticated user profile", + "operationId" : "getCurrentUser", + "responses" : { + "200" : { + "description" : "Current user profile returned", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + } + }, + "401" : { + "description" : "Authentication is required", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + } + }, + "404" : { + "description" : "User not found", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Users" ], + "summary" : "회원 탈퇴 (soft delete + 모든 refresh token revoke)", + "description" : "User row 의 is_deleted=true. 모든 refresh_token revoke. GitHub access token 무효화는 사용자가 GitHub Settings 에서 별도 수행.", + "operationId" : "deleteCurrentUser", + "responses" : { + "204" : { + "description" : "탈퇴 처리됨" + }, + "401" : { + "description" : "인증 실패" + }, + "410" : { + "description" : "이미 탈퇴한 사용자" + } + } + } + }, + "/api/users/me/stats" : { + "get" : { + "tags" : [ "Users (Stats)" ], + "summary" : "내 면접 통계 (US-02)", + "description" : "신규 사용자(데이터 없음)는 카운트 0, 평균 null, recent 빈 배열.", + "operationId" : "getCurrentUserStats", + "responses" : { + "200" : { + "description" : "통계 반환", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserStatsResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserStatsResponse" + } + } + } + } + } + } + }, + "/api/system/ready" : { + "get" : { + "tags" : [ "system-controller" ], + "operationId" : "ready", + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SystemHealthResponse" + } + } + } + } + } + } + }, + "/api/system/live" : { + "get" : { + "tags" : [ "system-controller" ], + "operationId" : "live", + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SystemLiveResponse" + } + } + } + } + } + } + }, + "/api/system/health" : { + "get" : { + "tags" : [ "system-controller" ], + "operationId" : "health", + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/SystemHealthResponse" + } + } + } + } + } + } + }, + "/api/sessions/{sessionId}/feedback" : { + "get" : { + "tags" : [ "Session Feedback" ], + "summary" : "세션 종합 피드백 조회", + "operationId" : "getSessionFeedback", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "피드백", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + }, + "404" : { + "description" : "세션 또는 피드백 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/FeedbackResponse" + } + } + } + } + } + } + }, + "/api/resumes/{resumeId}" : { + "get" : { + "tags" : [ "Resumes" ], + "summary" : "이력서 단건 조회", + "operationId" : "getResume", + "parameters" : [ { + "name" : "resumeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "이력서 메타", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "404" : { + "description" : "이력서 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Resumes" ], + "summary" : "이력서 soft delete", + "operationId" : "deleteResume", + "parameters" : [ { + "name" : "resumeId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "삭제 완료" + }, + "401" : { + "description" : "인증 실패" + }, + "404" : { + "description" : "이력서 없음" + } + } + } + }, + "/api/repositories/{repositoryId}" : { + "get" : { + "tags" : [ "Repositories" ], + "summary" : "등록 레포 단건 조회", + "operationId" : "getRepository", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "레포 메타", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + }, + "404" : { + "description" : "레포 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/RegisteredRepositoryResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "Repositories" ], + "summary" : "등록 레포 soft delete", + "operationId" : "deleteRepository", + "parameters" : [ { + "name" : "repositoryId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "삭제 완료" + }, + "401" : { + "description" : "인증 실패" + }, + "404" : { + "description" : "레포 없음" + } + } + } + }, + "/api/repositories/github" : { + "get" : { + "tags" : [ "Repositories" ], + "summary" : "GitHub 후보 레포 목록 (페이지)", + "description" : "사용자의 GitHub 토큰으로 owner 레포를 updated 순으로 페이지 조회. 각 항목에 alreadyRegistered 플래그.", + "operationId" : "listCandidateRepositories", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 1 + } + }, { + "name" : "perPage", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 30 + } + } ], + "responses" : { + "200" : { + "description" : "GitHub 후보 페이지", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CandidateRepositoryResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CandidateRepositoryResponse" + } + } + } + } + }, + "502" : { + "description" : "GitHub API 호출 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CandidateRepositoryResponse" + } + } + } + } + } + } + } + }, + "/api/internal/users/{userId}/github-token" : { + "get" : { + "tags" : [ "Internal: GitHub Token Delegation" ], + "summary" : "사용자 GitHub access token 평문 반환", + "description" : "GithubTokenCipher 로 복호화한 평문 토큰을 응답. 메모리/로그/이벤트 envelope 등 외부 영속/전송 금지.", + "operationId" : "internalFetchGithubToken", + "parameters" : [ { + "name" : "userId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "토큰 반환", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubTokenResponse" + } + } + } + }, + "401" : { + "description" : "X-Internal-API-Key 인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubTokenResponse" + } + } + } + }, + "404" : { + "description" : "사용자 없음 또는 토큰 미존재", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubTokenResponse" + } + } + } + } + } + } + }, + "/api/documents" : { + "get" : { + "tags" : [ "Documents" ], + "summary" : "내 분석 문서 목록 (filter: resumeId 또는 repositoryId)", + "description" : "filter 미지정 시 사용자 보유 전체. resumeId/repositoryId 둘 다 지정 시 resumeId 가 우선.", + "operationId" : "listAnalyzedDocuments", + "parameters" : [ { + "name" : "resumeId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "repositoryId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "분석 문서 목록 (documentDownloadUrl 은 비어있음)", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + } + } + } + } + }, + "/api/documents/{documentId}" : { + "get" : { + "tags" : [ "Documents" ], + "summary" : "분석 문서 상세 + presigned MD 다운로드 URL", + "description" : "documentDownloadUrl 은 10분 TTL presigned. analysis_status 가 ANALYZED 인 경우에만 의미 있음.", + "operationId" : "getAnalyzedDocument", + "parameters" : [ { + "name" : "documentId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "분석 문서 상세", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + }, + "404" : { + "description" : "분석 문서 없음", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AnalyzedDocumentResponse" + } + } + } + } + } + } + }, + "/api/auth/github/callback" : { + "get" : { + "tags" : [ "Auth" ], + "summary" : "Complete GitHub OAuth login", + "operationId" : "completeGithubLogin", + "parameters" : [ { + "name" : "code", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "state", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "Login completed", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubCallbackResponse" + } + } + } + }, + "401" : { + "description" : "GitHub OAuth login failed", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GithubCallbackResponse" + } + } + } + } + } + } + }, + "/api/users/me/consents/{type}" : { + "delete" : { + "tags" : [ "User Consents" ], + "summary" : "동의 철회 (최신 활성 row 의 revoked_at 갱신)", + "operationId" : "revokeConsent", + "parameters" : [ { + "name" : "type", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string", + "enum" : [ "TOS", "PRIVACY", "MARKETING" ] + } + } ], + "responses" : { + "204" : { + "description" : "철회 처리됨" + }, + "400" : { + "description" : "철회할 활성 동의 없음" + }, + "401" : { + "description" : "인증 실패" + } + } + } + }, + "/api/auth/logout" : { + "delete" : { + "tags" : [ "Auth" ], + "summary" : "Logout and revoke refresh token", + "operationId" : "logout", + "parameters" : [ { + "name" : "refresh_token", + "in" : "cookie", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "204" : { + "description" : "Logged out" + } + } + } + } + }, + "components" : { + "schemas" : { + "ChunkRequest" : { + "type" : "object", + "properties" : { + "chunkIndex" : { + "type" : "integer", + "format" : "int32" + }, + "chunkText" : { + "type" : "string" + }, + "embedding" : { + "type" : "array", + "items" : { + "type" : "number", + "format" : "float" + } + } + } + }, + "UpsertRequest" : { + "type" : "object", + "properties" : { + "model" : { + "type" : "string" + }, + "dim" : { + "type" : "integer", + "format" : "int32" + }, + "chunks" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ChunkRequest" + }, + "minItems" : 1 + } + }, + "required" : [ "chunks" ] + }, + "UpsertResponse" : { + "type" : "object", + "properties" : { + "upserted" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "ConsentSubmitRequest" : { + "type" : "object", + "properties" : { + "consentType" : { + "type" : "string", + "enum" : [ "TOS", "PRIVACY", "MARKETING" ] + }, + "consentVersion" : { + "type" : "string", + "minLength" : 1 + } + }, + "required" : [ "consentType", "consentVersion" ] + }, + "ConsentResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "consentType" : { + "type" : "string", + "enum" : [ "TOS", "PRIVACY", "MARKETING" ] + }, + "consentVersion" : { + "type" : "string" + }, + "agreed" : { + "type" : "boolean" + }, + "agreedAt" : { + "type" : "string", + "format" : "date-time" + }, + "revokedAt" : { + "type" : "string", + "format" : "date-time" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "SessionCreateRequest" : { + "type" : "object", + "properties" : { + "title" : { + "type" : "string", + "maxLength" : 200, + "minLength" : 0 + }, + "memo" : { + "type" : "string", + "maxLength" : 4000, + "minLength" : 0 + }, + "mode" : { + "type" : "string", + "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ] + }, + "jobCategory" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + }, + "maxQuestions" : { + "type" : "integer", + "format" : "int32", + "maximum" : 30, + "minimum" : 2 + }, + "maxDurationMinutes" : { + "type" : "integer", + "format" : "int32" + }, + "contextDocumentIds" : { + "type" : "array", + "items" : { + "type" : "integer", + "format" : "int64" + } + } + }, + "required" : [ "jobCategory", "mode" ] + }, + "SessionResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "title" : { + "type" : "string" + }, + "memo" : { + "type" : "string" + }, + "mode" : { + "type" : "string", + "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ] + }, + "jobCategory" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + }, + "maxQuestions" : { + "type" : "integer", + "format" : "int32" + }, + "maxDurationMinutes" : { + "type" : "integer", + "format" : "int32" + }, + "status" : { + "type" : "string", + "enum" : [ "READY", "IN_PROGRESS", "INTERRUPTED", "COMPLETED", "CANCELLED" ] + }, + "totalQuestionCount" : { + "type" : "integer", + "format" : "int32" + }, + "startedAt" : { + "type" : "string", + "format" : "date-time" + }, + "endedAt" : { + "type" : "string", + "format" : "date-time" + }, + "contextDocumentIds" : { + "type" : "array", + "items" : { + "type" : "integer", + "format" : "int64" + } + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "StreamTokenResponse" : { + "type" : "object", + "properties" : { + "streamToken" : { + "type" : "string" + } + } + }, + "MessageSubmitRequest" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "string", + "maxLength" : 8000, + "minLength" : 0 + } + }, + "required" : [ "content" ] + }, + "MessageResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "sequenceNumber" : { + "type" : "integer", + "format" : "int32" + }, + "role" : { + "type" : "string", + "enum" : [ "INTERVIEWER", "INTERVIEWEE", "SYSTEM" ] + }, + "content" : { + "type" : "string" + }, + "audioFilePath" : { + "type" : "string" + }, + "ttsAudioPath" : { + "type" : "string" + }, + "ttsStatus" : { + "type" : "string", + "enum" : [ "NOT_REQUESTED", "PENDING", "SUCCEEDED", "FAILED" ] + }, + "ttsDurationSec" : { + "type" : "number", + "format" : "double" + }, + "parentMessageId" : { + "type" : "integer", + "format" : "int64" + }, + "status" : { + "type" : "string", + "enum" : [ "CREATED", "COMPLETED", "FAILED" ] + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "VoiceStreamBeginResponse" : { + "type" : "object", + "properties" : { + "messageId" : { + "type" : "integer", + "format" : "int64" + }, + "parentMessageId" : { + "type" : "integer", + "format" : "int64" + }, + "sequenceNumber" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "ResumeResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "originalFilename" : { + "type" : "string" + }, + "filePath" : { + "type" : "string" + }, + "fileType" : { + "type" : "string", + "enum" : [ "PDF" ] + }, + "fileSize" : { + "type" : "integer", + "format" : "int64" + }, + "status" : { + "type" : "string", + "enum" : [ "PENDING", "ANALYZING", "ANALYZED", "FAILED" ] + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "RegisterRepositoryRequest" : { + "type" : "object", + "properties" : { + "githubRepoId" : { + "type" : "integer", + "format" : "int64" + }, + "fullName" : { + "type" : "string", + "minLength" : 1 + } + }, + "required" : [ "fullName", "githubRepoId" ] + }, + "RegisteredRepositoryResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "githubRepoId" : { + "type" : "integer", + "format" : "int64" + }, + "repoName" : { + "type" : "string" + }, + "repoFullName" : { + "type" : "string" + }, + "repoUrl" : { + "type" : "string" + }, + "defaultBranch" : { + "type" : "string" + }, + "status" : { + "type" : "string", + "enum" : [ "PENDING", "ANALYZING", "ANALYZED", "FAILED" ] + }, + "lastSyncedAt" : { + "type" : "string", + "format" : "date-time" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "InternalAnswerSubmitRequest" : { + "type" : "object", + "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + }, + "content" : { + "type" : "string", + "minLength" : 1 + }, + "idempotencyKey" : { + "type" : "string" + } + }, + "required" : [ "content", "userId" ] + }, + "SearchRequest" : { + "type" : "object", + "properties" : { + "queryEmbedding" : { + "type" : "array", + "items" : { + "type" : "number", + "format" : "float" + } + }, + "documentIds" : { + "type" : "array", + "items" : { + "type" : "integer", + "format" : "int64" + } + }, + "topK" : { + "type" : "integer", + "format" : "int32" + } + }, + "required" : [ "queryEmbedding" ] + }, + "SearchResponse" : { + "type" : "object", + "properties" : { + "hits" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SearchResponseHit" + } + } + } + }, + "SearchResponseHit" : { + "type" : "object", + "properties" : { + "documentId" : { + "type" : "integer", + "format" : "int64" + }, + "chunkIndex" : { + "type" : "integer", + "format" : "int32" + }, + "chunkText" : { + "type" : "string" + }, + "distance" : { + "type" : "number", + "format" : "double" + } + } + }, + "TriggerRequest" : { + "type" : "object", + "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + } + }, + "required" : [ "userId" ] + }, + "AnalysisHandle" : { + "type" : "object", + "properties" : { + "analyzedDocumentId" : { + "type" : "integer", + "format" : "int64" + }, + "resumeId" : { + "type" : "integer", + "format" : "int64" + }, + "repositoryId" : { + "type" : "integer", + "format" : "int64" + } + } + }, + "Request" : { + "type" : "object", + "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + }, + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "requestType" : { + "type" : "string", + "minLength" : 1 + }, + "modelName" : { + "type" : "string" + }, + "inputTokens" : { + "type" : "integer", + "format" : "int32" + }, + "outputTokens" : { + "type" : "integer", + "format" : "int32" + }, + "latencyMs" : { + "type" : "integer", + "format" : "int32" + }, + "status" : { + "type" : "string" + }, + "errorMessage" : { + "type" : "string" + } + }, + "required" : [ "requestType", "status" ] + }, + "RefreshTokenResponse" : { + "type" : "object", + "properties" : { + "accessToken" : { + "type" : "string", + "description" : "New StackUp JWT access token", + "example" : "new-our-jwt-access-token" + }, + "tokenType" : { + "type" : "string", + "description" : "Access token type", + "example" : "Bearer" + }, + "expiresIn" : { + "type" : "integer", + "format" : "int64", + "description" : "Access token TTL in seconds", + "example" : 900 + } + } + }, + "GithubLoginResponse" : { + "type" : "object", + "properties" : { + "authorizationUrl" : { + "type" : "string", + "description" : "GitHub OAuth authorization URL", + "example" : "https://github.com/login/oauth/authorize?client_id=...&redirect_uri=...&scope=read:user%20user:email%20repo&state=generated-state" + }, + "state" : { + "type" : "string", + "description" : "CSRF protection state value to be verified on callback", + "example" : "generated-state" + } + } + }, + "SessionUpdateRequest" : { + "type" : "object", + "properties" : { + "title" : { + "type" : "string", + "maxLength" : 200, + "minLength" : 0 + }, + "memo" : { + "type" : "string", + "maxLength" : 4000, + "minLength" : 0 + } + } + }, + "UserProfileResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64", + "description" : "StackUp user id", + "example" : 1 + }, + "githubId" : { + "type" : "integer", + "format" : "int64", + "description" : "Stable GitHub user id", + "example" : 123456 + }, + "githubUsername" : { + "type" : "string", + "description" : "GitHub username at login time", + "example" : "octocat" + }, + "email" : { + "type" : [ "string", "null" ], + "description" : "Primary GitHub email when available", + "example" : "octocat@example.com" + }, + "avatarUrl" : { + "type" : [ "string", "null" ], + "description" : "GitHub avatar URL", + "example" : "https://avatars.githubusercontent.com/u/123456" + } + } + }, + "AverageScores" : { + "type" : "object", + "properties" : { + "overall" : { + "type" : "number", + "format" : "double" + }, + "technical" : { + "type" : "number", + "format" : "double" + }, + "logic" : { + "type" : "number", + "format" : "double" + }, + "communication" : { + "type" : "number", + "format" : "double" + } + } + }, + "RecentScore" : { + "type" : "object", + "properties" : { + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "overall" : { + "type" : "number", + "format" : "double" + }, + "technical" : { + "type" : "number", + "format" : "double" + }, + "logic" : { + "type" : "number", + "format" : "double" + }, + "communication" : { + "type" : "number", + "format" : "double" + }, + "endedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "UserStatsResponse" : { + "type" : "object", + "properties" : { + "totalSessionCount" : { + "type" : "integer", + "format" : "int64" + }, + "completedSessionCount" : { + "type" : "integer", + "format" : "int64" + }, + "averages" : { + "$ref" : "#/components/schemas/AverageScores" + }, + "recent" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RecentScore" + } + } + } + }, + "ComponentHealthResponse" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "status" : { + "type" : "string" + }, + "details" : { + "type" : "object", + "additionalProperties" : { } + } + } + }, + "SystemHealthResponse" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string" + }, + "timestamp" : { + "type" : "string", + "format" : "date-time" + }, + "components" : { + "type" : "object", + "additionalProperties" : { + "$ref" : "#/components/schemas/ComponentHealthResponse" + } + } + } + }, + "SystemLiveResponse" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string" + }, + "timestamp" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "FeedbackResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "sessionId" : { + "type" : "integer", + "format" : "int64" + }, + "overallScore" : { + "type" : "number", + "format" : "double" + }, + "technicalAccuracy" : { + "type" : "number", + "format" : "double" + }, + "logicScore" : { + "type" : "number", + "format" : "double" + }, + "communicationScore" : { + "type" : "number", + "format" : "double" + }, + "strengthsSummary" : { + "type" : "string" + }, + "weaknessesSummary" : { + "type" : "string" + }, + "improvementKeywords" : { + "type" : "array", + "description" : "Improvement keywords returned by AI. The current contract is a string list.", + "items" : { + "type" : "string" + } + }, + "reportFilePath" : { + "type" : "string", + "description" : "Stored report path when AI generates a detailed learning guide/report." + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "CandidateRepositoryResponse" : { + "type" : "object", + "properties" : { + "githubRepoId" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + }, + "fullName" : { + "type" : "string" + }, + "htmlUrl" : { + "type" : "string" + }, + "defaultBranch" : { + "type" : "string" + }, + "isPrivate" : { + "type" : "boolean" + }, + "alreadyRegistered" : { + "type" : "boolean" + } + } + }, + "GithubTokenResponse" : { + "type" : "object", + "properties" : { + "accessToken" : { + "type" : "string" + } + } + }, + "AnalyzedDocumentResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "sourceType" : { + "type" : "string" + }, + "sourceId" : { + "type" : "integer", + "format" : "int64" + }, + "documentPath" : { + "type" : "string" + }, + "documentDownloadUrl" : { + "type" : "string", + "format" : "uri" + }, + "summary" : { + "type" : "string" + }, + "techStack" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "embeddingChunkCount" : { + "type" : "integer", + "format" : "int32" + }, + "analysisStatus" : { + "type" : "string", + "enum" : [ "PROCESSING", "ANALYZED", "FAILED" ] + }, + "errorCode" : { + "type" : "string" + }, + "errorMessage" : { + "type" : "string" + }, + "status" : { + "type" : "string", + "enum" : [ "ACTIVE", "ARCHIVED" ] + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "AuthUserResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64", + "description" : "StackUp user id", + "example" : 1 + }, + "githubId" : { + "type" : "integer", + "format" : "int64", + "description" : "Stable GitHub user id", + "example" : 123456 + }, + "githubUsername" : { + "type" : "string", + "description" : "GitHub username at login time", + "example" : "octocat" + }, + "email" : { + "type" : [ "string", "null" ], + "description" : "Primary GitHub email when available", + "example" : "octocat@example.com" + }, + "avatarUrl" : { + "type" : [ "string", "null" ], + "description" : "GitHub avatar URL", + "example" : "https://avatars.githubusercontent.com/u/123456" + } + } + }, + "GithubCallbackResponse" : { + "type" : "object", + "properties" : { + "accessToken" : { + "type" : "string", + "description" : "StackUp JWT access token", + "example" : "our-jwt-access-token" + }, + "tokenType" : { + "type" : "string", + "description" : "Access token type", + "example" : "Bearer" + }, + "expiresIn" : { + "type" : "integer", + "format" : "int64", + "description" : "Access token TTL in seconds", + "example" : 900 + }, + "user" : { + "$ref" : "#/components/schemas/AuthUserResponse", + "description" : "Authenticated user profile" + }, + "isNewUser" : { + "type" : "boolean", + "description" : "Whether a new user was created during this login", + "example" : false + } + } + } + } + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java new file mode 100644 index 00000000..4c7f1dc5 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/openapi/OpenApiSpecExportTest.java @@ -0,0 +1,81 @@ +package com.stackup.stackup.common.openapi; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stackup.stackup.auth.domain.OAuthStateRepository; +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.log.ai.domain.AiRequestLogRepository; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionFeedbackRepository; +import com.stackup.stackup.user.domain.UserRepository; +import com.stackup.stackup.user.domain.consent.UserConsentRepository; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * OpenAPI 스펙을 정적 파일(backend/openapi.json)로 산출하는 테스트. + * + *

프론트엔드는 이 커밋된 파일에서 openapi-typescript 로 타입을 생성하므로, 로컬 백엔드 기동 없이도 타입 안정성을 얻는다. 백엔드 + * 계약을 바꾸면 이 테스트를 다시 실행해 openapi.json 을 갱신·커밋한다. (CI 에서 "커밋된 스펙이 최신인지" 검증하는 가드로 확장 + * 가능 — 생성 결과와 커밋본을 비교.) + * + *

test 프로파일은 application-test.yml 의 더미 secret 으로 전체 컨텍스트를 로드하고, 모든 repository 는 + * {@link StackupApplicationTests} 와 동일하게 mock 처리해 실 DB 없이 springdoc 의 매핑·스키마만으로 스펙을 + * 렌더한다. + */ +@SpringBootTest +class OpenApiSpecExportTest { + + private static final Path OUTPUT = Path.of("openapi.json"); + + @Autowired private WebApplicationContext webApplicationContext; + + @MockitoBean private OAuthStateRepository oauthStateRepository; + @MockitoBean private RefreshTokenRepository refreshTokenRepository; + @MockitoBean private UserRepository userRepository; + @MockitoBean private ResumeRepository resumeRepository; + @MockitoBean private GithubRepositoryRepository githubRepositoryRepository; + @MockitoBean private AnalyzedDocumentRepository analyzedDocumentRepository; + @MockitoBean private ProcessedMessageRepository processedMessageRepository; + @MockitoBean private DocumentEmbeddingRepository documentEmbeddingRepository; + @MockitoBean private UserConsentRepository userConsentRepository; + @MockitoBean private InterviewSessionRepository interviewSessionRepository; + @MockitoBean private InterviewMessageRepository interviewMessageRepository; + @MockitoBean private SessionContextRepository sessionContextRepository; + @MockitoBean private SessionFeedbackRepository sessionFeedbackRepository; + @MockitoBean private MessageVoiceAnalysisRepository messageVoiceAnalysisRepository; + @MockitoBean private AiRequestLogRepository aiRequestLogRepository; + + @Test + void exportOpenApiSpec() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + MvcResult result = + mockMvc.perform(get("/api/v3/api-docs")).andExpect(status().isOk()).andReturn(); + + byte[] raw = result.getResponse().getContentAsByteArray(); + ObjectMapper objectMapper = new ObjectMapper(); + Object tree = objectMapper.readValue(raw, Object.class); + String pretty = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tree); + + Files.writeString(OUTPUT, pretty + System.lineSeparator(), StandardCharsets.UTF_8); + } +} diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 714afe32..dae8aba2 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -15,13 +15,15 @@ | Language | TypeScript | 5.9.x | | Lint | ESLint 9 (flat config) + Prettier | | | Module | ESM (`"type": "module"`) | | +| Router | React Router | 7.x (도입) | +| Server State | TanStack Query | 5.x (도입) | +| Styling | Tailwind CSS v4 (`@theme` 토큰) | 4.x (도입) | +| API 타입 | openapi-typescript → `shared/api/generated.ts` | (도입, §7.1) | +| 테스트 | Vitest + Testing Library (jsdom) | (도입) | -### 미정 (Phase 1 도입 시점에 결정) -- Router: 후보 — React Router v7 / TanStack Router -- Server State: TanStack Query -- Form: React Hook Form + Zod -- Styling: CSS Variables 기반 + Tailwind 또는 vanilla-extract -- 테스트: Vitest + Testing Library + Playwright(E2E) +### 미정 (도입 시점에 결정) +- Form: React Hook Form + Zod (현재 controlled `useState` 검증으로 충분 — 폼 복잡도 증가 시 도입) +- E2E: Playwright > 신규 의존성 추가 시 [`/docs/coding-conventions.md §6`](../docs/coding-conventions.md) 절차를 따르고 본 표를 갱신. @@ -274,8 +276,9 @@ npm run lint # ESLint --- -## 16. 현재 상태 메모 (2026-04 기준) +## 16. 현재 상태 메모 (2026-06 기준) -- `App.tsx`는 Vite 기본 데모 상태 → 첫 페이지(Login) 구현 시 교체 예정 -- 라이브러리 결정 전 (라우터, server state, styling) → 첫 PR 시 의사결정 + 본 문서 §1 갱신 -- 디자인 시스템 토큰 파일 미생성 → 디자인 시스템 첫 적용 PR에서 `app/styles/tokens.css` 생성 +- 라이브러리 결정 완료: React Router v7, TanStack Query v5, Tailwind v4, Vitest+Testing Library (§1). +- 디자인 토큰 `app/styles/tokens.css` 생성됨. `shared/ui` 프리미티브: StatusBadge, Button, Spinner, TextArea, RadioCardGroup, Stepper. +- **텍스트 면접 세션 구현됨**: `/sessions/new`(생성 설정), `/sessions/:id`(라이브 진행). `features/interview` + `domain/session`. 라이브는 WS(`useInterviewSocket`) 알림 + `GET messages` 쿼리 본문 소유 + 낙관적 답변 (`useLiveInterview`). TTS 재생·음성 답변(RT3)은 범위 밖. +- **OpenAPI 타입 파이프라인**: 백엔드가 `OpenApiSpecExportTest`로 `backend/openapi.json` 산출·커밋 → 프론트 `npm run openapi`(= `openapi-typescript ../backend/openapi.json …`)로 `shared/api/generated.ts` 생성. 프론트는 백엔드 런타임에 무의존. 계약 변경 시 백엔드 테스트 재실행으로 갱신. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43742b6d..96977803 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -27,12 +30,73 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.1.1", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.1", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^4.1.8" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -225,6 +289,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -273,6 +347,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -461,6 +688,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -583,6 +828,106 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -830,6 +1175,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -1113,6 +1465,96 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1123,6 +1565,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1488,9 +1956,122 @@ } } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", @@ -1540,6 +2121,27 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1563,6 +2165,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1601,6 +2223,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -1690,6 +2322,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1707,6 +2349,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1727,6 +2376,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1781,6 +2437,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1788,6 +2465,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1805,6 +2496,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1821,6 +2519,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1830,6 +2538,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1864,6 +2580,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1882,6 +2611,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2122,6 +2858,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2132,6 +2878,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2437,6 +3193,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2487,6 +3256,29 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2510,6 +3302,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2526,6 +3325,16 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2546,6 +3355,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2899,6 +3759,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2917,6 +3788,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2938,6 +3816,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2989,6 +3877,51 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3052,6 +3985,37 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3072,6 +4036,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3090,6 +4061,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -3144,6 +4125,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -3184,6 +4195,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", @@ -3222,6 +4241,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3271,6 +4314,19 @@ "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3316,6 +4372,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3325,6 +4388,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3351,6 +4441,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -3370,6 +4467,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3386,6 +4500,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3419,6 +4589,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3457,6 +4640,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", + "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3505,6 +4698,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", @@ -3582,6 +4782,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3598,6 +4936,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3608,6 +4963,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3615,6 +4987,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 532c212b..0fd8c5c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "openapi": "openapi-typescript ../backend/openapi.json -o src/shared/api/generated.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tailwindcss/vite": "^4.3.0", @@ -20,6 +23,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -29,9 +35,12 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^29.1.1", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.1", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^4.1.8" } } diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx index ee278444..a7008443 100644 --- a/frontend/src/app/router/index.tsx +++ b/frontend/src/app/router/index.tsx @@ -4,6 +4,9 @@ import HomePage from '@/pages/Home' import LoginPage from '@/pages/Login' import AuthCallbackPage from '@/pages/AuthCallback' import WorkspacePage from '@/pages/Workspace' +import InterviewSetupPage from '@/pages/InterviewSetup' +import InterviewSessionPage from '@/pages/InterviewSession' +import SessionFeedbackPage from '@/pages/SessionFeedback' export const router = createBrowserRouter([ { path: '/', element: }, @@ -17,6 +20,30 @@ export const router = createBrowserRouter([ ), }, + { + path: '/sessions/new', + element: ( + + + + ), + }, + { + path: '/sessions/:id', + element: ( + + + + ), + }, + { + path: '/sessions/:id/feedback', + element: ( + + + + ), + }, { path: '/design-system/*', lazy: async () => { diff --git a/frontend/src/domain/session/index.ts b/frontend/src/domain/session/index.ts index e69de29b..bd0488de 100644 --- a/frontend/src/domain/session/index.ts +++ b/frontend/src/domain/session/index.ts @@ -0,0 +1,11 @@ +export type { + Session, + Message, + SessionCreateRequest, + SessionStatus, + SessionMode, + JobCategory, + MessageRole, +} from './model/types' +export { isQuestion, isAnswer, currentTurn, canSubmitAnswer, sessionProgress } from './lib/turn' +export type { Turn } from './lib/turn' diff --git a/frontend/src/domain/session/lib/turn.test.ts b/frontend/src/domain/session/lib/turn.test.ts new file mode 100644 index 00000000..95b86db2 --- /dev/null +++ b/frontend/src/domain/session/lib/turn.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import type { Message, Session } from '../model/types' +import { currentTurn, canSubmitAnswer, sessionProgress, isQuestion, isAnswer } from './turn' + +const msg = (role: 'INTERVIEWER' | 'INTERVIEWEE', content = '내용'): Message => + ({ role, content } as Message) +const session = (over: Partial): Session => over as Session + +describe('currentTurn', () => { + it('마지막이 내용 있는 면접관 질문이면 답변 차례', () => { + expect(currentTurn([msg('INTERVIEWER')])).toBe('AWAITING_ANSWER') + }) + it('마지막이 답변이면 질문 대기', () => { + expect(currentTurn([msg('INTERVIEWER'), msg('INTERVIEWEE')])).toBe('WAITING_FOR_QUESTION') + }) + it('비어 있으면 질문 대기', () => { + expect(currentTurn([])).toBe('WAITING_FOR_QUESTION') + }) + it('내용 없는 질문(생성 중)이면 질문 대기', () => { + expect(currentTurn([msg('INTERVIEWER', '')])).toBe('WAITING_FOR_QUESTION') + }) +}) + +describe('canSubmitAnswer', () => { + it('IN_PROGRESS이고 답변 차례면 true', () => { + expect(canSubmitAnswer(session({ status: 'IN_PROGRESS' }), [msg('INTERVIEWER')])).toBe(true) + }) + it('세션이 진행중이 아니면 false', () => { + expect(canSubmitAnswer(session({ status: 'READY' }), [msg('INTERVIEWER')])).toBe(false) + }) +}) + +describe('sessionProgress', () => { + it('현재/최대/비율을 계산', () => { + expect(sessionProgress(session({ totalQuestionCount: 2, maxQuestions: 5 }))).toEqual({ + current: 2, max: 5, ratio: 0.4, + }) + }) + it('max가 0이면 ratio 0', () => { + expect(sessionProgress(session({ totalQuestionCount: 0, maxQuestions: 0 })).ratio).toBe(0) + }) +}) + +describe('isQuestion/isAnswer', () => { + it('역할을 판별', () => { + expect(isQuestion(msg('INTERVIEWER'))).toBe(true) + expect(isAnswer(msg('INTERVIEWEE'))).toBe(true) + }) +}) diff --git a/frontend/src/domain/session/lib/turn.ts b/frontend/src/domain/session/lib/turn.ts new file mode 100644 index 00000000..f5e340f6 --- /dev/null +++ b/frontend/src/domain/session/lib/turn.ts @@ -0,0 +1,31 @@ +import type { Message, Session } from '../model/types' + +export type Turn = 'AWAITING_ANSWER' | 'WAITING_FOR_QUESTION' + +export function isQuestion(message: Message): boolean { + return message.role === 'INTERVIEWER' +} + +export function isAnswer(message: Message): boolean { + return message.role === 'INTERVIEWEE' +} + +export function currentTurn(messages: Message[]): Turn { + const last = messages[messages.length - 1] + // 마지막이 '내용이 있는' 면접관 질문일 때만 답변 차례. 질문 생성 중(빈 content) + // 메시지가 먼저 도착해도 컴포저가 섣불리 활성화되지 않도록 content 유무로 가드한다. + // (질문 status 는 CREATED 로 들어오므로 status 기준으로 거르면 안 된다.) + const askable = last?.role === 'INTERVIEWER' && (last.content ?? '').trim().length > 0 + return askable ? 'AWAITING_ANSWER' : 'WAITING_FOR_QUESTION' +} + +export function canSubmitAnswer(session: Session, messages: Message[]): boolean { + return session.status === 'IN_PROGRESS' && currentTurn(messages) === 'AWAITING_ANSWER' +} + +export function sessionProgress(session: Session): { current: number; max: number; ratio: number } { + const max = session.maxQuestions ?? 0 + const current = session.totalQuestionCount ?? 0 + const ratio = max > 0 ? Math.min(current / max, 1) : 0 + return { current, max, ratio } +} diff --git a/frontend/src/domain/session/model/types.ts b/frontend/src/domain/session/model/types.ts new file mode 100644 index 00000000..552ca3c5 --- /dev/null +++ b/frontend/src/domain/session/model/types.ts @@ -0,0 +1,12 @@ +import type { components } from '@/shared/api/generated' + +type Schemas = components['schemas'] + +export type Session = Schemas['SessionResponse'] +export type Message = Schemas['MessageResponse'] +export type SessionCreateRequest = Schemas['SessionCreateRequest'] + +export type SessionStatus = NonNullable +export type SessionMode = NonNullable +export type JobCategory = NonNullable +export type MessageRole = NonNullable diff --git a/frontend/src/features/interview/api/messageApi.ts b/frontend/src/features/interview/api/messageApi.ts new file mode 100644 index 00000000..3a32cd95 --- /dev/null +++ b/frontend/src/features/interview/api/messageApi.ts @@ -0,0 +1,8 @@ +import { apiClient } from '@/shared/api' +import type { components } from '@/shared/api/generated' + +type S = components['schemas'] + +export async function listMessages(sessionId: number): Promise { + return (await apiClient.get(`/api/sessions/${sessionId}/messages`)).data +} diff --git a/frontend/src/features/interview/api/sessionApi.ts b/frontend/src/features/interview/api/sessionApi.ts new file mode 100644 index 00000000..2d405e29 --- /dev/null +++ b/frontend/src/features/interview/api/sessionApi.ts @@ -0,0 +1,24 @@ +import { apiClient } from '@/shared/api' +import type { components } from '@/shared/api/generated' + +type S = components['schemas'] + +export async function createSession(body: S['SessionCreateRequest']): Promise { + return (await apiClient.post('/api/sessions', body)).data +} + +export async function getSession(id: number): Promise { + return (await apiClient.get(`/api/sessions/${id}`)).data +} + +export async function startSession(id: number): Promise { + return (await apiClient.patch(`/api/sessions/${id}/start`)).data +} + +export async function endSession(id: number): Promise { + return (await apiClient.patch(`/api/sessions/${id}/end`)).data +} + +export async function interruptSession(id: number): Promise { + return (await apiClient.patch(`/api/sessions/${id}/interrupt`)).data +} diff --git a/frontend/src/features/interview/index.ts b/frontend/src/features/interview/index.ts index e69de29b..ecc69d87 100644 --- a/frontend/src/features/interview/index.ts +++ b/frontend/src/features/interview/index.ts @@ -0,0 +1,5 @@ +export { LiveInterview } from './ui/live/LiveInterview' +export { InterviewSetupForm } from './ui/setup/InterviewSetupForm' +export type { DocOption } from './ui/setup/ContextDocumentPicker' +export { useCreateSession } from './model/useCreateSession' +export { useLiveInterview } from './model/useLiveInterview' diff --git a/frontend/src/features/interview/model/interviewEvent.test.ts b/frontend/src/features/interview/model/interviewEvent.test.ts new file mode 100644 index 00000000..e643ff7d --- /dev/null +++ b/frontend/src/features/interview/model/interviewEvent.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { interviewEventAction } from './interviewEvent' + +describe('interviewEventAction', () => { + it('session.message → 메시지 refetch', () => { + expect(interviewEventAction('session.message')).toEqual({ kind: 'refetch-messages' }) + }) + it('session.state → 세션 refetch', () => { + expect(interviewEventAction('session.state')).toEqual({ kind: 'refetch-session' }) + }) + it('feedback.ready → 피드백 리다이렉트', () => { + expect(interviewEventAction('feedback.ready')).toEqual({ kind: 'redirect-feedback' }) + }) + it('알 수 없는 이벤트는 무시', () => { + expect(interviewEventAction('whatever')).toEqual({ kind: 'ignore' }) + }) +}) diff --git a/frontend/src/features/interview/model/interviewEvent.ts b/frontend/src/features/interview/model/interviewEvent.ts new file mode 100644 index 00000000..7e516e54 --- /dev/null +++ b/frontend/src/features/interview/model/interviewEvent.ts @@ -0,0 +1,18 @@ +export type InterviewAction = + | { kind: 'refetch-messages' } + | { kind: 'refetch-session' } + | { kind: 'redirect-feedback' } + | { kind: 'ignore' } + +export function interviewEventAction(event: string): InterviewAction { + switch (event) { + case 'session.message': + return { kind: 'refetch-messages' } + case 'session.state': + return { kind: 'refetch-session' } + case 'feedback.ready': + return { kind: 'redirect-feedback' } + default: + return { kind: 'ignore' } + } +} diff --git a/frontend/src/features/interview/model/optimistic.test.ts b/frontend/src/features/interview/model/optimistic.test.ts new file mode 100644 index 00000000..57fdecd0 --- /dev/null +++ b/frontend/src/features/interview/model/optimistic.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import type { Message } from '@/domain/session' +import { pendingAnswers, toOptimisticMessage } from './optimistic' + +const answerMsg = (content: string): Message => ({ role: 'INTERVIEWEE', content } as Message) + +describe('pendingAnswers', () => { + it('서버에 같은 내용 답변이 없으면 유지', () => { + const result = pendingAnswers([{ tempId: 't1', content: '내 답변' }], []) + expect(result).toHaveLength(1) + }) + it('서버에 같은 내용 답변이 있으면 제거', () => { + const result = pendingAnswers([{ tempId: 't1', content: '내 답변' }], [answerMsg('내 답변')]) + expect(result).toHaveLength(0) + }) + it('동일 내용 낙관적 2개 vs 서버 1개 → 1:1 소진으로 1개만 남는다', () => { + const result = pendingAnswers( + [ + { tempId: 't1', content: '네' }, + { tempId: 't2', content: '네' }, + ], + [answerMsg('네')], + ) + expect(result).toHaveLength(1) + expect(result[0].tempId).toBe('t2') + }) +}) + +describe('toOptimisticMessage', () => { + it('INTERVIEWEE 메시지로 변환', () => { + const m = toOptimisticMessage({ tempId: 't1', content: '안녕' }) + expect(m.role).toBe('INTERVIEWEE') + expect(m.content).toBe('안녕') + }) +}) diff --git a/frontend/src/features/interview/model/optimistic.ts b/frontend/src/features/interview/model/optimistic.ts new file mode 100644 index 00000000..b102017f --- /dev/null +++ b/frontend/src/features/interview/model/optimistic.ts @@ -0,0 +1,32 @@ +import type { Message } from '@/domain/session' + +export type OptimisticAnswer = { tempId: string; content: string } + +// 서버 messages 에 같은 내용의 INTERVIEWEE 답변이 반영된 만큼만 낙관적 항목을 제거한다. +// content 가 같은 답변이 여러 번 제출돼도 서버 메시지 1개당 낙관적 1개씩만 소진(1:1)되도록 +// 카운트로 매칭한다 — 동일 내용 답변이 한꺼번에 사라지는 문제 방지. +export function pendingAnswers( + optimistic: OptimisticAnswer[], + serverMessages: Message[], +): OptimisticAnswer[] { + const remaining = new Map() + for (const m of serverMessages) { + if (m.role === 'INTERVIEWEE' && typeof m.content === 'string') { + remaining.set(m.content, (remaining.get(m.content) ?? 0) + 1) + } + } + const pending: OptimisticAnswer[] = [] + for (const o of optimistic) { + const count = remaining.get(o.content) ?? 0 + if (count > 0) { + remaining.set(o.content, count - 1) // 서버 메시지 하나로 소진 + } else { + pending.push(o) + } + } + return pending +} + +export function toOptimisticMessage(answer: OptimisticAnswer): Message { + return { role: 'INTERVIEWEE', content: answer.content, status: 'COMPLETED' } as Message +} diff --git a/frontend/src/features/interview/model/useCreateSession.ts b/frontend/src/features/interview/model/useCreateSession.ts new file mode 100644 index 00000000..e8370a91 --- /dev/null +++ b/frontend/src/features/interview/model/useCreateSession.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createSession } from '../api/sessionApi' +import { sessionKeys } from './useSession' + +export function useCreateSession() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: createSession, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: sessionKeys.all }) + }, + }) +} diff --git a/frontend/src/features/interview/model/useLiveInterview.ts b/frontend/src/features/interview/model/useLiveInterview.ts new file mode 100644 index 00000000..9c635b00 --- /dev/null +++ b/frontend/src/features/interview/model/useLiveInterview.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { currentTurn } from '@/domain/session' +import type { Message } from '@/domain/session' +import { sessionKeys, useSession } from './useSession' +import { messageKeys, useSessionMessages } from './useSessionMessages' +import { useSessionLifecycle } from './useSessionLifecycle' +import { useInterviewSocket } from './useInterviewSocket' +import { interviewEventAction } from './interviewEvent' +import { pendingAnswers, toOptimisticMessage } from './optimistic' +import type { OptimisticAnswer } from './optimistic' + +export type ThreadItem = Message & { key: string } +export type ConnectionStatus = 'connecting' | 'open' | 'closed' + +export function useLiveInterview(sessionId: number) { + const navigate = useNavigate() + const queryClient = useQueryClient() + const sessionQuery = useSession(sessionId) + const status = sessionQuery.data?.status + const messagesQuery = useSessionMessages(sessionId, status === 'IN_PROGRESS' || status === 'COMPLETED') + const { end } = useSessionLifecycle(sessionId) + + const [optimistic, setOptimistic] = useState([]) + const [connection, setConnection] = useState('connecting') + + // 서버가 sequenceNumber asc 로 주지만, 순서를 코드에서 명시적으로 보장한다. + const serverMessages = [...(messagesQuery.data ?? [])].sort( + (a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0), + ) + const pending = pendingAnswers(optimistic, serverMessages) + const items: ThreadItem[] = [ + ...serverMessages.map((m) => ({ ...m, key: `m-${m.id}` })), + ...pending.map((o) => ({ ...toOptimisticMessage(o), key: `opt-${o.tempId}` })), + ] + + // 서버에 반영된 낙관적 답변은 상태에서 제거(세션 내내 누적 방지). + useEffect(() => { + setOptimistic((prev) => { + const next = pendingAnswers(prev, messagesQuery.data ?? []) + return next.length === prev.length ? prev : next + }) + }, [messagesQuery.data]) + + const { submitAnswer: socketSubmit } = useInterviewSocket({ + sessionId, + enabled: status === 'IN_PROGRESS', + onStatusChange: setConnection, + onEvent: (frame) => { + const action = interviewEventAction(frame.event) + if (action.kind === 'refetch-messages') { + void queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) }) + } else if (action.kind === 'refetch-session') { + void queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) }) + } else if (action.kind === 'redirect-feedback') { + navigate(`/sessions/${sessionId}/feedback`) + } + }, + }) + + const submitAnswer = useCallback( + (content: string) => { + const tempId = crypto.randomUUID() + setOptimistic((prev) => [...prev, { tempId, content }]) + socketSubmit(content, tempId) + }, + [socketSubmit], + ) + + return { + session: sessionQuery.data, + status, + items, + turn: currentTurn(items), + connection, + submitAnswer, + endSession: () => end.mutate(), + isLoading: sessionQuery.isLoading, + } +} diff --git a/frontend/src/features/interview/model/useSession.ts b/frontend/src/features/interview/model/useSession.ts new file mode 100644 index 00000000..a8595ca3 --- /dev/null +++ b/frontend/src/features/interview/model/useSession.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import { getSession } from '../api/sessionApi' + +export const sessionKeys = { + all: ['sessions'] as const, + detail: (id: number) => [...sessionKeys.all, 'detail', id] as const, +} + +export function useSession(sessionId: number) { + return useQuery({ + queryKey: sessionKeys.detail(sessionId), + queryFn: () => getSession(sessionId), + }) +} diff --git a/frontend/src/features/interview/model/useSessionLifecycle.ts b/frontend/src/features/interview/model/useSessionLifecycle.ts new file mode 100644 index 00000000..00d0e91d --- /dev/null +++ b/frontend/src/features/interview/model/useSessionLifecycle.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { endSession, interruptSession, startSession } from '../api/sessionApi' +import { sessionKeys } from './useSession' + +export function useSessionLifecycle(sessionId: number) { + const queryClient = useQueryClient() + const invalidate = () => + queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) }) + + const start = useMutation({ mutationFn: () => startSession(sessionId), onSuccess: invalidate }) + const end = useMutation({ mutationFn: () => endSession(sessionId), onSuccess: invalidate }) + const interrupt = useMutation({ mutationFn: () => interruptSession(sessionId), onSuccess: invalidate }) + + return { start, end, interrupt } +} diff --git a/frontend/src/features/interview/model/useSessionMessages.ts b/frontend/src/features/interview/model/useSessionMessages.ts new file mode 100644 index 00000000..e85343a9 --- /dev/null +++ b/frontend/src/features/interview/model/useSessionMessages.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query' +import { listMessages } from '../api/messageApi' + +export const messageKeys = { + all: ['messages'] as const, + list: (sessionId: number) => [...messageKeys.all, sessionId] as const, +} + +export function useSessionMessages(sessionId: number, enabled = true) { + return useQuery({ + queryKey: messageKeys.list(sessionId), + queryFn: () => listMessages(sessionId), + enabled, + }) +} diff --git a/frontend/src/features/interview/ui/live/AnswerBubble.tsx b/frontend/src/features/interview/ui/live/AnswerBubble.tsx new file mode 100644 index 00000000..e0a49966 --- /dev/null +++ b/frontend/src/features/interview/ui/live/AnswerBubble.tsx @@ -0,0 +1,11 @@ +import type { Message } from '@/domain/session' + +export function AnswerBubble({ message }: { message: Message }) { + return ( +

+
+ {message.content} +
+
+ ) +} diff --git a/frontend/src/features/interview/ui/live/AnswerComposer.test.tsx b/frontend/src/features/interview/ui/live/AnswerComposer.test.tsx new file mode 100644 index 00000000..6ebb2db5 --- /dev/null +++ b/frontend/src/features/interview/ui/live/AnswerComposer.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AnswerComposer } from './AnswerComposer' + +describe('AnswerComposer', () => { + it('Enter로 제출하고 입력을 비운다', async () => { + const onSubmit = vi.fn() + render() + const input = screen.getByLabelText('답변 입력') + await userEvent.type(input, '내 답변{Enter}') + expect(onSubmit).toHaveBeenCalledWith('내 답변') + expect(input).toHaveValue('') + }) + + it('Shift+Enter는 제출하지 않는다', async () => { + const onSubmit = vi.fn() + render() + const input = screen.getByLabelText('답변 입력') + await userEvent.type(input, '줄1{Shift>}{Enter}{/Shift}') + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('disabled면 제출되지 않는다', async () => { + const onSubmit = vi.fn() + render() + const input = screen.getByLabelText('답변 입력') + await userEvent.type(input, '답변{Enter}') + expect(onSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/features/interview/ui/live/AnswerComposer.tsx b/frontend/src/features/interview/ui/live/AnswerComposer.tsx new file mode 100644 index 00000000..b2864eaf --- /dev/null +++ b/frontend/src/features/interview/ui/live/AnswerComposer.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import type { KeyboardEvent } from 'react' +import { TextArea } from '@/shared/ui/TextArea' +import { Button } from '@/shared/ui/Button' + +export function AnswerComposer({ + disabled = false, + onSubmit, +}: { + disabled?: boolean + onSubmit: (content: string) => void +}) { + const [value, setValue] = useState('') + + const submit = () => { + const trimmed = value.trim() + if (!trimmed || disabled) return + onSubmit(trimmed) + setValue('') + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + submit() + } + } + + return ( +
+