From 89ed25e18f25b83402cfc4adf18a835d8d550595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:36:33 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=8B=A4=ED=8C=A8=20code=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=EB=A5=BC=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B3=84=EC=95=BD=20enum=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델 프로브의 422 code 비교를 생성 enum 값으로 교체 - 이전: 이름 문자열 상수 2개와 비교 - 원인: 계약이 code 이름을 바꿔도 컴파일이 잡지 못하고 일반 거절로 떨어짐 - 확정 실패 번역 표의 키를 이름 문자열에서 생성 enum 으로 교체 - 계약 대조 테스트도 enum 집합으로 비교 - 낡은 주석 정정: 카탈로그 어휘 4곳 · 55s 리터럴 2곳 · Jackson DTO 시절 실측 · 옛 응답 클래스명 · 옛 문서 경로 - CLAUDE.md 변환 규칙 예시를 생성 응답 클래스로 갱신 --- CLAUDE.md | 2 +- .../service/ExtractionFailureBucket.kt | 4 +-- .../service/remote/ExtractionModelProbe.kt | 14 ++++---- .../remote/HttpProductLinkExtractor.kt | 2 +- .../remote/ProductExtractorErrorCode.kt | 4 +-- .../remote/RemoteExtractionContract.kt | 35 ++++++++++--------- .../RemoteExtractionHttpClientConfig.kt | 1 - .../remote/RemoteExtractionProperties.kt | 2 +- .../remote/ExtractionErrorCatalogTest.kt | 8 ++--- .../remote/HttpExtractionModelProbeTest.kt | 5 +-- 10 files changed, 38 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6888aba43..48ca620dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ docker info > /dev/null 2>&1 || (open -a Docker && until docker info > /dev/null - **도메인 → 응답 DTO**: 응답 DTO 의 `companion object` 에 `from(도메인)` 정적 팩토리. 예: `UserResponse.from(user)`, `WishItemResponse.WishView.from(wish)`. - **요청 DTO → 도메인/커맨드**: 요청 DTO 의 `toXxx()` 인스턴스 메서드. 예: `CreateTournamentRequest.toCreateTournament()`. -- **외부 응답 → 도메인**: 외부 결과 객체의 `toXxx()`. 예: 원격 추출 응답 DTO 의 `toProductSnapshot(link)` (`RemoteExtractionContract.kt`). +- **외부 응답 → 도메인**: 외부 결과 객체의 `toXxx()`. 예: 생성 응답 클래스(`ExtractionResult`)의 확장 `toProductSnapshot(link)` (`RemoteExtractionContract.kt`). - **스냅샷·도메인 → 엔티티**: 받는 엔티티의 `companion object` 정적 팩토리. 예: `ItemSnapshot.pending(...)`, `ItemSnapshot.manual(...)`. 매핑 분기·정규화는 단위 테스트로 검증한다 (`.claude/rules/testing-principles.md` 의 "분기 위치 결정 트리" (3) 매퍼 함수 분기). diff --git a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt index 76c6c852e..126eb4fbe 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/ExtractionFailureBucket.kt @@ -5,7 +5,7 @@ import com.depromeet.piki.common.exception.ErrorCode // 확정 실패(원격 422)를 **운영 액션 축**으로 나눈 분류(#936). "이 숫자가 늘면 누가 무엇을 하는가"가 기준이라, // 실패의 기술적 원인이 아니라 대응이 같은 것끼리 묶인다. // 계약 정본(shared-infra/contracts/extraction.proto)의 Bucket 과 1:1 이고, 파싱 메트릭의 -// reason 라벨(ItemParsingMetrics)도 여기서 파생한다 — 셋(카탈로그·예외·메트릭)이 어긋나면 +// reason 라벨(ItemParsingMetrics)도 여기서 파생한다 — 셋(계약·예외·메트릭)이 어긋나면 // ExtractionErrorCatalogTest 가 잡는다. enum class ExtractionFailureBucket { // 사용자가 상품 아닌 걸 넣었다. 정상 트래픽이라 할 일이 없다. @@ -27,7 +27,7 @@ enum class ExtractionFailureBucket { // 확정 실패 예외가 자기 bucket 을 스스로 밝히게 하는 ErrorCode. 예외 클래스가 늘어도 워커는 이 인터페이스만 // 보므로 reason 파생 경로가 한 줄로 유지되고, bucket 은 code 정의 옆(single source)에 박힌다. // -// bucket 이 nullable 인 이유: 일시(transient) code 는 bucket 이 없다(카탈로그도 같은 모양). 일시 실패는 +// bucket 이 nullable 인 이유: 일시(transient) code 는 bucket 이 없다(계약도 같은 모양). 일시 실패는 // 소유권 반납으로 되살아나 종결 집계(reason)에 닿지 않고, 상한을 소진하면 recover 가 retry_exhausted 로 센다. interface ExtractionFailureCode : ErrorCode { val bucket: ExtractionFailureBucket? diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt index d2db3a6d9..922b21186 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ExtractionModelProbe.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.product.service.remote +import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode import com.depromeet.piki.contracts.extraction.v1.ExtractionFailure import com.depromeet.piki.contracts.extraction.v1.ModelProbeRequest import org.slf4j.LoggerFactory @@ -12,7 +13,7 @@ import org.springframework.web.client.RestClientException import org.springframework.web.client.RestClientResponseException // 모델 유효성 프로브. 백오피스가 모델을 저장하기 전에 extractor 로 실호출해 "이 모델이 이 경로에서 실제로 -// 동작하는가"를 확인한다 (계약: extractor repo docs/api-contract.md, POST /internal/models/probe). +// 동작하는가"를 확인한다 (계약: shared-infra/contracts/extraction.proto 의 rpc ModelProbe). // // 아는 모델 목록을 코드에 박는 대신 런타임 실측으로 거르는 자리다 — allowlist 를 박으면 새 모델이 나올 때마다 // extractor 배포가 필요해져 "배포 없이 바꾼다"는 목적이 절반 무너진다. 경고창만 띄우는 방식은 게이트가 되지 @@ -34,7 +35,7 @@ interface ExtractionModelProbe { @Component class HttpExtractionModelProbe( - // 추출 호출과 같은 클라이언트를 쓴다. read-timeout 이 55s 라 백오피스 저장이 최악의 경우 그만큼 멈추지만, + // 추출 호출과 같은 클라이언트를 쓴다. read-timeout 이 계약의 호출자 타임아웃이라 백오피스 저장이 최악의 경우 그만큼 멈추지만, // 프로브는 최소 프롬프트 1회라 실측 수 초이고 없는 모델은 즉시 404 로 돌아온다 — 전용 빈을 늘릴 만큼의 // 이득이 없다. 느린 모델 때문에 화면이 오래 멈추기 시작하면 그때 프로브 전용 타임아웃으로 가른다. @Qualifier("remoteExtractionRestClient") private val restClient: RestClient, @@ -81,13 +82,13 @@ class HttpExtractionModelProbe( return IllegalArgumentException(MESSAGE_UNAVAILABLE) } // 실패 body 는 추출 계약과 같은 모양이라 생성 클래스를 공유한다. 모르는 code 는 UNSPECIFIED 라 else 로 간다. - val code = runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code?.name }.getOrNull() + val code = runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code }.getOrNull() // 거절은 계약상 정상 결과(운영자가 없는 모델을 입력)라 info. log.info("모델 프로브 거절 code={} target={} model={}", code, target, model) return IllegalArgumentException( when (code) { - CODE_MODEL_NOT_FOUND -> MESSAGE_NOT_FOUND - CODE_MODEL_INCOMPATIBLE -> MESSAGE_INCOMPATIBLE + ExtractionErrorCode.MODEL_NOT_FOUND -> MESSAGE_NOT_FOUND + ExtractionErrorCode.MODEL_INCOMPATIBLE -> MESSAGE_INCOMPATIBLE // 모르는 code — 이 바이너리보다 새 extractor 가 사유를 늘렸을 수 있다. 거절 사실만 전한다. else -> MESSAGE_REJECTED }, @@ -103,9 +104,6 @@ class HttpExtractionModelProbe( // internal 인 이유: 이 사유 문구가 곧 백오피스 화면에 뜨는 계약이라 테스트가 같은 상수로 단언한다. // 테스트에 문자열을 다시 적으면 한쪽만 바뀌었을 때 어긋난 채로 통과한다. companion object { - internal const val CODE_MODEL_NOT_FOUND = "MODEL_NOT_FOUND" - internal const val CODE_MODEL_INCOMPATIBLE = "MODEL_INCOMPATIBLE" - internal const val MESSAGE_NOT_FOUND = "응답하지 않는 모델입니다. 모델명을 확인해 주세요." internal const val MESSAGE_INCOMPATIBLE = "모델은 있으나 이 경로의 요청을 처리하지 못합니다. 다른 모델을 골라 주세요." internal const val MESSAGE_REJECTED = "이 모델은 사용할 수 없습니다." diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt index 802a467de..cffbec72b 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/HttpProductLinkExtractor.kt @@ -11,7 +11,7 @@ import org.springframework.stereotype.Component import org.springframework.web.client.RestClient // 원격 추출 서비스(extractor)의 링크 추출 클라이언트. 계약 정본은 TeamPiKi/infra 의 -// contracts/extraction-api.md 이고, 호출·3갈래 번역(2xx / 422+code / 그 외)·2xx 계약 위반 가드는 이미지 클라이언트 +// contracts/extraction.proto(모양)·extraction-api.md(동작 규칙)이고, 호출·3갈래 번역(2xx / 422+code / 그 외)·2xx 계약 위반 가드는 이미지 클라이언트 // (HttpImageSnapshotExtractor)와 공유하므로 RemoteExtractionContract 한 곳에 있다 — 여기는 링크 고유의 요청(URL)만 진다. // // 차단(BLOCKED)은 여기서 끝난다 — 요청을 아예 내보내지 않으므로 extractor·renderer 는 그 도메인의 존재조차 diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt index 1ad4cde55..2aad7c92b 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/ProductExtractorErrorCode.kt @@ -15,7 +15,7 @@ import com.depromeet.piki.product.service.ExtractionFailureCode // 나머지는 비 RETRYABLE(즉시 FAILED). // // bucket 은 확정 실패를 메트릭에서 무엇으로 셀지의 정본이다(#936). 일시(TRANSIENT_FAILURE)는 종결 집계에 -// 닿지 않으므로 bucket 이 없다(카탈로그의 transient code 와 같은 모양). +// 닿지 않으므로 bucket 이 없다(계약의 TRANSIENT code 와 같은 모양). // // status 교정: 종전엔 두 팩토리 모두 502 를 직접 들었으나, category 가 status 를 소유하게 되며 // PERMANENT_FAILURE(SERVER_ERROR)는 500 으로 파생된다(에픽 결정 2 의 OAuthException.misconfigured 502→500 과 동형). @@ -32,7 +32,7 @@ enum class ProductExtractorErrorCode( // 우리 방어가 발동했거나(호스트 차단·리다이렉트 이상) 이 바이너리가 모르는 code 로 422 가 온 경우. // tolerant reader — 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). 재시도 무의미. // 둘 다 "코드를 조사한다"가 대응이라 internal_error 로 센다: 전자는 우리 방어·버그이고, 후자는 매핑이 - // 뒤처졌다는 신호(카탈로그·translate 갱신)라 결국 코드 작업으로 귀결된다. + // 뒤처졌다는 신호(계약·translate 갱신)라 결국 코드 작업으로 귀결된다. PERMANENT_FAILURE( "EXTRACTOR-002", ErrorCategory.SERVER_ERROR, diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt index 79a04cc01..635b107c5 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionContract.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.product.service.remote import com.depromeet.piki.common.exception.BaseException +import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode import com.depromeet.piki.contracts.extraction.v1.ExtractionFailure import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod import com.depromeet.piki.contracts.extraction.v1.ExtractionResult @@ -37,31 +38,31 @@ internal object RemoteExtractionContract { // 확정 실패(422) code 전수 → 우리 예외. 계약 정본(shared-infra/contracts/extraction.proto)의 // permanent code 를 빠짐없이 여기에 명시한다 — 표에 없는 code 는 아래 fallback 으로 떨어져 internal_error 로 // 세지므로, 매핑 누락이 "우리가 이름을 아는 실패"인 척 묻히지 않는다. - // 표를 when 대신 값으로 둔 이유: 카탈로그와의 전수 대조(ExtractionErrorCatalogTest)가 이 키 집합을 직접 읽는다. + // 표를 when 대신 값으로 둔 이유: 계약과의 전수 대조(ExtractionErrorCatalogTest)가 이 키 집합을 직접 읽는다. // when 분기는 밖에서 열거할 수 없어 "누락이 else 로 조용히 흡수됐는지"를 기계가 가릴 수 없다. // // 어느 예외로 보내는지가 곧 메트릭 reason 이다 — 예외의 errorCode 가 bucket 을 들고 있고(ExtractionFailureCode), // ItemParsingMetrics.reasonOf 가 그 bucket 을 라벨로 옮긴다. 문구는 여기서 갈리지 않는다(고정 사용자 문구). - internal val PERMANENT_TRANSLATIONS: Map BaseException> = + internal val PERMANENT_TRANSLATIONS: Map BaseException> = mapOf( // 사용자가 상품 아닌 걸 넣었다 — 정상 트래픽. - "NOT_PRODUCT_PAGE" to { ProductSnapshotException.notProductPage() }, - "INVALID_URL" to { ProductSnapshotException.notProductPage() }, + ExtractionErrorCode.NOT_PRODUCT_PAGE to { ProductSnapshotException.notProductPage() }, + ExtractionErrorCode.INVALID_URL to { ProductSnapshotException.notProductPage() }, // 우리 구성으로 못 읽었다 — 도메인 허가 후보 신호. - "EMPTY_SHELL" to { ProductSnapshotException.noExtractableContent() }, - "NO_EXTRACTABLE_CONTENT" to { ProductSnapshotException.noExtractableContent() }, + ExtractionErrorCode.EMPTY_SHELL to { ProductSnapshotException.noExtractableContent() }, + ExtractionErrorCode.NO_EXTRACTABLE_CONTENT to { ProductSnapshotException.noExtractableContent() }, // 대상이 우리를 막았다 — BLOCKED 정책 후보. - "FETCH_CLIENT_ERROR" to { ProductExtractorException.blockedByTarget() }, - "PERMANENT_UPSTREAM" to { ProductExtractorException.blockedByTarget() }, + ExtractionErrorCode.FETCH_CLIENT_ERROR to { ProductExtractorException.blockedByTarget() }, + ExtractionErrorCode.PERMANENT_UPSTREAM to { ProductExtractorException.blockedByTarget() }, // 추출은 됐는데 값을 믿을 수 없다 — 모델·프롬프트·검증 규칙 소관. // IMAGE_UNSUPPORTED(이미지 경로 전용)도 "받은 결과를 상품 정보로 쓸 수 없다"는 같은 성격이다. - "UNTRUSTWORTHY_VALUE" to { ProductSnapshotException.untrustworthyValue() }, - "LLM_INVALID_RESPONSE" to { ProductSnapshotException.untrustworthyValue() }, - "IMAGE_UNSUPPORTED" to { ProductSnapshotException.untrustworthyValue() }, + ExtractionErrorCode.UNTRUSTWORTHY_VALUE to { ProductSnapshotException.untrustworthyValue() }, + ExtractionErrorCode.LLM_INVALID_RESPONSE to { ProductSnapshotException.untrustworthyValue() }, + ExtractionErrorCode.IMAGE_UNSUPPORTED to { ProductSnapshotException.untrustworthyValue() }, // 우리 방어가 발동했다 — 정상 흐름이면 애초에 우리 경계(SSRF 가드·리다이렉트 제한)가 먼저 걸렀어야 한다. - "BLOCKED_HOST" to { ProductExtractorException.permanentFailure() }, - "TOO_MANY_REDIRECTS" to { ProductExtractorException.permanentFailure() }, - "MALFORMED_REDIRECT" to { ProductExtractorException.permanentFailure() }, + ExtractionErrorCode.BLOCKED_HOST to { ProductExtractorException.permanentFailure() }, + ExtractionErrorCode.TOO_MANY_REDIRECTS to { ProductExtractorException.permanentFailure() }, + ExtractionErrorCode.MALFORMED_REDIRECT to { ProductExtractorException.permanentFailure() }, ) // 원격 추출 호출 한 건의 전부 — POST 부터 3갈래(2xx 매핑 / 422+code 확정 / 그 외 일시)가 이 함수 안에서 끝난다. @@ -94,7 +95,7 @@ internal object RemoteExtractionContract { } // 2xx 응답 → ProductSnapshot. extractor 는 2xx 로 name·imageUrl·currentPrice 중 **하나 이상**을 보장한다 - // (자기 쪽 ExtractionResponse.from 이 하나도 못 건졌을 때만 422 UNTRUSTWORTHY_VALUE 로 닫는다, extractor#37). + // (자기 쪽 응답 경계가 하나도 못 건졌을 때만 422 UNTRUSTWORTHY_VALUE 로 닫는다, extractor#37). // 그래서 부분값 2xx 는 계약 위반이 아니라 정상 결과이고, 그것을 READY/INCOMPLETE/FAILED 로 가르는 판정은 // 경계가 아니라 도메인(ItemSnapshot.markExtracted, #944)이 쥔다 — 여기서 세 필드를 다 요구하면 부분값이 // INCOMPLETE 로 가는 길 자체가 닫혀 재시도 후 FAILED 로 끝난다(#950 이 고친 것이 정확히 그 상태다). @@ -150,9 +151,9 @@ internal object RemoteExtractionContract { log.warn("remote extract transient status={} {}", e.statusCode.value(), target) return ProductExtractorException.transientFailure(e) } - // 모르는 code 문자열은 파서가 무시해 UNSPECIFIED 로 읽힌다 — 그 이름은 표에 없으니 아래 fallback 으로 간다. + // 모르는 code 문자열은 파서가 무시해 UNSPECIFIED 로 읽힌다 — 표에 없으니 아래 fallback 으로 간다. val code = - runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code?.name } + runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code } .getOrNull() // code 는 관측·디버깅용(계약 §1) — 응답엔 노출되지 않고 로그로만 남긴다. 확정 실패는 계약상 정상 결과라 info. // 원문 code 는 이 줄에만 남는다: 메트릭은 bucket 단위라(카디널리티) 개별 code 추적은 로그가 진다. diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionHttpClientConfig.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionHttpClientConfig.kt index fc798f0e2..b5d567713 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionHttpClientConfig.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionHttpClientConfig.kt @@ -28,7 +28,6 @@ class RemoteExtractionHttpClientConfig { setReadTimeout(properties.readTimeoutMs) }, ).observationRegistry(observationRegistry) - // registerDefaults 를 빼면 Jackson 이 사라져 image·probe 요청이 "컨버터 없음"으로 일시 실패한다(실측). .configureMessageConverters { it.registerDefaults().addCustomConverter(RemoteExtractionContract.messageConverter()) } .build() } diff --git a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt index 5a4f6e7ca..6d84e9a6c 100644 --- a/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt +++ b/src/main/kotlin/com/depromeet/piki/product/service/remote/RemoteExtractionProperties.kt @@ -16,7 +16,7 @@ data class RemoteExtractionProperties( // 기본값의 정본은 계약(extraction.proto 의 caller_read_timeout_seconds)이다. 전 경로가 같은 클라이언트·같은 값을 // 쓰고, extractor 는 자기 내부 예산이 이 값 미만인지 테스트로 대조한다. // 한계: SimpleClientHttpRequestFactory 의 read timeout 은 per-read 소켓 타임아웃이라 총 소요시간의 상한은 아니다 — - // slow-drip 응답(read 마다 55s 미만 간격)은 이 가드를 지나 stale 을 넘길 수 있다. 그 경우에도 extractor 가 + // slow-drip 응답(read 마다 타임아웃 미만 간격)은 이 가드를 지나 stale 을 넘길 수 있다. 그 경우에도 extractor 가 // 무상태라 중복 발주의 대가는 LLM 비용 1회로 바운드된다(상태 오염 없음). val readTimeoutMs: Int = ExtractionEndpoints.CALLER_READ_TIMEOUT_MS, ) { diff --git a/src/test/kotlin/com/depromeet/piki/product/service/remote/ExtractionErrorCatalogTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/remote/ExtractionErrorCatalogTest.kt index 5407a0344..7994f1b23 100644 --- a/src/test/kotlin/com/depromeet/piki/product/service/remote/ExtractionErrorCatalogTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/service/remote/ExtractionErrorCatalogTest.kt @@ -31,7 +31,7 @@ class ExtractionErrorCatalogTest { @Test fun `계약의 확정 실패 code 는 전수가 translate 의 명시 분기로 있다`() { - val expected = parsingPermanentCodes.map { it.name }.toSet() + val expected = parsingPermanentCodes.toSet() val mapped = RemoteExtractionContract.PERMANENT_TRANSLATIONS.keys val missing = expected - mapped val unknown = mapped - expected @@ -43,13 +43,13 @@ class ExtractionErrorCatalogTest { if (missing.isNotEmpty()) { appendLine( "- 매핑 누락(모르는 code 용 fallback 으로 떨어져 internal_error 로 집계된다): " + - missing.sorted().joinToString(", "), + missing.map { it.name }.sorted().joinToString(", "), ) } if (unknown.isNotEmpty()) { appendLine( "- 계약에 없는 code 를 매핑하고 있다(오타이거나 계약에서 사라진 code): " + - unknown.sorted().joinToString(", "), + unknown.map { it.name }.sorted().joinToString(", "), ) } }, @@ -67,7 +67,7 @@ class ExtractionErrorCatalogTest { if (bucket == Bucket.BUCKET_UNSPECIFIED) { return@mapNotNull "${code.name}: 계약에 bucket 이 없다(확정 실패는 bucket 필수)" } - val translate = RemoteExtractionContract.PERMANENT_TRANSLATIONS[code.name] ?: return@mapNotNull null + val translate = RemoteExtractionContract.PERMANENT_TRANSLATIONS[code] ?: return@mapNotNull null val reason = ItemParsingMetrics.reasonOf(translate()) val expected = bucket.name.lowercase() reason.takeIf { it != expected }?.let { "${code.name}: 계약 bucket=$expected 인데 메트릭 reason=$it 로 집계된다" } diff --git a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt index e9f8c0dbb..c33a87313 100644 --- a/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt +++ b/src/test/kotlin/com/depromeet/piki/product/service/remote/HttpExtractionModelProbeTest.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.product.service.remote +import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode import org.junit.jupiter.api.Test import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -67,7 +68,7 @@ class HttpExtractionModelProbeTest { fun `422 MODEL_NOT_FOUND 는 모델명을 확인하라는 사유로 거절한다`() { assertEquals( HttpExtractionModelProbe.MESSAGE_NOT_FOUND, - rejectionMessage("""{"code":"${HttpExtractionModelProbe.CODE_MODEL_NOT_FOUND}"}"""), + rejectionMessage("""{"code":"${ExtractionErrorCode.MODEL_NOT_FOUND.name}"}"""), ) } @@ -75,7 +76,7 @@ class HttpExtractionModelProbeTest { fun `422 MODEL_INCOMPATIBLE 은 다른 모델을 고르라는 사유로 거절한다`() { assertEquals( HttpExtractionModelProbe.MESSAGE_INCOMPATIBLE, - rejectionMessage("""{"code":"${HttpExtractionModelProbe.CODE_MODEL_INCOMPATIBLE}"}"""), + rejectionMessage("""{"code":"${ExtractionErrorCode.MODEL_INCOMPATIBLE.name}"}"""), ) }