diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java index 9b3001f..afc3b4b 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java +++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java @@ -6,8 +6,9 @@ import lombok.extern.slf4j.Slf4j; /** - * 성공 응답 (docs/api-contract.md). {@code from} 이 요구하는 세 필드는 호출자(core)의 READY 불변식과 같은 - * 조건이다 — 그래서 하나라도 못 채우면 성공으로 내려보내지 않고 실패로 떨어뜨린다. currency 는 READY 필수가 + * 성공 응답 (docs/api-contract.md). 세 필드(name·imageUrl·currentPrice)는 호출자(core)의 READY 불변식과 같은 + * 조건이지만, **다 채우지 못해도 성공으로 내려보낸다** — 호출자가 부분값을 INCOMPLETE 로 받아 사용자가 나머지를 + * 채우기 때문이다(TeamPiKi/core#944). 하나도 못 건졌을 때만 확정 실패로 닫는다. currency 는 READY 필수가 * 아니라 nullable 이다. * *
finalUrl·method 는 additive 확장이다. finalUrl 은 리다이렉트 귀결점(link 경로 항상, image 경로 null)으로 @@ -30,9 +31,16 @@ public static ExtractionResponse from(ProductSnapshot snapshot) { if (snapshot.method() == null) { log.warn("extraction response without method - origin marking missed"); } - if (snapshot.missingReadyField()) { + // 하나도 못 건졌을 때만 확정 실패로 닫는다. 예전에는 세 필드 중 하나라도 비면 닫아 채운 값까지 함께 + // 버렸는데, 사진에 가격이 박혀 있지 않은 것은 정상 입력이라 그 계약은 "쇼핑몰 화면 캡처"만 통과시켰다. + // 부분값은 호출자가 INCOMPLETE 로 받아 사용자가 나머지를 채운다(TeamPiKi/core#944). + if (snapshot.hasNoExtractedValue()) { throw ProductSnapshotException.untrustworthyValue(); } + // 부분값은 성공 응답이라 code 가 남지 않는다 — 어느 필드를 못 채웠는지는 여기서만 관측할 수 있다. + if (snapshot.missingReadyField()) { + log.info("extraction incomplete missing={} method={}", snapshot.missingFieldNames(), snapshot.method()); + } return new ExtractionResponse( snapshot.name(), snapshot.imageUrl(), diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java index 6750901..44a48ae 100644 --- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java +++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java @@ -1,5 +1,7 @@ package com.depromeet.piki.extractor.domain; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -44,6 +46,35 @@ public boolean missingReadyField() { return name == null || name.isBlank() || imageUrl == null || currentPrice == null; } + /** + * 추출값을 하나도 못 얻었는가 — 호출자에게 내려보낼 것도, 사용자에게 "무엇을 채우라" 할 근거도 없는 상태다. + * + *
부분값(일부만 채움)은 호출자가 INCOMPLETE 로 수용해 사용자가 나머지를 채우므로(TeamPiKi/core#944)
+ * 성공으로 내려보내고, 이 판정이 참일 때만 확정 실패로 닫는다. currency 는 READY 필수가 아니라 단독으로는
+ * "건졌다"의 근거가 되지 못하므로 세지 않는다.
+ */
+ public boolean hasNoExtractedValue() {
+ return (name == null || name.isBlank()) && imageUrl == null && currentPrice == null;
+ }
+
+ /**
+ * 못 채운 READY 필드 이름들("currentPrice" · "name+currentPrice") — 무엇을 사용자에게 물어야 하는지를
+ * 로그로 남기는 데 쓴다. 부분값을 성공으로 내려보내면 code 만으로는 어느 필드가 비었는지 사후 판별이 불가능하다.
+ */
+ public String missingFieldNames() {
+ List 확장자·content-type 을 결과물과 함께 나르는 이유: 예전에는 둘 다 png 로 하드코딩돼 있어, 크롭을 건너뛴
+ * HEIC 바이트가 {@code .png} · {@code image/png} 로 위장돼 저장됐다. 브라우저 대부분이 그 파일을 렌더링하지
+ * 못한다. 등록이 허용하는 5개 포맷 중 셋이 이 경로를 탄다(#35).
+ */
+ private record UploadTarget(byte[] bytes, String extension, String mimeType, boolean cropped) {}
+
+ private UploadTarget croppedOrOriginal(ProductImage image, ImageExtraction extraction) {
if (extraction.boundingBox() == null) {
- return image.bytes();
+ return original(image);
}
byte[] cropped = imageCropper.crop(image.bytes(), extraction.boundingBox());
- return cropped != null ? cropped : image.bytes();
+ if (cropped == null) {
+ return original(image);
+ }
+ return new UploadTarget(cropped, "png", "image/png", true);
+ }
+
+ private UploadTarget original(ProductImage image) {
+ return new UploadTarget(image.bytes(), image.extension(), image.mimeType(), false);
}
}
diff --git a/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java b/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java
index 81b77fc..e6c2ae3 100644
--- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java
@@ -1,7 +1,10 @@
package com.depromeet.piki.extractor.api;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -114,6 +117,33 @@ void boundingBoxCropIsWiredToUpload() throws Exception {
BufferedImage uploaded = ImageIO.read(new ByteArrayInputStream(stubImageStorage.lastUploadedBytes));
assertEquals(320, uploaded.getWidth());
assertEquals(320, uploaded.getHeight());
+ // 크롭이 실제로 일어났을 때만 PNG 다 — 아래 HEIC 케이스와 짝을 이루는 대조군.
+ assertEquals("image/png", stubImageStorage.lastUploadedContentType);
+ assertTrue(stubImageStorage.lastUploadedKey.endsWith(".png"));
+ }
+
+ @Test
+ @DisplayName("크롭할 수 없는 포맷은 원본 확장자·content-type 으로 올린다 - png 로 위장하지 않는다")
+ void uploadsOriginalFormatWhenCropIsImpossible() throws Exception {
+ // HEIC 은 ImageIO 에 디코더가 없어 크롭이 건너뛰어진다(의도된 fallback). 그때 원본 바이트를 .png ·
+ // image/png 로 올리면 브라우저가 렌더링하지 못하는 파일이 저장된다 — prod 에서 실제로 그랬다(#35).
+ // 등록이 허용하는 5개 포맷 중 webp·heic·heif 셋이 이 경로를 탄다.
+ stubGeminiClient.reset();
+ stubImageStorage.lastUploadedKey = null;
+ stubImageStorage.lastUploadedContentType = null;
+ byte[] heicBytes = {1, 2, 3, 4};
+ stubImageStorage.onDownload = (bucket, key) -> new StoredImage(heicBytes, "image/heic");
+ stubGeminiClient.build = request ->
+ new GeminiImageResult("사진 상품", 30000, null, "KRW", new GeminiImageResult.BoundingBoxDto(100, 100, 500, 500));
+
+ mockMvc().perform(post("/internal/extractions/image")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body("items/raw/photo.heic")))
+ .andExpect(status().isOk());
+
+ assertEquals("image/heic", stubImageStorage.lastUploadedContentType);
+ assertTrue(stubImageStorage.lastUploadedKey.endsWith(".heic"));
+ assertArrayEquals(heicBytes, stubImageStorage.lastUploadedBytes, "크롭이 불가능하면 원본 바이트가 그대로 올라간다");
}
@Test
@@ -146,7 +176,7 @@ void storageDownloadError() throws Exception {
}
@Test
- @DisplayName("추출이 name 을 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다 (link 와 같은 non-null 규약)")
+ @DisplayName("추출이 name 을 못 채워도 200 으로 채운 값을 반환한다 - 호출자가 INCOMPLETE 로 받는다")
void incompleteExtraction() throws Exception {
stubGeminiClient.reset();
stubImageStorage.onDownload = (bucket, key) -> new StoredImage(new byte[] {1, 2, 3}, "image/png");
@@ -155,7 +185,26 @@ void incompleteExtraction() throws Exception {
mockMvc().perform(post("/internal/extractions/image")
.contentType(MediaType.APPLICATION_JSON)
.content(body("items/raw/noname.png")))
- .andExpect(status().isUnprocessableEntity())
- .andExpect(jsonPath("$.code").value("UNTRUSTWORTHY_VALUE"));
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.name").value(nullValue()))
+ .andExpect(jsonPath("$.currentPrice").value(1000))
+ // 이미지 경로는 추출 결과물을 올려 imageUrl 이 항상 채워진다 — 사용자가 채울 것은 이름뿐이다.
+ .andExpect(jsonPath("$.imageUrl").value(notNullValue()));
+ }
+
+ @Test
+ @DisplayName("이미지 경로는 추출값이 전부 비어도 업로드 결과가 있어 200 이다 - 사용자가 이름·가격을 채운다")
+ void noExtractedValue() throws Exception {
+ stubGeminiClient.reset();
+ stubImageStorage.onDownload = (bucket, key) -> new StoredImage(new byte[] {1, 2, 3}, "image/png");
+ // 가격·이름 모두 없음. imageUrl 은 업로드 결과라 채워지므로, 값 0개를 만들려면 업로드 자체가 없어야 하는데
+ // 이미지 경로에는 그 상태가 없다 — 그래서 이 판정은 link 경로에서 표면화된다(ExtractionLinkIntegrationTest).
+ stubGeminiClient.build = request -> new GeminiImageResult(null, null, null, null, null);
+
+ mockMvc().perform(post("/internal/extractions/image")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body("items/raw/empty.png")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.imageUrl").value(notNullValue()));
}
}
diff --git a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
index 4efe841..d870901 100644
--- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionLinkIntegrationTest.java
@@ -1,5 +1,6 @@
package com.depromeet.piki.extractor.api;
+import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -186,15 +187,34 @@ void emptyShellReclassified() throws Exception {
}
@Test
- @DisplayName("추출 결과가 READY 필수 필드(name·price·imageUrl)를 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다")
+ @DisplayName("추출 결과가 READY 필수 필드를 다 못 채워도 200 으로 채운 값을 반환한다")
void incompleteExtraction() throws Exception {
stubGeminiClient.reset();
stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML);
stubGeminiClient.build = request -> new GeminiExtractionResult(true, "이미지 없는 상품", 5000, "KRW", null);
+ // 예전에는 imageUrl 하나가 비었다고 422 로 닫아 이름·가격까지 버렸다. 이제는 채운 값을 내려보내고
+ // 호출자가 INCOMPLETE 로 받아 사용자가 나머지를 채운다(TeamPiKi/core#944).
mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content(body("https://shop.example.com/p/4")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.name").value("이미지 없는 상품"))
+ .andExpect(jsonPath("$.currentPrice").value(5000))
+ .andExpect(jsonPath("$.imageUrl").value(nullValue()));
+ }
+
+ @Test
+ @DisplayName("추출이 값을 하나도 못 채우면 422 UNTRUSTWORTHY_VALUE 를 반환한다")
+ void noExtractedValue() throws Exception {
+ stubGeminiClient.reset();
+ stubPageFetcher.build = link -> PageContent.of(link, NO_STRUCTURED_HTML);
+ // 상품 페이지라고는 하는데 아무 값도 못 뽑은 경우 — 사용자에게 무엇을 채우라 할 근거조차 없어 확정 실패로 닫는다.
+ stubGeminiClient.build = request -> new GeminiExtractionResult(true, null, null, null, null);
+
+ mockMvc().perform(post("/internal/extractions/link")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body("https://shop.example.com/p/empty")))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value("UNTRUSTWORTHY_VALUE"));
}
diff --git a/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java b/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java
index e96321c..b66c7d8 100644
--- a/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java
+++ b/src/test/java/com/depromeet/piki/extractor/support/StubImageStorage.java
@@ -24,6 +24,14 @@ public class StubImageStorage implements ImageStorage {
*/
public byte[] lastUploadedBytes;
+ /**
+ * 위장 업로드 회귀(#35)를 잡으려면 바이트만으로는 부족하다 — 크롭 불가 포맷(HEIC 등)의 원본을 {@code .png} ·
+ * {@code image/png} 로 올리던 버그는 key·content-type 을 봐야 드러난다. 같은 이유로 테스트 본문이 먼저 초기화한다.
+ */
+ public String lastUploadedKey;
+
+ public String lastUploadedContentType;
+
@Override
public StoredImage download(String bucket, String key) {
return onDownload.apply(bucket, key);
@@ -32,6 +40,8 @@ public StoredImage download(String bucket, String key) {
@Override
public String upload(String bucket, byte[] bytes, String key, String contentType) {
lastUploadedBytes = bytes;
+ lastUploadedKey = key;
+ lastUploadedContentType = contentType;
return UPLOADED_URL;
}
}