diff --git a/docs/plans/2026-10-05-followup-quality.md b/docs/plans/2026-10-05-followup-quality.md new file mode 100644 index 00000000..5f0ab247 --- /dev/null +++ b/docs/plans/2026-10-05-followup-quality.md @@ -0,0 +1,45 @@ +# 남은 조회 오동작 개선 계획 + +> 에이전트 작업: superpowers:dispatching-parallel-agents와 test-driven-development로 독립 파일 영역을 분담하고, 명세 검토 후 독립 품질 리뷰를 수행합니다. + +**목표:** 사용자 승인한 R1–R4를 수정하고 운영 서버와 공개 CLI에서 원래 재현을 검증합니다. +**설계:** 상품과 매장 식별자를 유지해 선택한 매장의 관측값만 표시합니다. 실제 검색 페이지 계약을 확인해 Seven 페이지를 처리합니다. 비교의 부분 실패는 캐시에 저장하지 않으며 기존 부분 실패 캐시는 namespace 갱신으로 제거합니다. +**기술:** TypeScript/Hono/Zod/Vitest/Cloudflare Worker, Node CLI, 기존 Mac 중계. + +## R1/R2 대화형 CLI + +대상: src/cli/interactiveTypes.ts, src/utils/cliInteractiveHelpers.ts, src/cli/interactiveItemSearch.ts와 필요한 작은 helper; tests/app의 대화형 테스트. + +- [x] 실제 API fixture로 OY 매장 재고10/O2O8/검색값0, 미확인/미판매/진짜0, CU 매장stock48/픽업false/상품픽업true를 실패 테스트로 작성하고 red 기록. +- [x] 매장 코드를 파싱·보존. 이름/address fallback은 정확한 매칭만 허용. 잘못된 매장으로 대체하지 않음. +- [x] OY는 선택한 상품·매장의 storeInventory만 수량/상태로 출력. 관측하지 못한 수량은 확인 불가. +- [x] CU는 선택 상품이 최초 stockItemCode와 다르면 해당 상품명으로 재조회하고 반환 상품코드 일치 확인. 선택 매장의 stock/채널 설정을 출력. 실패·미확인은 확인 불가이며 일반 상품 설정을 매장 값으로 사용하지 않음. +- [x] 기존 대화형 흐름과 새 선택/재조회 테스트 통과. + +## R3 Seven 페이지 + +대상: src/services/seveneleven/client.ts, productKeyword.ts, 관련 테스트; 실제 계약 변경 시 scripts/relay/convenience.ts 및 중계 테스트. + +- [x] 공식 공개 프런트엔드의 검색 요청 형식과 기존 중계 원시 페이지 응답을 확인. 페이지 계약을 추측해 구현하지 않음. +- [x] 페이지1/2/3의 상품코드 차이, 크기 한도, 총개수, 마지막 빈 페이지를 검증하는 red 테스트 작성. +- [x] 확인한 계약으로 client/relay 요청 구현. 서로 다른 컬렉션 병합·중복·변형 검색에도 페이지 중복과 이중 slicing 방지. +- [ ] 실제 운영과 중계 최소 호출로 동일 검색의 페이지별 코드를 대조. 기존 원장·인증 보존. + +## R4 비교 실패 캐시 + +대상: src/utils/cache.ts, src/api/routes/compareRoutes.ts, src/api/compareHandlers.ts, src/cliRenderer.ts 및 tests/app/api/utils. + +- [x] 실제 cache 저장소를 주입해 첫 partial failure 후 회복 결과 재조회가 실패하는 red 테스트 작성. +- [x] errors가 있는 비교 응답은 no-store 및 캐시 저장 생략. 다른 정상 조회의 캐시 동작은 보존. 필요한 경우 cache option의 응답 predicate로 해당 route에만 적용. +- [x] 비교 namespace 갱신으로 이전 partial result 제거. CLI에 서비스 부분 실패를 분명히 표시. +- [x] 실패/정상/null·잘못된 응답 및 기존 정상 cache 테스트 통과. + +## 통합·리뷰·배포 + +- [x] 독립 명세 검토 및 품질 리뷰, check/전체 tests/coverage100/build/audit0 확인. src 파일450줄 이하. +- [ ] 한국어 커밋/PR, GitHub CI·Coverage·CodeQL 성공 확인 후 기존 사용자 승인에 따라 병합·Worker 배포. +- [ ] 중계 계약이 변경되면 소유 프로세스 종료 확인→런타임 교체→재기동·health·원장보존 검증. 변경 없으면 불필요 재기동하지 않음. +- [ ] CLI patch 릴리스·tag/gitHead·npm tarball무결성 확인, 공개 코드에서 OY/CU 실제 API fixture 재현과 Seven 페이지/비교 회복 검증. +- [ ] 보고서/PROJECT 갱신, 작업용 checkout 정리와 완료 알림. + +공유 호출 예산: 편의점 일일 잔여 최소150회 유지, 매분 잔여 확인 후 필요한 최소 읽기 요청만 수행. 한도·원장·인증·Google/Zyte 설정 및 롯데마트 종료 정책은 바꾸지 않습니다. 메시지 제출 도구는 실제 발송하지 않습니다. diff --git a/docs/seveneleven-product-pagination.md b/docs/seveneleven-product-pagination.md new file mode 100644 index 00000000..e29c10ff --- /dev/null +++ b/docs/seveneleven-product-pagination.md @@ -0,0 +1,25 @@ +# 세븐일레븐 상품 검색 페이지 계약 + +2026-10-05 공식 웹 앱의 `/search/result` 요청과 공개 API 응답을 대조했습니다. 이전의 `pageNo/pageSize` 요청은 페이지를 바꿔도 같은 상품을 반환했습니다. + +공식 요청은 `/api/v1/open/search/goods`에 다음 본문을 POST합니다. + +```json +{ + "collection": "goods", + "query": "커피", + "sort": "quantity/desc,itemOnm/asc", + "startCount": 0, + "listCount": 100 +} +``` + +`startCount`는 0부터 시작하는 페이지 번호입니다. 각 컬렉션은 따로 페이지를 반환합니다. 컬렉션 사이에 같은 상품이 있으므로 원본 `totalCount`의 합은 고유 상품 수와 다릅니다. 확인 당시 커피는 원본 166행, 고유 상품 122개였습니다. + +검색은 원본 목록을 완전히 수집한 뒤 상품 코드로 중복을 제거하고, 보정 검색의 결과를 합쳐 한 번만 페이지를 나눕니다. 응답의 `totalCount`는 고유 상품 수입니다. 마지막 페이지 이후에는 같은 전체 개수와 빈 상품 목록을 반환합니다. + +부하를 제한하기 위해 요청당 컬렉션별 100행, 검색어당 최대 5페이지와 원본 합계 500행, 보정 검색 전체 최대 10요청을 허용합니다. 범위가 이 제한을 넘거나 페이지가 반복·누락되면 불완전한 목록을 정상 결과로 반환하지 않습니다. REST는 오류 응답, MCP는 명시적인 `degraded` 응답을 반환합니다. 넓은 검색은 상품명을 더 구체적으로 입력해야 합니다. + +Mac 중계는 구형 본문과 공식 본문을 함께 허용합니다. 중계를 먼저 반영한 뒤 Worker를 배포합니다. 중계 인증·GS25 세션·호출 원장은 교체하거나 초기화하지 않습니다. REST 검색 캐시 namespace는 `seveneleven-products-v3`로 갱신했습니다. + +확인한 공식 소스: [검색 화면 번들](https://static.7-elevenapp.co.kr/k7app/_nuxt/DH1pJlb5.js), [검색 API 번들](https://static.7-elevenapp.co.kr/k7app/_nuxt/BqIULyu_.js). diff --git a/openapi.json b/openapi.json index 5746f6ca..98d607cd 100644 --- a/openapi.json +++ b/openapi.json @@ -1141,10 +1141,12 @@ }, "remainQuantity": { "type": "integer", + "nullable": true, "example": 3 }, "o2oRemainQuantity": { "type": "integer", + "nullable": true, "example": 2 }, "stockStatus": { @@ -1152,7 +1154,8 @@ "enum": [ "in_stock", "out_of_stock", - "not_sold" + "not_sold", + "unknown" ], "example": "in_stock" }, diff --git a/openapi.yaml b/openapi.yaml index 4962d88e..cc67ea47 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -814,9 +814,11 @@ components: example: true remainQuantity: type: integer + nullable: true example: 3 o2oRemainQuantity: type: integer + nullable: true example: 2 stockStatus: type: string @@ -824,6 +826,7 @@ components: - in_stock - out_of_stock - not_sold + - unknown example: in_stock stockLabel: type: string diff --git a/scripts/relay/convenience.ts b/scripts/relay/convenience.ts index 0f5cdce4..bf506807 100644 --- a/scripts/relay/convenience.ts +++ b/scripts/relay/convenience.ts @@ -80,7 +80,10 @@ const schemas = { [v.latVal, v.baseLatVal].every((n) => Math.abs(Number(n)) <= 90) && [v.longVal, v.baseLongVal].every((n) => Math.abs(Number(n)) <= 180), ), - 'seven-goods': z.object({ query: text.min(1), pageNo: count, pageSize: count.min(1) }).strict(), + 'seven-goods': z.union([ + z.object({ collection: z.literal('goods'), query: text.min(1), sort: z.literal('quantity/desc,itemOnm/asc'), startCount: count, listCount: count.min(1) }).strict(), + z.object({ query: text.min(1), pageNo: count, pageSize: count.min(1) }).strict(), + ]), 'seven-store': z .object({ collection: z.literal('store'), @@ -246,7 +249,10 @@ const paths: Record = { }; export function createConvenienceRelay( token: string, - options: Pick & { gs25ApiKey?: string; gs25Session?: Gs25SessionTransport }, + options: Pick & { + gs25ApiKey?: string; + gs25Session?: Gs25SessionTransport; + }, ) { return createHttpRelay(token, { ...options, diff --git a/src/api/routes/compareRoutes.ts b/src/api/routes/compareRoutes.ts index 2f7dfdaa..7d94d0a8 100644 --- a/src/api/routes/compareRoutes.ts +++ b/src/api/routes/compareRoutes.ts @@ -10,7 +10,20 @@ export function registerCompareRoutes(app: Hono<{ Bindings: AppBindings }>): voi { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'compare-products-v2', + keyPrefix: 'compare-products-v3', + shouldCache: async (response) => { + try { + const payload = await response.json() as { + success?: unknown; + data?: { results?: unknown; errors?: unknown }; + } | null; + return response.ok && payload?.success === true && + Array.isArray(payload.data?.results) && + Array.isArray(payload.data?.errors) && payload.data.errors.length === 0; + } catch { + return false; + } + }, }, () => handleCompareProducts(c), ), diff --git a/src/api/routes/oliveyoungRoutes.ts b/src/api/routes/oliveyoungRoutes.ts index 84120fa8..3b50584a 100644 --- a/src/api/routes/oliveyoungRoutes.ts +++ b/src/api/routes/oliveyoungRoutes.ts @@ -43,7 +43,7 @@ export function registerOliveyoungRoutes(app: Hono<{ Bindings: AppBindings }>): { ttlSeconds: 60 * 10, staleWhileRevalidateSeconds: 60, - keyPrefix: 'oliveyoung-inventory-v3', + keyPrefix: 'oliveyoung-inventory-v4', }, () => handleOliveyoungCheckInventory(c), ), diff --git a/src/api/routes/sevenelevenRoutes.ts b/src/api/routes/sevenelevenRoutes.ts index 6e7a5c2d..c4c3af3f 100644 --- a/src/api/routes/sevenelevenRoutes.ts +++ b/src/api/routes/sevenelevenRoutes.ts @@ -20,7 +20,7 @@ export function registerSevenElevenRoutes(app: Hono<{ Bindings: AppBindings }>): { ttlSeconds: 60 * 3, staleWhileRevalidateSeconds: 60, - keyPrefix: 'seveneleven-products-v2', + keyPrefix: 'seveneleven-products-v3', }, () => handleSevenElevenSearchProducts(c), ), diff --git a/src/cli/interactiveItemSearch.ts b/src/cli/interactiveItemSearch.ts index a8acaafa..009cfe69 100644 --- a/src/cli/interactiveItemSearch.ts +++ b/src/cli/interactiveItemSearch.ts @@ -7,21 +7,19 @@ import { isRecord, parseDaisoProducts, toText } from '../utils/cliInteractiveHel import type { InteractiveCliDeps, InteractivePrompt, InteractiveStore, InteractiveTheater, WriteFn } from './interactiveTypes.js'; import { askNonEmpty } from './interactivePrompt.js'; import { fetchEnvelope } from './interactiveFetch.js'; +import { cuStoreStock, oliveyoungStoreStock, printCuStock, printOliveyoungStock, quantityText } from './interactiveStoreInventory.js'; interface OliveyoungProductPreview { goodsNumber: string; goodsName: string; priceToPay: number; - o2oRemainQuantity: number; + source: Record; } interface CuInventoryPreview { itemCode: string; itemName: string; price: number; - pickupYn: boolean; - deliveryYn: boolean; - reserveYn: boolean; } interface LotteCinemaMoviePreview { @@ -245,7 +243,7 @@ export async function runOliveyoungItemSearch( goodsNumber: toText(entry.goodsNumber), goodsName: toText(entry.goodsName), priceToPay: Number.parseInt(toText(entry.priceToPay), 10) || 0, - o2oRemainQuantity: Number.parseInt(toText(entry.o2oRemainQuantity), 10) || 0, + source: entry, })) .filter((entry) => entry.goodsName.length > 0); @@ -267,7 +265,7 @@ export async function runOliveyoungItemSearch( cancelText: '상품 선택을 취소했습니다.', items: products, renderItem: (product, index) => - `${index + 1}. ${product.goodsName} (${product.priceToPay}원, 남은수량 ${product.o2oRemainQuantity})`, + `${index + 1}. ${product.goodsName} (${product.priceToPay}원, 남은수량 ${quantityText(oliveyoungStoreStock(product.source, store)?.remainQuantity)})`, filterText: (product) => `${product.goodsName} ${product.goodsNumber}`, indexText: '입력: 번호 선택 | /키워드 필터 | all 전체보기 | 0 취소', }); @@ -278,7 +276,23 @@ export async function runOliveyoungItemSearch( deps.writeOut(`- 상품: ${selected.goodsName}`); deps.writeOut(`- 가격: ${selected.priceToPay}원`); - deps.writeOut(`- 남은수량: ${selected.o2oRemainQuantity}`); + let match = oliveyoungStoreStock(selected.source, store); + if (!isRecord(selected.source.storeInventory) && selected.goodsNumber) { + try { + const refreshed = await fetchEnvelope(deps.fetchImpl, '/api/oliveyoung/inventory', { + keyword: selected.goodsName, storeKeyword: store.name, size: '10', + }); + if (isRecord(refreshed) && refreshed.success === true && isRecord(refreshed.data) + && isRecord(refreshed.data.inventory) && Array.isArray(refreshed.data.inventory.products)) { + const product = refreshed.data.inventory.products.find((entry: unknown) => + isRecord(entry) && toText(entry.goodsNumber) === selected.goodsNumber); + match = oliveyoungStoreStock(product, store); + } + } catch { + // 재조회 실패는 품절로 단정하지 않는다. + } + } + printOliveyoungStock(deps.writeOut, match); } export async function runCuItemSearch( @@ -311,9 +325,6 @@ export async function runCuItemSearch( itemCode: toText(entry.itemCode), itemName: toText(entry.itemName), price: Number.parseInt(toText(entry.price), 10) || 0, - pickupYn: toText(entry.pickupYn).toLowerCase() === 'true', - deliveryYn: toText(entry.deliveryYn).toLowerCase() === 'true', - reserveYn: toText(entry.reserveYn).toLowerCase() === 'true', })) .filter((entry) => entry.itemName.length > 0); @@ -345,7 +356,16 @@ export async function runCuItemSearch( deps.writeOut(`- 상품: ${selected.itemName}`); deps.writeOut(`- 가격: ${selected.price}원`); - deps.writeOut(`- 픽업 가능: ${selected.pickupYn ? '예' : '아니오'}`); - deps.writeOut(`- 배달 가능: ${selected.deliveryYn ? '예' : '아니오'}`); - deps.writeOut(`- 예약 가능: ${selected.reserveYn ? '예' : '아니오'}`); + let stockPayload: unknown = payload; + const nearby = payload.data.nearbyStores; + if (!isRecord(nearby) || nearby.stockItemCode !== selected.itemCode) { + try { + stockPayload = await fetchEnvelope(deps.fetchImpl, '/api/cu/inventory', { + keyword: selected.itemName, storeKeyword: store.name, size: '10', storeLimit: '10', + }); + } catch { + stockPayload = undefined; + } + } + printCuStock(deps.writeOut, cuStoreStock(stockPayload, selected.itemCode, store)); } diff --git a/src/cli/interactiveStoreInventory.ts b/src/cli/interactiveStoreInventory.ts new file mode 100644 index 00000000..f42c2b3e --- /dev/null +++ b/src/cli/interactiveStoreInventory.ts @@ -0,0 +1,57 @@ +/** + * 선택 매장의 검증된 재고와 채널 표시 + */ +import { isRecord, toText } from '../utils/cliInteractiveHelpers.js'; +import type { InteractiveStore, WriteFn } from './interactiveTypes.js'; + +export function matchSelectedStore(stores: unknown, store: InteractiveStore): Record | undefined { + if (!Array.isArray(stores)) return undefined; + const entries = stores.filter((entry): entry is Record => isRecord(entry)); + const codeMatches = store.storeCode + ? entries.filter((entry) => toText(entry.storeCode) === store.storeCode) + : []; + if (codeMatches.length === 1) return codeMatches[0]; + if (codeMatches.length > 1) return undefined; + const matches = entries.filter((entry) => { + if (store.storeCode && toText(entry.storeCode)) return false; + return store.address.length > 0 && toText(entry.address) === store.address + && (toText(entry.storeName) || toText(entry.name)) === store.name; + }); + return matches.length === 1 ? matches[0] : undefined; +} + +export function quantityText(value: unknown): string { + const quantity = typeof value === 'number' ? value + : typeof value === 'string' && value.trim() ? Number(value) : NaN; + return Number.isInteger(quantity) && quantity >= 0 ? String(quantity) : '확인 불가'; +} + +export function oliveyoungStoreStock(product: unknown, store: InteractiveStore): Record | undefined { + if (!isRecord(product) || !isRecord(product.storeInventory)) return undefined; + return matchSelectedStore(product.storeInventory.stores, store); +} + +export function printOliveyoungStock(writeOut: WriteFn, match: Record | undefined): void { + const status = match?.stockStatus; + const label = status === 'in_stock' ? '재고 있음' : status === 'out_of_stock' ? '품절' + : status === 'not_sold' ? '미취급' : '확인 불가'; + writeOut(`- 남은수량: ${quantityText(match?.remainQuantity)}`); + writeOut(`- O2O 남은수량: ${quantityText(match?.o2oRemainQuantity)}`); + writeOut(`- 재고 상태: ${label}`); +} + +export function cuStoreStock(payload: unknown, itemCode: string, store: InteractiveStore): Record | undefined { + if (!isRecord(payload) || payload.success !== true || !isRecord(payload.data)) return undefined; + const nearby = payload.data.nearbyStores; + if (!isRecord(nearby) || nearby.available === false || !itemCode || nearby.stockItemCode !== itemCode) return undefined; + return matchSelectedStore(nearby.stores, store); +} + +export function printCuStock(writeOut: WriteFn, match: Record | undefined): void { + writeOut(`- 재고 수량: ${quantityText(match?.stock)}`); + const hasVerifiedStock = quantityText(match?.stock) !== '확인 불가'; + for (const [field, label] of [['pickupYn', '픽업'], ['deliveryYn', '배달'], ['reserveYn', '예약']]) { + const value = hasVerifiedStock ? match?.[field] : undefined; + writeOut(`- ${label} 가능: ${value === true ? '예' : value === false ? '아니오' : '확인 불가'}`); + } +} diff --git a/src/cli/interactiveTypes.ts b/src/cli/interactiveTypes.ts index 95d39f5a..99c1a9a3 100644 --- a/src/cli/interactiveTypes.ts +++ b/src/cli/interactiveTypes.ts @@ -18,6 +18,7 @@ export interface InteractivePrompt { } export interface InteractiveStore { + storeCode?: string; name: string; address: string; phone: string; diff --git a/src/cliRenderer.ts b/src/cliRenderer.ts index 42501f30..72e96c00 100644 --- a/src/cliRenderer.ts +++ b/src/cliRenderer.ts @@ -231,7 +231,14 @@ function formatCompare(data: Record): string[] { ); } lines.push(...formatCollection('가격 후보', data.results)); - lines.push(...formatCollection('오류', data.errors)); + if (Array.isArray(data.errors) && data.errors.length > 0) { + lines.push(`서비스 조회 실패: ${data.errors.length}건`); + for (const error of data.errors) { + lines.push(isRecord(error) + ? `- ${toText(error.service)}: ${toText(error.message)}` + : `- ${toText(error)}`); + } + } if (typeof data.note === 'string') { lines.push(`참고: ${data.note}`); } @@ -248,7 +255,11 @@ export function renderApiEnvelope(command: string, url: URL, payload: unknown): return JSON.stringify(payload, null, 2); } - const lines: string[] = [`요청 성공: ${url.pathname}`]; + const partialCompare = command === 'compare' && isRecord(envelope.data) && + Array.isArray(envelope.data.errors) && envelope.data.errors.length > 0; + const lines: string[] = [ + `${partialCompare ? '일부 서비스 조회 실패' : '요청 성공'}: ${url.pathname}`, + ]; lines.push(...formatMeta(envelope.meta)); if (!isRecord(envelope.data)) { diff --git a/src/pages/openapiSpecComponentsDaisoOliveyoung.ts b/src/pages/openapiSpecComponentsDaisoOliveyoung.ts index a5366425..4c63d899 100644 --- a/src/pages/openapiSpecComponentsDaisoOliveyoung.ts +++ b/src/pages/openapiSpecComponentsDaisoOliveyoung.ts @@ -220,9 +220,9 @@ export const OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS = { distance: { type: 'number', format: 'float', example: 0.07 }, pickupYn: { type: 'boolean', example: true }, salesStoreYn: { type: 'boolean', example: true }, - remainQuantity: { type: 'integer', example: 3 }, - o2oRemainQuantity: { type: 'integer', example: 2 }, - stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock', 'not_sold'], example: 'in_stock' }, + remainQuantity: { type: 'integer', nullable: true, example: 3 }, + o2oRemainQuantity: { type: 'integer', nullable: true, example: 2 }, + stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock', 'not_sold', 'unknown'], example: 'in_stock' }, stockLabel: { type: 'string', example: '재고 3개' }, openYn: { type: 'boolean', example: true }, }, diff --git a/src/services/oliveyoung/client.ts b/src/services/oliveyoung/client.ts index 8c685ed6..38d68327 100644 --- a/src/services/oliveyoung/client.ts +++ b/src/services/oliveyoung/client.ts @@ -275,7 +275,7 @@ async function fetchOliveyoungStockStores( const result = { totalCount: body.data?.totalCount || 0, inStockCount, - outOfStockCount: stores.length - inStockCount - notSoldCount, + outOfStockCount: stores.filter((store) => store.stockStatus === 'out_of_stock').length, notSoldCount, stores, }; @@ -344,7 +344,9 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory( ? 'in_stock' : storeInventory.stores.length > 0 && storeInventory.notSoldCount === storeInventory.stores.length ? 'not_sold' - : 'out_of_stock'; + : storeInventory.stores.some((store) => store.stockStatus === 'unknown') + ? 'unknown' + : 'out_of_stock'; const stockSource: OliveyoungProduct['stockSource'] = 'nearby_store'; return { diff --git a/src/services/oliveyoung/normalize.ts b/src/services/oliveyoung/normalize.ts index 684119aa..f3228251 100644 --- a/src/services/oliveyoung/normalize.ts +++ b/src/services/oliveyoung/normalize.ts @@ -7,7 +7,10 @@ import type { OliveyoungApiResponse, OliveyoungStockStore } from './types.js'; const OLIVEYOUNG_IMAGE_HOST = 'https://image.oliveyoung.co.kr'; const OLIVEYOUNG_IMAGE_PATH_PREFIX = '/uploads/images/goods'; -export function resolveOliveyoungInStock(o2oStockFlag: boolean, o2oRemainQuantity: number): boolean { +export function resolveOliveyoungInStock( + o2oStockFlag: boolean, + o2oRemainQuantity: number, +): boolean { return o2oStockFlag || o2oRemainQuantity > 0; } @@ -33,8 +36,11 @@ export function resolveOliveyoungImageUrl(imagePath?: string): string | undefine return `${OLIVEYOUNG_IMAGE_HOST}${OLIVEYOUNG_IMAGE_PATH_PREFIX}${normalizedPath}`; } -function resolveOliveyoungStoreQuantity(remainQuantity: number, o2oRemainQuantity: number): number { - return Math.max(remainQuantity, o2oRemainQuantity); +function resolveOliveyoungStoreQuantity(quantity: unknown): number | null { + // 미관측 수량과 유효하지 않은 수량은 실제 0개 관측과 구분합니다. + return typeof quantity === 'number' && Number.isInteger(quantity) && quantity >= 0 + ? quantity + : null; } export function resolveOliveyoungStoreStock( @@ -44,17 +50,23 @@ export function resolveOliveyoungStoreStock( : never : never, ): OliveyoungStockStore { - const remainQuantity = rawStore?.remainQuantity || 0; - const o2oRemainQuantity = rawStore?.o2oRemainQuantity || 0; - const quantity = resolveOliveyoungStoreQuantity(remainQuantity, o2oRemainQuantity); + const remainQuantity = resolveOliveyoungStoreQuantity(rawStore?.remainQuantity); + const o2oRemainQuantity = resolveOliveyoungStoreQuantity(rawStore?.o2oRemainQuantity); + const quantity = + remainQuantity === null && o2oRemainQuantity === null + ? null + : Math.max(remainQuantity ?? 0, o2oRemainQuantity ?? 0); const salesStoreYn = Boolean(rawStore?.salesStoreYn); let stockStatus: OliveyoungStockStore['stockStatus'] = 'out_of_stock'; let stockLabel = '품절'; - if (!salesStoreYn) { + if (rawStore?.salesStoreYn === false) { stockStatus = 'not_sold'; stockLabel = '미판매'; + } else if (quantity === null) { + stockStatus = 'unknown'; + stockLabel = '재고 미확인'; } else if (quantity > 0) { stockStatus = 'in_stock'; stockLabel = quantity >= 9 ? '재고 9개 이상' : `재고 ${quantity}개`; diff --git a/src/services/oliveyoung/types.ts b/src/services/oliveyoung/types.ts index 9e13ff50..b94aaf5b 100644 --- a/src/services/oliveyoung/types.ts +++ b/src/services/oliveyoung/types.ts @@ -36,9 +36,9 @@ export interface OliveyoungStockStore { distance: number; pickupYn: boolean; salesStoreYn: boolean; - remainQuantity: number; - o2oRemainQuantity: number; - stockStatus: 'in_stock' | 'out_of_stock' | 'not_sold'; + remainQuantity: number | null; + o2oRemainQuantity: number | null; + stockStatus: 'in_stock' | 'out_of_stock' | 'not_sold' | 'unknown'; stockLabel: string; openYn: boolean; } diff --git a/src/services/seveneleven/client.ts b/src/services/seveneleven/client.ts index 27dc1f72..a8ff2e62 100644 --- a/src/services/seveneleven/client.ts +++ b/src/services/seveneleven/client.ts @@ -1,3 +1,4 @@ +import { SevenElevenSearchPagination } from './searchPagination.js'; import { hasConvenienceRelay, requestConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * 세븐일레븐 API 클라이언트 @@ -130,9 +131,12 @@ function normalizeStore(raw: Record): SevenElevenStore { return { storeCode: toStringValue(raw.storeCode || raw.storeCd || raw.strCd || raw.storCd || raw.shopCd), storeName: toStringValue(raw.storeName || raw.storeNm || raw.strNm || raw.storNm || raw.shopNm), - address: toStringValue(raw.address || raw.addr || raw.roadAddr || raw.shopAddr) || mergedAddress, + address: + toStringValue(raw.address || raw.addr || raw.roadAddr || raw.shopAddr) || mergedAddress, latitude: toNumber(raw.latitude || raw.storeLat || raw.lat || raw.yPos || raw.y || raw.yCoord), - longitude: toNumber(raw.longitude || raw.storeLon || raw.lng || raw.xPos || raw.x || raw.xCoord), + longitude: toNumber( + raw.longitude || raw.storeLon || raw.lng || raw.xPos || raw.x || raw.xCoord, + ), pickupEnabled: toBooleanYn(raw.pickupEnabled || raw.pickupYn), deliveryEnabled: toBooleanYn(raw.deliveryEnabled || raw.deliveryYn || raw.dlvyYn), closeYn: toStringValue(raw.closeYn || raw.storeCloseYn || raw.closeYN || raw.clsYn), @@ -167,51 +171,65 @@ function normalizeStores(items: unknown[]): SevenElevenStore[] { .filter((store) => store.storeCode.length > 0 || store.storeName.length > 0); } -export async function searchSevenElevenProducts( - params: SearchProductsParams, +/** 전체 검색 결과를 가져와 컬렉션 간 중복을 제거합니다. */ +export async function fetchSevenElevenProductSearchResults( + query: string, options: SevenElevenRequestOptions = {}, + budget = { remaining: 10 }, ): Promise { - const { query, page = 1, size = 20 } = params; - const pageNo = Math.max(Math.trunc(page) - 1, 0); - const pageSize = Math.max(Math.trunc(size), 1); - - const response = await requestSevenElevenJson( - SEVENELEVEN_API.SEARCH_GOODS_PATH, - 'POST', - { - // 공개 검색 엔드포인트는 pageNo/pageSize만 안정적으로 동작한다. - // sort를 함께 보내면 빈 결과가 내려오는 케이스가 확인되어 제외한다. - query, - pageNo, - pageSize, - }, - options, - ); - - const data = response.data || {}; - const queryResult = data.SearchQueryResult; - const collections = queryResult?.Collection || []; - const collectionIds = collections.map((item) => item.CollectionId || '').filter((id) => id.length > 0); - - let totalCount = 0; - const allDocuments: unknown[] = []; - for (const collection of collections) { - totalCount += toNumber(collection.Documentset?.totalCount); - allDocuments.push(...(collection.Documentset?.Document || [])); + const pagination = new SevenElevenSearchPagination(); + let data: SearchGoodsData = {}; + let complete = false; + // 원본 페이지를 최대 5회 수집하고 불완전한 결과는 명시적으로 거절합니다. + for (let startCount = 0; startCount < 5; startCount += 1) { + if (budget.remaining <= 0) throw new Error('세븐일레븐 전체 상품 검색 결과가 잘렸습니다: 최대 10회 검색 제한'); + budget.remaining -= 1; + const response = await requestSevenElevenJson( + SEVENELEVEN_API.SEARCH_GOODS_PATH, 'POST', + { collection: 'goods', query, sort: 'quantity/desc,itemOnm/asc', startCount, listCount: 100 }, + options, + ); + data = response.data || {}; + const collections = data.SearchQueryResult?.Collection; + if (!Array.isArray(collections)) { + if (startCount === 0 && Array.isArray(data.content)) { + if (data.content.length > 500) throw new Error('세븐일레븐 전체 상품 검색 결과가 잘렸습니다: 최대 500개 제한'); + complete = true; + break; + } + throw new Error('세븐일레븐 전체 상품 검색 결과가 잘렸습니다'); + } + complete = pagination.append(collections); + if (complete) break; } - - const collectionProducts = normalizeProducts(allDocuments); - const contentProducts = normalizeProducts(Array.isArray(data.content) ? data.content : []); - const products = (collectionProducts.length > 0 ? collectionProducts : contentProducts).slice(0, pageSize); - + if (!complete) throw new Error('세븐일레븐 전체 상품 검색 결과가 잘렸습니다: 최대 5페이지 제한'); + const rows = normalizeProducts( + pagination.documents.length ? pagination.documents : Array.isArray(data.content) ? data.content : [], + ); + const products = [ + ...new Map( + rows.map((product) => [product.itemCode || product.productNo || product.itemName, product]), + ).values(), + ]; return { - query: queryResult?.query || query, - totalCount, + query: data.SearchQueryResult?.query || query, + totalCount: products.length, products, - collectionIds, + collectionIds: [...pagination.collectionIds], }; } +export async function searchSevenElevenProducts( + params: SearchProductsParams, + options: SevenElevenRequestOptions = {}, +): Promise { + const { query, page = 1, size = 20 } = params; + const result = await fetchSevenElevenProductSearchResults(query, options); + const pageSize = Math.max(Math.trunc(size), 1); + const offset = Math.max(Math.trunc(page) - 1, 0) * pageSize; + return { ...result, products: result.products.slice(offset, offset + pageSize) }; +} + export async function fetchSevenElevenStoresByKeyword( params: SearchStoresParams, options: SevenElevenRequestOptions = {}, diff --git a/src/services/seveneleven/productKeyword.ts b/src/services/seveneleven/productKeyword.ts index 34f819db..2d351d06 100644 --- a/src/services/seveneleven/productKeyword.ts +++ b/src/services/seveneleven/productKeyword.ts @@ -2,10 +2,7 @@ * 세븐일레븐 상품 검색어 보정 */ -import { - searchSevenElevenProducts, - type SevenElevenRequestOptions, -} from './client.js'; +import { fetchSevenElevenProductSearchResults, type SevenElevenRequestOptions } from './client.js'; import type { SevenElevenProduct, SevenElevenSearchResult } from './types.js'; interface SearchVariantOptions extends SevenElevenRequestOptions { @@ -111,7 +108,10 @@ function scoreSevenElevenProduct(product: SevenElevenProduct, rawKeyword: string } } - if (hasSandwichSuffix && SANDWICH_SUFFIX_FAMILY.some((suffix) => normalizedName.includes(suffix))) { + if ( + hasSandwichSuffix && + SANDWICH_SUFFIX_FAMILY.some((suffix) => normalizedName.includes(suffix)) + ) { score += 35; } @@ -137,29 +137,20 @@ export async function searchSevenElevenProductsWithVariants( query: string, options: SearchVariantOptions = {}, ): Promise { - const { page = 1, size = 20, sort = 'recommend', timeout, zyteApiKey } = options; + const { page = 1, size = 20, timeout, zyteApiKey } = options; const appliedQueries = buildSevenElevenProductKeywordVariants(query); const seenKeys = new Set(); const collectionIds = new Set(); const mergedProducts: SevenElevenProduct[] = []; - let totalCount = 0; + const budget = { remaining: 10 }; for (const candidate of appliedQueries) { - const result = await searchSevenElevenProducts( - { - query: candidate, - page, - size, - sort, - }, - { - ...options, - timeout, - zyteApiKey, - }, - ); - - totalCount = Math.max(totalCount, result.totalCount); + const result = await fetchSevenElevenProductSearchResults(candidate, { + ...options, + timeout, + zyteApiKey, + }, budget); + for (const collectionId of result.collectionIds) { collectionIds.add(collectionId); } @@ -186,11 +177,13 @@ export async function searchSevenElevenProductsWithVariants( return 0; }); - const pagedProducts = rankedProducts.slice(0, size); + const pageSize = Math.max(Math.trunc(size), 1); + const offset = Math.max(Math.trunc(page) - 1, 0) * pageSize; + const pagedProducts = rankedProducts.slice(offset, offset + pageSize); return { query, - totalCount: Math.max(totalCount, rankedProducts.length), + totalCount: rankedProducts.length, products: pagedProducts, collectionIds: [...collectionIds], appliedQueries, diff --git a/src/services/seveneleven/searchPagination.ts b/src/services/seveneleven/searchPagination.ts new file mode 100644 index 00000000..ba6944f6 --- /dev/null +++ b/src/services/seveneleven/searchPagination.ts @@ -0,0 +1,65 @@ +/** 컬렉션별 검색 페이지의 합계와 상품 진행 상태를 검증합니다. */ +interface SearchCollection { + CollectionId?: string; + Documentset?: { totalCount?: number; Document?: unknown[] }; +} +interface CollectionProgress { + total: number; + received: number; + identities: Set; +} +const incomplete = () => new Error('세븐일레븐 전체 상품 검색 결과가 잘렸습니다'); +function documentIdentity(document: unknown): string { + const record = (document || {}) as Record; + const field = (record.field || record) as Record; + return String(field.itemCd || field.prdNo || field.itemOnm || JSON.stringify(document)); +} +export class SevenElevenSearchPagination { + readonly documents: unknown[] = []; + readonly collectionIds = new Set(); + private readonly collections = new Map(); + private initialized = false; + + append(collections: SearchCollection[]): boolean { + const present = new Set(); + for (const collection of collections) { + const id = collection.CollectionId || ''; + if (present.has(id)) throw incomplete(); + present.add(id); + if (!collection.Documentset || !Array.isArray(collection.Documentset.Document)) + throw incomplete(); + const total = collection.Documentset.totalCount; + if (typeof total !== 'number' || !Number.isInteger(total) || total < 0) throw incomplete(); + let progress = this.collections.get(id); + if (!progress) { + if (this.initialized) throw incomplete(); + progress = { total, received: 0, identities: new Set() }; + this.collections.set(id, progress); + if (id) this.collectionIds.add(id); + } + if (progress.total !== total) throw incomplete(); + const documents = collection.Documentset.Document; + const identities = documents.map(documentIdentity); + if (new Set(identities).size !== identities.length) throw incomplete(); + // 컬렉션 사이의 중복은 허용하되 이전 원본 페이지 상품의 반복은 거절합니다. + if (identities.some((identity) => progress.identities.has(identity))) throw incomplete(); + if (progress.received + documents.length > total) throw incomplete(); + progress.received += documents.length; + for (const identity of identities) progress.identities.add(identity); + this.documents.push(...documents); + } + this.initialized = true; + if ([...this.collections.values()].reduce((sum, progress) => sum + progress.total, 0) > 500) { + throw new Error('세븐일레븐 전체 상품 검색 결과가 잘렸습니다: 최대 500개 제한'); + } + for (const [id, progress] of this.collections) { + if ( + progress.received < progress.total && + (!present.has(id) || + !collections.find((c) => (c.CollectionId || '') === id)?.Documentset?.Document?.length) + ) + throw incomplete(); + } + return [...this.collections.values()].every((progress) => progress.received === progress.total); + } +} diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 73d32810..a0a3ac61 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -9,6 +9,7 @@ interface EdgeCacheOptions { ttlSeconds: number; staleWhileRevalidateSeconds?: number; keyPrefix?: string; + shouldCache?: (response: Response) => Promise; } function getCacheStorage(): CacheStorage | undefined { @@ -56,12 +57,12 @@ export async function withEdgeCache( const cacheStorage = getCacheStorage(); const cache = cacheStorage?.default; - if (!cache) { + if (!cache && !options.shouldCache) { return fetcher(); } const cacheKey = createCacheKey(requestUrl, options.keyPrefix); - const cachedResponse = await cache.match(cacheKey); + const cachedResponse = await cache?.match(cacheKey); if (cachedResponse) { return cachedResponse; @@ -69,7 +70,12 @@ export async function withEdgeCache( const response = await fetcher(); - if (!response.ok) { + if (options.shouldCache && !(await options.shouldCache(response.clone()))) { + response.headers.set('Cache-Control', 'no-store'); + return response; + } + + if (!cache || !response.ok) { return response; } diff --git a/src/utils/cliInteractiveHelpers.ts b/src/utils/cliInteractiveHelpers.ts index 351a3b6e..0578f2a4 100644 --- a/src/utils/cliInteractiveHelpers.ts +++ b/src/utils/cliInteractiveHelpers.ts @@ -4,6 +4,7 @@ import { buildDaisoStoreKeywordVariants } from './daisoKeyword.js'; interface InteractiveStore { + storeCode?: string; name: string; address: string; phone: string; @@ -48,6 +49,7 @@ export function parseStores(payload: unknown): InteractiveStore[] { } result.push({ + ...(toText(store.storeCode) ? { storeCode: toText(store.storeCode) } : {}), name, address: toText(store.address), phone: toText(store.phone), diff --git a/tests/app/cliInteractive.helpers.test.ts b/tests/app/cliInteractive.helpers.test.ts index 45125643..5bf0186a 100644 --- a/tests/app/cliInteractive.helpers.test.ts +++ b/tests/app/cliInteractive.helpers.test.ts @@ -377,7 +377,7 @@ describe('cliInteractiveTestables', () => { createPrompt(['선크림', '1']), store, ); - expect(out.join('\n')).toContain('남은수량: 3'); + expect(out.join('\n')).toContain('남은수량: 확인 불가'); }); it('runCuItemSearch의 예외/성공 분기를 처리한다', async () => { @@ -487,8 +487,8 @@ describe('cliInteractiveTestables', () => { createPrompt(['과자', '1']), store, ); - expect(out.join('\n')).toContain('픽업 가능: 예'); - expect(out.join('\n')).toContain('배달 가능: 아니오'); + expect(out.join('\n')).toContain('픽업 가능: 확인 불가'); + expect(out.join('\n')).toContain('배달 가능: 확인 불가'); await cliInteractiveTestables.runCuItemSearch( { @@ -518,8 +518,8 @@ describe('cliInteractiveTestables', () => { createPrompt(['음료', '1']), store, ); - expect(out.join('\n')).toContain('픽업 가능: 아니오'); - expect(out.join('\n')).toContain('배달 가능: 예'); - expect(out.join('\n')).toContain('예약 가능: 예'); + expect(out.join('\n')).toContain('픽업 가능: 확인 불가'); + expect(out.join('\n')).toContain('배달 가능: 확인 불가'); + expect(out.join('\n')).toContain('예약 가능: 확인 불가'); }); }); diff --git a/tests/app/cliInteractive.storeInventory.test.ts b/tests/app/cliInteractive.storeInventory.test.ts new file mode 100644 index 00000000..fbc79db9 --- /dev/null +++ b/tests/app/cliInteractive.storeInventory.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; +import { parseStores } from '../../src/utils/cliInteractiveHelpers.js'; +import { matchSelectedStore, quantityText } from '../../src/cli/interactiveStoreInventory.js'; +import { runCuItemSearch, runOliveyoungItemSearch } from '../../src/cli/interactiveItemSearch.js'; + +const store = { storeCode: 'DF23', name: '선택점', address: '주소', phone: '' }; +function response(data: unknown) { return new Response(JSON.stringify({ success: true, data })); } +function setup(data: unknown, selection = '1', nextData?: unknown) { + const out: string[] = []; + const answers = ['검색', selection]; + const fetchImpl = vi.fn().mockResolvedValueOnce(response(data)); + if (nextData !== undefined) fetchImpl.mockResolvedValueOnce(response(nextData)); + return { out, fetchImpl, deps: { fetchImpl, writeOut: (line: string) => out.push(line), writeErr: () => {} }, + prompt: { ask: async () => answers.shift() ?? '', close: () => {} } }; +} +const product = { goodsNumber: 'G1', goodsName: '상품', priceToPay: 1000, o2oRemainQuantity: 0 }; +const items = [{ itemCode: 'A', itemName: '첫상품', price: 1000, pickupYn: true }, + { itemCode: 'B', itemName: '다른상품', price: 2000, pickupYn: true }]; +function cuData(code: unknown = 'A', extra: Record = {}) { + return { inventory: { items }, nearbyStores: { available: true, stockItemCode: code, + stores: [{ storeCode: 'DF23', storeName: '선택점', address: '주소', stock: 48, + pickupYn: false, deliveryYn: true, reserveYn: false }], ...extra } }; +} + +describe('선택 매장 재고의 상품/매장 일치', () => { + it('매장 코드를 선택 과정에서 보존한다', () => { + expect(parseStores({ success: true, data: { stores: [store] } })[0]).toMatchObject({ storeCode: 'DF23' }); + }); + it('올리브영은 동일 이름의 다른 매장이 아닌 선택 코드의 실제 재고를 표시한다', async () => { + const s = setup({ inventory: { products: [{ ...product, storeInventory: { stores: [ + { storeCode: 'OTHER', storeName: store.name, remainQuantity: 99 }, + { storeCode: 'DF23', storeName: '다른표기', remainQuantity: 10, o2oRemainQuantity: 8, stockStatus: 'in_stock' }, + ] } }] } }); + await runOliveyoungItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 남은수량: 10'); + expect(s.out).toContain('- O2O 남은수량: 8'); + expect(s.out).toContain('- 재고 상태: 재고 있음'); + }); + it.each([ + [{ storeCode: 'DF23', remainQuantity: 0, o2oRemainQuantity: 0, stockStatus: 'out_of_stock' }, '0', '품절'], + [{ storeCode: 'DF23', stockStatus: 'not_sold' }, '확인 불가', '미취급'], + [{ storeCode: 'DF23', remainQuantity: null }, '확인 불가', '확인 불가'], + [{ storeCode: 'OTHER', storeName: store.name, remainQuantity: 99 }, '확인 불가', '확인 불가'], + ])('올리브영 수량과 미취급/불명 상태를 구분한다 %#', async (entry, quantity, status) => { + const s = setup({ inventory: { products: [{ ...product, storeInventory: { stores: [entry] } }] } }); + await runOliveyoungItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain(`- 남은수량: ${quantity}`); + expect(s.out).toContain(`- 재고 상태: ${status}`); + }); + it('올리브영 미조회 후보는 재조회 후 동일 상품의 매장 재고를 표시한다', async () => { + const chosen = { ...product, goodsNumber: 'G6', goodsName: '여섯번째' }; + const s = setup({ inventory: { products: [product, chosen] } }, '2', { + inventory: { products: [{ ...chosen, storeInventory: { stores: [ + { storeCode: 'DF23', remainQuantity: 10, o2oRemainQuantity: 8, stockStatus: 'in_stock' }, + ] } }] }, + }); + await runOliveyoungItemSearch(s.deps, s.prompt, store); + expect(new URL(String(s.fetchImpl.mock.calls[1]?.[0])).searchParams.get('keyword')).toBe('여섯번째'); + expect(s.out).toContain('- 남은수량: 10'); + }); + it('올리브영 재조회 상품 코드가 다르면 재고를 사용하지 않는다', async () => { + const s = setup({ inventory: { products: [product] } }, '1', { + inventory: { products: [{ ...product, goodsNumber: 'OTHER', storeInventory: { stores: [ + { storeCode: 'DF23', remainQuantity: 99, stockStatus: 'in_stock' }, + ] } }] }, + }); + await runOliveyoungItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 남은수량: 확인 불가'); + }); + it('CU는 선택 매장의 수량과 채널을 표시한다', async () => { + const s = setup(cuData()); + await runCuItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 재고 수량: 48'); + expect(s.out).toContain('- 픽업 가능: 아니오'); + expect(s.out).toContain('- 배달 가능: 예'); + expect(s.fetchImpl).toHaveBeenCalledTimes(1); + }); + it('CU의 다른 상품 선택은 상품명으로 다시 조회하고 코드가 일치한 재고를 쓴다', async () => { + const s = setup(cuData(), '2', cuData('B')); + await runCuItemSearch(s.deps, s.prompt, store); + expect(new URL(String(s.fetchImpl.mock.calls[1]?.[0])).searchParams.get('keyword')).toBe('다른상품'); + expect(s.out).toContain('- 재고 수량: 48'); + }); + it.each([cuData('A'), cuData('B', { available: false }), cuData(null)])( + 'CU 재조회 상품 불일치/조회불가/코드 누락은 수량과 채널을 불명으로 표시한다 %#', async (next) => { + const s = setup(cuData(), '2', next); + await runCuItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 재고 수량: 확인 불가'); + expect(s.out).toContain('- 픽업 가능: 확인 불가'); + }); +}); + +// 불완전한 응답에서 다른 매장의 재고나 수량 0을 만들어내지 않는다. +describe('선택 매장 재고의 불완전한 응답', () => { + it.each([0, null, undefined, -1, 'invalid'])('CU 수량 %s의 의미를 보존한다', async (stock) => { + const data = cuData(); + data.nearbyStores.stores[0].stock = stock as number; + const s = setup(data); + await runCuItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain(`- 재고 수량: ${stock === 0 ? '0' : '확인 불가'}`); + }); + it('CU 같은 이름의 다른 코드 매장 채널을 가져오지 않는다', async () => { + const data = cuData(); + data.nearbyStores.stores[0].storeCode = 'OTHER'; + const s = setup(data); + await runCuItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 재고 수량: 확인 불가'); + expect(s.out).toContain('- 배달 가능: 확인 불가'); + }); + it('CU 상품 재조회 HTTP 실패를 불명으로 표시한다', async () => { + const s = setup(cuData(), '2'); + s.fetchImpl.mockRejectedValueOnce(new Error('offline')); + await runCuItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 재고 수량: 확인 불가'); + }); + it.each([ + { success: false }, + { success: true, data: {} }, + { success: true, data: { inventory: { products: [] } } }, + ])('올리브영 재조회 불완전 응답은 불명이다 %#', async (next) => { + const s = setup({ inventory: { products: [product] } }); + s.fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify(next))); + await runOliveyoungItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 남은수량: 확인 불가'); + }); +}); + + +describe('매장 식별과 수량 검증', () => { + it('코드가 없으면 고유한 이름/주소 쌍으로만 매장을 찾는다', () => { + const entry = { storeName: store.name, address: store.address, stock: 48 }; + expect(matchSelectedStore([entry], store)).toBe(entry); + expect(matchSelectedStore([entry, entry], store)).toBeUndefined(); + expect(matchSelectedStore([{ ...entry, address: '다른 주소' }], store)).toBeUndefined(); + expect(matchSelectedStore([entry], { ...store, address: '' })).toBeUndefined(); + expect(matchSelectedStore([entry], { ...store, storeCode: undefined })).toBe(entry); + }); + it('중복 코드나 잘못된 목록을 임의로 선택하지 않는다', () => { + expect(matchSelectedStore([{ storeCode: 'DF23' }, { storeCode: 'DF23' }], store)).toBeUndefined(); + expect(matchSelectedStore(undefined, store)).toBeUndefined(); + expect(matchSelectedStore([null, 'bad'], store)).toBeUndefined(); + }); + it.each([[0, '0'], ['8', '8'], ['', '확인 불가'], [true, '확인 불가'], [Infinity, '확인 불가']])( + '수량 %s를 정확히 표시한다', (input, expected) => expect(quantityText(input)).toBe(expected), + ); +}); + +// 2026-10-05 실제 REST 응답의 선택 상품/매장 필드만 남긴 fixture. +// REST nearbyStores에는 도구 응답과 달리 available 필드가 없다. +const cuRestData = { + keyword: '레쓰비', + nearbyStores: { + totalCount: 1, stockItemCode: '8801056018979', stockItemName: '레쓰비)캔200ml', + stores: [{ storeCode: '24008', storeName: '강남푸르지오점', address: '선택 주소', stock: 48, + pickupYn: false, deliveryYn: false, reserveYn: false }], + }, + inventory: { available: true, items: [{ itemCode: '8801056018979', itemName: '레쓰비)캔200ml', + price: 1300, pickupYn: true, deliveryYn: true, reserveYn: false }] }, +}; +const cuRestStore = { storeCode: '24008', name: '강남푸르지오점', address: '선택 주소', phone: '' }; + +describe('CU REST와 웹 fallback 계약', () => { + it('실제 REST의 available 없는 nearbyStores에서 매장 재고 48/픽업 불가를 표시한다', async () => { + const s = setup(cuRestData); + await runCuItemSearch(s.deps, s.prompt, cuRestStore); + expect(s.out).toContain('- 재고 수량: 48'); + expect(s.out).toContain('- 픽업 가능: 아니오'); + expect(s.fetchImpl).toHaveBeenCalledTimes(1); + }); + it.each([null, undefined, -1, 0.5, 'invalid'])('웹 fallback/불명 수량 %s의 더미 채널을 불가로 단정하지 않는다', async (stock) => { + const data = structuredClone(cuRestData); + Object.assign(data.nearbyStores.stores[0], { stock, latitude: 0, longitude: 0, + pickupYn: false, deliveryYn: false, reserveYn: false }); + const s = setup(data); + await runCuItemSearch(s.deps, s.prompt, cuRestStore); + expect(s.out).toContain('- 재고 수량: 확인 불가'); + expect(s.out).toContain('- 픽업 가능: 확인 불가'); + expect(s.out).toContain('- 배달 가능: 확인 불가'); + expect(s.out).toContain('- 예약 가능: 확인 불가'); + }); + it('available true 응답도 재고 없는 웹 fallback의 더미 채널을 검증된 불가로 표시하지 않는다', async () => { + const data = cuData(); + Object.assign(data.nearbyStores.stores[0], { stock: null, latitude: 0, longitude: 0, + pickupYn: false, deliveryYn: false, reserveYn: false }); + const s = setup(data); + await runCuItemSearch(s.deps, s.prompt, store); + expect(s.out).toContain('- 픽업 가능: 확인 불가'); + expect(s.out).toContain('- 배달 가능: 확인 불가'); + }); + it('실제 REST의 확정 재고 0과 실제 false 채널은 보존한다', async () => { + const data = structuredClone(cuRestData); + data.nearbyStores.stores[0].stock = 0; + const s = setup(data); + await runCuItemSearch(s.deps, s.prompt, cuRestStore); + expect(s.out).toContain('- 재고 수량: 0'); + expect(s.out).toContain('- 픽업 가능: 아니오'); + }); + it('소수 수량을 재고 수량으로 표시하지 않는다', () => { + expect(quantityText(0.5)).toBe('확인 불가'); + expect(quantityText('1.5')).toBe('확인 불가'); + }); +}); diff --git a/tests/app/cliInteractive.test.ts b/tests/app/cliInteractive.test.ts index 5cd12c4d..6879144b 100644 --- a/tests/app/cliInteractive.test.ts +++ b/tests/app/cliInteractive.test.ts @@ -263,7 +263,7 @@ describe('runInteractiveCli', () => { success: true, data: { inventory: { - products: [{ goodsNumber: 'G1', goodsName: '선크림A', priceToPay: 12000, o2oRemainQuantity: 3 }], + products: [{ goodsNumber: 'G1', goodsName: '선크림A', priceToPay: 12000, o2oRemainQuantity: 0, storeInventory: { stores: [{ storeName: '올리브영 강남점', address: '서울 강남구', remainQuantity: 3, o2oRemainQuantity: 3, stockStatus: 'in_stock' }] } }], }, }, }), @@ -273,7 +273,7 @@ describe('runInteractiveCli', () => { success: true, data: { inventory: { - products: [{ goodsNumber: 'G2', goodsName: '립밤B', priceToPay: 8000, o2oRemainQuantity: 1 }], + products: [{ goodsNumber: 'G2', goodsName: '립밤B', priceToPay: 8000, o2oRemainQuantity: 0, storeInventory: { stores: [{ storeName: '올리브영 강남점', address: '서울 강남구', remainQuantity: 1, o2oRemainQuantity: 1, stockStatus: 'in_stock' }] } }], }, }, }), @@ -372,6 +372,8 @@ describe('runInteractiveCli', () => { expect(exitCode).toBe(0); expect(output.join('\n')).toContain('- 상품: 감자칩'); + expect(output.join('\n')).toContain('- 재고 수량: 확인 불가'); + expect(output.join('\n')).toContain('- 픽업 가능: 확인 불가'); expect(fetchImpl).toHaveBeenNthCalledWith(1, 'https://mcp.aka.page/api/cu/stores?keyword=%EA%B0%95%EB%82%A8&limit=10'); expect(fetchImpl).toHaveBeenNthCalledWith( 2, diff --git a/tests/app/followup-compare-cache.test.ts b/tests/app/followup-compare-cache.test.ts new file mode 100644 index 00000000..3d4f68d7 --- /dev/null +++ b/tests/app/followup-compare-cache.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import app from '../../src/index.js'; +import { registerCompareRoutes } from '../../src/api/routes/compareRoutes.js'; +import { renderApiEnvelope } from '../../src/cliRenderer.js'; + +const handler = vi.hoisted(() => vi.fn()); +vi.mock('../../src/api/compareHandlers.js', () => ({ handleCompareProducts: handler })); +afterEach(() => { vi.unstubAllGlobals(); handler.mockReset(); }); + +function installCache() { + const entries = new Map(); + const match = vi.fn(async (request: Request) => entries.get(request.url)?.clone()); + const put = vi.fn(async (request: Request, response: Response) => { entries.set(request.url, response.clone()); }); + vi.stubGlobal('caches', { default: { match, put } }); + return { entries, match, put }; +} + +const complete = { success: true, data: { results: [{ name: '콜라', price: 1300 }], errors: [] } }; +const partial = { success: true, data: { ...complete.data, errors: [{ service: 'emart24', message: 'HTTP 403' }] } }; +const url = '/api/compare/products?keyword=콜라'; + +describe('비교 캐시 복구', () => { + it('부분 실패 다음 요청에서 원본을 재조회하고 복구된 결과만 캐시한다', async () => { + const cache = installCache(); + handler.mockResolvedValueOnce(Response.json(partial)).mockResolvedValueOnce(Response.json(complete)); + const first = await app.request(url); + expect(await first.json()).toEqual(partial); + const recovered = await app.request(url); + expect(await recovered.json()).toEqual(complete); + expect(handler).toHaveBeenCalledTimes(2); + expect(first.headers.get('Cache-Control')).toBe('no-store'); + expect(cache.put).toHaveBeenCalledTimes(1); + const cached = await app.request(url); + expect(await cached.json()).toEqual(complete); + expect(handler).toHaveBeenCalledTimes(2); + expect(cached.headers.get('Cache-Control')).toContain('max-age=600'); + }); + + it('기존 v2 캐시를 건너뛴다', async () => { + const cache = installCache(); + cache.entries.set(new URL(`${url}&__cache_prefix=compare-products-v2`, 'http://localhost').href, Response.json(partial)); + handler.mockResolvedValue(Response.json(complete)); + const response = await app.request(url); + expect(await response.json()).toEqual(complete); + expect(handler).toHaveBeenCalledTimes(1); + expect(cache.put.mock.calls[0][0].url).toContain('compare-products-v3'); + }); + + it.each([null, [], {}, { success: false, data: complete.data }, { success: true }, + { success: true, data: null }, { success: true, data: {} }, + { success: true, data: { results: [], errors: 'unknown' } }, + { success: true, data: { results: null, errors: [] } }, + ])('잘못된 비교 응답을 저장하지 않는다: %j', async (payload) => { + const cache = installCache(); + handler.mockResolvedValue(Response.json(payload)); + const response = await app.request(url); + expect(await response.json()).toEqual(payload); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(cache.put).not.toHaveBeenCalled(); + }); + + it('JSON 파싱 실패 응답도 저장하지 않는다', async () => { + const cache = installCache(); + handler.mockResolvedValue(new Response('invalid json')); + const response = await app.request(url); + expect(await response.text()).toBe('invalid json'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(cache.put).not.toHaveBeenCalled(); + }); + + it('캐시 없는 환경의 부분 실패도 no-store를 반환한다', async () => { + vi.stubGlobal('caches', undefined); + handler.mockResolvedValue(Response.json(partial)); + const route = new Hono(); + registerCompareRoutes(route); + const response = await route.request(url); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual(partial); + }); + + it('HTTP 오류를 저장하지 않고 no-store를 반환한다', async () => { + const cache = installCache(); + handler.mockResolvedValue(Response.json({ success: false, error: 'failure' }, { status: 500 })); + const response = await app.request(url); + expect(response.status).toBe(500); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(cache.put).not.toHaveBeenCalled(); + }); +}); + +describe('비교 CLI 부분 실패', () => { + it('실패 서비스와 원인을 표시하고 성공 결과를 보존한다', () => { + const output = renderApiEnvelope('compare', new URL(url, 'https://example.com'), partial); + expect(output).toContain('일부 서비스 조회 실패'); + expect(output).toContain('emart24: HTTP 403'); + expect(output).toContain('콜라'); + expect(output).not.toContain('요청 성공:'); + }); + it('알 수 없는 서비스 오류도 누락하지 않는다', () => { + const output = renderApiEnvelope('compare', new URL(url, 'https://example.com'), { + success: true, data: { results: [], errors: [{ service: 'unknown', message: '장애' }, '읽기 실패', {}] }, + }); + expect(output).toContain('unknown: 장애'); + expect(output).toContain('읽기 실패'); + expect(output).toContain('-: -'); + }); + it('정상 비교는 성공으로 표시한다', () => { + expect(renderApiEnvelope('compare', new URL(url, 'https://example.com'), complete)).toContain('요청 성공:'); + }); +}); diff --git a/tests/app/followup-compare-recovery.test.ts b/tests/app/followup-compare-recovery.test.ts new file mode 100644 index 00000000..af0eaa9a --- /dev/null +++ b/tests/app/followup-compare-recovery.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; + +afterEach(() => vi.unstubAllGlobals()); + +it('이마트24 403 이후 같은 검색의 원본을 재호출해 정상 가격을 복구한다', async () => { + const entries = new Map(); + const put = vi.fn(async (request: Request, response: Response) => { entries.set(request.url, response.clone()); }); + vi.stubGlobal('caches', { default: { + match: async (request: Request) => entries.get(request.url)?.clone(), put, + } }); + let recovering = false; + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes('everse.emart24.co.kr')) { + return recovering ? Response.json({ totalCnt: 1, productList: [{ pluCd: 'E', goodsNm: '복구 콜라', viewPrice: 1300 }] }) + : new Response('Forbidden', { status: 403 }); + } + return Response.json({ resultSet: { result: [{ totalSize: 1, resultDocuments: [{ PD_NO: 'D', PDNM: '다이소 콜라', PD_PRC: '1500' }] }] } }); + }); + vi.stubGlobal('fetch', fetcher); + const url = '/api/compare/products?keyword=콜라&services=daiso,emart24&limit=1'; + const first = await app.request(url); + expect(await first.json()).toMatchObject({ success: true, data: { + results: [{ service: 'daiso' }], errors: [{ service: 'emart24', message: expect.stringContaining('403') }], + } }); + recovering = true; + const recovered = await app.request(url); + expect(await recovered.json()).toMatchObject({ success: true, data: { + results: [{ service: 'emart24', name: '복구 콜라' }, { service: 'daiso' }], errors: [], bestPrice: { price: 1300 }, + } }); + expect(first.headers.get('Cache-Control')).toBe('no-store'); + expect(fetcher).toHaveBeenCalledTimes(4); + expect(put).toHaveBeenCalledTimes(1); + await app.request(url); + expect(fetcher).toHaveBeenCalledTimes(4); +}); diff --git a/tests/relay/convenience.test.ts b/tests/relay/convenience.test.ts index 37508dfd..1f12b62f 100644 --- a/tests/relay/convenience.test.ts +++ b/tests/relay/convenience.test.ts @@ -35,13 +35,30 @@ describe('편의점 고정 작업 중계', () => { expect( ( await relay( - req('seven-goods', { query: '커피', pageNo: 0, pageSize: 20, url: 'https://evil' }), + req('seven-goods', { + query: '커피', + collection: 'goods', + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 20, + url: 'https://evil', + }), ) ).status, ).toBe(400); expect(fetcher).not.toHaveBeenCalled(); expect( - (await relay(req('seven-goods', { query: '커피', pageNo: 0, pageSize: 20 }))).status, + ( + await relay( + req('seven-goods', { + query: '커피', + collection: 'goods', + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 20, + }), + ) + ).status, ).toBe(502); }); }); @@ -235,9 +252,42 @@ it.each([ ], ['cu-store', storeBody, { resp_cd: '0000', storeList: [] }, 200], ['cu-store', storeBody, { resp_cd: '3000', storeList: [] }, 502], - ['seven-goods', { query: '과자', pageNo: 0, pageSize: 20 }, { data: [], code: 199 }, 502], - ['seven-goods', { query: '과자', pageNo: 0, pageSize: 20 }, { data: [], code: 300 }, 502], - ['seven-goods', { query: '과자', pageNo: 0, pageSize: 20 }, { code: 200 }, 502], + [ + 'seven-goods', + { + query: '과자', + collection: 'goods', + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 20, + }, + { data: [], code: 199 }, + 502, + ], + [ + 'seven-goods', + { + query: '과자', + collection: 'goods', + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 20, + }, + { data: [], code: 300 }, + 502, + ], + [ + 'seven-goods', + { + query: '과자', + collection: 'goods', + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 20, + }, + { code: 200 }, + 502, + ], ])('성공 및 실패 코드 검사 %s %j', async (op, body, data, status) => { const relay = createConvenienceRelay('convenience', { fetcher: async () => Response.json(data), @@ -285,3 +335,10 @@ it('정수 범위를 벗어나는 재고 문자열을 거절한다', async () => }); expect((await relay(req('seven-stock', realStockBody))).status).toBe(502); }); + +it('구형 상품 요청도 호환 중계해 먼저 배포할 수 있다', async () => { + const fetcher = vi.fn(async () => Response.json({ data: {} })); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect((await relay(req('seven-goods', { query: '커피', pageNo: 0, pageSize: 20 }))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(1); +}); diff --git a/tests/services/inventory-quality.test.ts b/tests/services/inventory-quality.test.ts index fee93016..9d39a973 100644 --- a/tests/services/inventory-quality.test.ts +++ b/tests/services/inventory-quality.test.ts @@ -88,22 +88,19 @@ it('Seven respects unmatched requested region', async () => { expect(sevenLocation.stores).toEqual([]); expect(sevenLocation.stockAvailable).toBe(false); }); -it('Seven preserves upstream page and total', async () => { +it('Seven paginates the complete deduplicated corpus', async () => { clearSevenElevenReadCache(); - let sevenRequestedPage: number | undefined; + let body: unknown; globalThis.fetch = async (_input, init) => { - sevenRequestedPage = JSON.parse(String(init?.body)).pageNo; - return json({ success: true, data: { SearchQueryResult: { Collection: [{ Documentset: { - totalCount: 40, - Document: Array.from({ length: 20 }, (_, i) => ({ field: { itemCd: `P${20 + i}`, itemOnm: `콜라${20 + i}` } })), - } }] } } }); + body = JSON.parse(String(init?.body)); + return json({ success: true, data: { SearchQueryResult: { Collection: [{ CollectionId: 'offline', Documentset: { + totalCount: 7, Document: Array.from({ length: 7 }, (_, index) => ({ field: { itemCd: String(index + 1), itemOnm: '콜라' } })), + } }] } } }); }; - const sevenPage = await searchSevenElevenProductsWithVariants('콜라', { page: 2, size: 20 }); - assert.equal(sevenRequestedPage, 1); - assert.equal(sevenPage.products.length, 20); - assert.equal(sevenPage.totalCount, 40); - - + const sevenPage = await searchSevenElevenProductsWithVariants('콜라', { page: 2, size: 3 }); + expect(sevenPage.totalCount).toBe(7); + expect(sevenPage.products.map(p => p.itemCode)).toEqual(['4', '5', '6']); + expect(body).toEqual({ collection: 'goods', query: '콜라', sort: 'quantity/desc,itemOnm/asc', startCount: 0, listCount: 100 }); }); it('Emart24 absent quantity remains unknown', async () => { globalThis.fetch = async input => { diff --git a/tests/services/oliveyoung/client.test.ts b/tests/services/oliveyoung/client.test.ts index 27fceb54..ca5f3291 100644 --- a/tests/services/oliveyoung/client.test.ts +++ b/tests/services/oliveyoung/client.test.ts @@ -1163,10 +1163,10 @@ describe('enrichOliveyoungProductsWithNearbyStoreInventory', () => { distance: 0, pickupYn: false, salesStoreYn: false, - remainQuantity: 0, - o2oRemainQuantity: 0, - stockStatus: 'not_sold', - stockLabel: '미판매', + remainQuantity: null, + o2oRemainQuantity: null, + stockStatus: 'unknown', + stockLabel: '재고 미확인', openYn: false, }); }); diff --git a/tests/services/oliveyoung/store-observation.test.ts b/tests/services/oliveyoung/store-observation.test.ts new file mode 100644 index 00000000..0d4e5a80 --- /dev/null +++ b/tests/services/oliveyoung/store-observation.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { resolveOliveyoungStoreStock } from '../../../src/services/oliveyoung/normalize.js'; +import { + __testOnlyClearOliveyoungCaches, + enrichOliveyoungProductsWithNearbyStoreInventory, +} from '../../../src/services/oliveyoung/client.js'; +import { generateFullOpenApiSpec } from '../../../src/pages/openapi.js'; + +it.each([undefined, null, -1, 1.5, NaN, Infinity, '3', '', false])( + '미관측 또는 유효하지 않은 수량 %s를 0으로 바꾸지 않는다', + (quantity) => { + const store = resolveOliveyoungStoreStock({ + storeCode: 'DF23', + salesStoreYn: true, + remainQuantity: quantity as number, + o2oRemainQuantity: quantity as number, + }); + expect(store).toMatchObject({ + remainQuantity: null, + o2oRemainQuantity: null, + stockStatus: 'unknown', + stockLabel: '재고 미확인', + }); + }, +); +it('두 수량이 빠진 판매 매장의 재고는 미확인이다', () => { + expect(resolveOliveyoungStoreStock({ storeCode: 'DF23', salesStoreYn: true })).toMatchObject({ + remainQuantity: null, + o2oRemainQuantity: null, + stockStatus: 'unknown', + }); +}); +it.each([ + [{ remainQuantity: 0 }, 'out_of_stock', '품절'], + [{ o2oRemainQuantity: 0 }, 'out_of_stock', '품절'], + [{ remainQuantity: 0, o2oRemainQuantity: 0 }, 'out_of_stock', '품절'], + [{ remainQuantity: 3 }, 'in_stock', '재고 3개'], + [{ o2oRemainQuantity: 9 }, 'in_stock', '재고 9개 이상'], + [{ remainQuantity: 2, o2oRemainQuantity: 5 }, 'in_stock', '재고 5개'], + [{ remainQuantity: -1, o2oRemainQuantity: 5 }, 'in_stock', '재고 5개'], +])('관측한 유효 수량의 상태를 유지한다: %j', (quantities, stockStatus, stockLabel) => { + expect(resolveOliveyoungStoreStock({ salesStoreYn: true, ...quantities })).toMatchObject({ + stockStatus, + stockLabel, + }); +}); +it('명시적 미판매 상태는 수량이 없어도 유지한다', () => { + expect(resolveOliveyoungStoreStock({ salesStoreYn: false })).toMatchObject({ + remainQuantity: null, + o2oRemainQuantity: null, + stockStatus: 'not_sold', + }); +}); +it('판매 여부와 수량 모두 빠진 매장을 미판매로 추정하지 않는다', () => { + expect(resolveOliveyoungStoreStock({ storeCode: 'DF23' })).toMatchObject({ + remainQuantity: null, + o2oRemainQuantity: null, + stockStatus: 'unknown', + }); +}); +it('판매 여부가 빠져도 양수 수량은 관측 재고로 유지한다', () => { + expect(resolveOliveyoungStoreStock({ remainQuantity: 2 })).toMatchObject({ + stockStatus: 'in_stock', + }); +}); +it.each(['false', 'true', 0, 1, null])( + '유효하지 않은 판매 여부 %s만으로 미판매를 추정하지 않는다', + (salesStoreYn) => { + expect(resolveOliveyoungStoreStock({ salesStoreYn: salesStoreYn as boolean })).toMatchObject({ + stockStatus: 'unknown', + }); + }, +); +const fetchMock = vi.fn(); +beforeEach(() => { + __testOnlyClearOliveyoungCaches(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); +it.each([ + [[{ salesStoreYn: true }], 'unknown', 0, 0, 0], + [ + [{ salesStoreYn: true }, { salesStoreYn: true, remainQuantity: 0 }, { salesStoreYn: false }], + 'unknown', + 0, + 1, + 1, + ], + [[{ salesStoreYn: true }, { salesStoreYn: true, remainQuantity: 1 }], 'in_stock', 1, 0, 0], +])( + '주변 매장 미확인 상태를 상품과 품절 집계에 반영한다: %j', + async (storeList, stockStatus, inStockCount, outOfStockCount, notSoldCount) => { + fetchMock + .mockResolvedValueOnce( + Response.json({ status: 'SUCCESS', data: { goodsInfo: { masterGoodsNumber: '8801' } } }), + ) + .mockResolvedValueOnce(Response.json({ status: 'SUCCESS', data: { storeList } })); + const product = { + goodsNumber: 'A1', + goodsName: '팩', + priceToPay: 1, + originalPrice: 1, + discountRate: 0, + o2oStockFlag: false, + o2oRemainQuantity: 0, + inStock: false, + stockStatus: 'out_of_stock' as const, + }; + const result = await enrichOliveyoungProductsWithNearbyStoreInventory([product], { + latitude: 37, + longitude: 127, + storeKeyword: '', + maxProducts: 1, + }); + expect(result.products[0]).toMatchObject({ + stockStatus, + storeInventory: { inStockCount, outOfStockCount, notSoldCount }, + }); + }, +); +it('공개 스키마에 미확인 매장 상태와 nullable 수량을 제공한다', () => { + const spec = generateFullOpenApiSpec('https://example.com') as { + components: { + schemas: { + OliveyoungStockStore: { + properties: Record; + }; + }; + }; + }; + const properties = spec.components.schemas.OliveyoungStockStore.properties; + expect(properties.remainQuantity.nullable).toBe(true); + expect(properties.o2oRemainQuantity.nullable).toBe(true); + expect(properties.stockStatus.enum).toContain('unknown'); +}); diff --git a/tests/services/seveneleven/pagination.test.ts b/tests/services/seveneleven/pagination.test.ts new file mode 100644 index 00000000..6e1dd9ef --- /dev/null +++ b/tests/services/seveneleven/pagination.test.ts @@ -0,0 +1,216 @@ +/** 세븐일레븐 전체 결과의 중복 제거와 페이지 경계를 검증합니다. */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { searchSevenElevenProducts } from '../../../src/services/seveneleven/client.js'; +import { searchSevenElevenProductsWithVariants } from '../../../src/services/seveneleven/productKeyword.js'; +import { clearSevenElevenReadCache } from '../../../src/services/seveneleven/readCache.js'; +const mockFetch = vi.fn(); +const product = (id: number) => ({ field: { itemCd: String(id), itemOnm: `커피 ${id}` } }); +function response(ids: number[], duplicate = false, total = ids.length) { + return new Response( + JSON.stringify({ + data: { + SearchQueryResult: { + Collection: [ + { + CollectionId: 'pickup', + Documentset: { totalCount: total, Document: ids.map(product) }, + }, + ...(duplicate + ? [ + { + CollectionId: 'offline', + Documentset: { totalCount: ids.length, Document: ids.map(product) }, + }, + ] + : []), + ], + }, + }, + }), + ); +} +beforeEach(() => { + clearSevenElevenReadCache(); + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); +afterEach(() => vi.restoreAllMocks()); +it('공식 요청으로 전체 상품을 받아 중복 없는 세 페이지와 마지막 빈 페이지를 반환한다', async () => { + mockFetch.mockImplementation(async () => response([1, 2, 3, 4, 5, 6, 7], true)); + const results = []; + for (const page of [1, 2, 3, 4]) + results.push(await searchSevenElevenProducts({ query: '커피', page, size: 3 })); + expect(results.map((r) => r.products.map((p) => p.itemCode))).toEqual([ + ['1', '2', '3'], + ['4', '5', '6'], + ['7'], + [], + ]); + expect(results.map((r) => r.totalCount)).toEqual([7, 7, 7, 7]); + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toEqual({ + collection: 'goods', + query: '커피', + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 100, + }); +}); +it('보정 질의 전체를 합쳐 중복 제거 후 한 번만 페이지를 자른다', async () => { + mockFetch.mockImplementation(async (_url, init) => + JSON.parse(init.body).query === '커 피' ? response([1, 2, 3, 4]) : response([3, 4, 5, 6]), + ); + const pages = []; + for (const page of [1, 2, 3]) + pages.push(await searchSevenElevenProductsWithVariants('커 피', { page, size: 2 })); + expect(pages.map((r) => r.products.map((p) => p.itemCode))).toEqual([ + ['1', '2'], + ['3', '4'], + ['5', '6'], + ]); + expect(pages.map((r) => r.totalCount)).toEqual([6, 6, 6]); +}); +it('전체 요청이 잘리면 일부 결과를 전체로 보고하지 않는다', async () => { + mockFetch.mockImplementation(async () => response([1, 2], false, 5)); + await expect(searchSevenElevenProducts({ query: '커피' })).rejects.toThrow( + '전체 상품 검색 결과가 잘렸습니다', + ); +}); + +it('100개씩 제한된 원본 페이지를 수집한 뒤 전역 페이지를 계산한다', async () => { + mockFetch.mockImplementation(async (_url, init) => { + const page = JSON.parse(init.body).startCount; + return response( + page === 0 ? Array.from({ length: 100 }, (_, i) => i + 1) : [101, 102], + false, + 102, + ); + }); + const result = await searchSevenElevenProducts({ query: '커피', page: 34, size: 3 }); + expect(result.totalCount).toBe(102); + expect(result.products.map((p) => p.itemCode)).toEqual(['100', '101', '102']); + expect(mockFetch.mock.calls.map((c) => JSON.parse(c[1].body).startCount)).toEqual([0, 1]); +}); + +it('500개 한도를 넘는 검색은 정상 결과로 표시하지 않는다', async () => { + mockFetch.mockImplementation(async () => response([1], false, 501)); + await expect(searchSevenElevenProducts({ query: '커피' })).rejects.toThrow('최대 500개 제한'); + expect(mockFetch).toHaveBeenCalledTimes(1); +}); + +it('보정 질의들이 원본 호출 예산 10회를 공유한다', async () => { + mockFetch.mockImplementation(async (_url, init) => { + const page = JSON.parse(init.body).startCount; + return response( + Array.from({ length: 100 }, (_, i) => page * 100 + i + 1), + false, + 400, + ); + }); + await expect(searchSevenElevenProductsWithVariants('후르츠산도')).rejects.toThrow( + '최대 10회 검색 제한', + ); + expect(mockFetch).toHaveBeenCalledTimes(10); +}); + +function collectionsResponse(collections: Array<[string, number, number[]]>) { + return new Response( + JSON.stringify({ + data: { + SearchQueryResult: { + Collection: collections.map(([id, total, ids]) => ({ + CollectionId: id, + Documentset: { totalCount: total, Document: ids.map(product) }, + })), + }, + }, + }), + ); +} +it.each([ + [ + '미완료 컬렉션 누락', + [ + ['A', 3, [1]], + ['B', 2, [2, 3]], + ], + [['B', 2, [4]]], + ], + ['컬렉션 합계 변경', [['A', 3, [1]]], [['A', 1, [2]]]], + [ + '다른 컬렉션이 진행해도 반복 상품 거절', + [ + ['A', 2, [1]], + ['B', 2, [3]], + ], + [ + ['A', 2, [1]], + ['B', 2, [4]], + ], + ], +] as Array<[string, Array<[string, number, number[]]>, Array<[string, number, number[]]>]>)( + '%s를 불완전한 결과로 거절한다', + async (_name, first, second) => { + mockFetch + .mockResolvedValueOnce(collectionsResponse(first)) + .mockResolvedValueOnce(collectionsResponse(second)); + await expect(searchSevenElevenProducts({ query: '커피' })).rejects.toThrow( + '전체 상품 검색 결과가 잘렸습니다', + ); + }, +); +it('완료된 컬렉션은 다음 원본 페이지에서 생략되어도 보존한다', async () => { + mockFetch + .mockResolvedValueOnce( + collectionsResponse([ + ['A', 2, [1]], + ['B', 1, [3]], + ]), + ) + .mockResolvedValueOnce(collectionsResponse([['A', 2, [2]]])); + const result = await searchSevenElevenProducts({ query: '커피' }); + expect(result.products.map((p) => p.itemCode)).toEqual(['1', '3', '2']); + expect(result.collectionIds).toEqual(['A', 'B']); + expect(result.totalCount).toBe(3); +}); +it('명시적 컬렉션이나 content 목록이 없는 응답은 거절한다', async () => { + mockFetch.mockResolvedValueOnce(Response.json({ data: {} })); + await expect(searchSevenElevenProducts({ query: '커피' })).rejects.toThrow( + '전체 상품 검색 결과가 잘렸습니다', + ); +}); +it('명시적 레거시 content 목록은 완전한 결과로 페이지 분할한다', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + data: { + content: [ + { itemCd: '1', itemOnm: '커피' }, + { itemCd: '2', itemOnm: '커피' }, + ], + }, + }), + ); + const result = await searchSevenElevenProducts({ query: '커피', page: 2, size: 1 }); + expect(result.totalCount).toBe(2); + expect(result.products.map((p) => p.itemCode)).toEqual(['2']); +}); + +it('공식 페이지 도중 레거시 목록으로 바뀌면 불완전한 결과를 거절한다', async () => { + mockFetch + .mockResolvedValueOnce(collectionsResponse([['A', 3, [1]]])) + .mockResolvedValueOnce( + Response.json({ data: { content: [{ itemCd: '2', itemOnm: '커피' }] } }), + ); + await expect(searchSevenElevenProducts({ query: '커피' })).rejects.toThrow( + '전체 상품 검색 결과가 잘렸습니다', + ); +}); +it('초기 레거시 목록도 전체 500개 제한을 적용한다', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + data: { + content: Array.from({ length: 501 }, (_, i) => ({ itemCd: String(i), itemOnm: '커피' })), + }, + }), + ); + await expect(searchSevenElevenProducts({ query: '커피' })).rejects.toThrow('최대 500개 제한'); +}); diff --git a/tests/services/seveneleven/productKeyword.test.ts b/tests/services/seveneleven/productKeyword.test.ts index 3b4225cd..1dc34542 100644 --- a/tests/services/seveneleven/productKeyword.test.ts +++ b/tests/services/seveneleven/productKeyword.test.ts @@ -190,7 +190,7 @@ describe('searchSevenElevenProductsWithVariants', () => { ]); }); - it('중복 상품과 빈 키 상품은 제외한다', async () => { + it('컬렉션 내부 중복 상품은 불완전한 목록으로 거절한다', async () => { mockFetch.mockResolvedValueOnce( makeProductResponse('핫식스', [ { @@ -214,13 +214,7 @@ describe('searchSevenElevenProductsWithVariants', () => { ]), ); - const result = await searchSevenElevenProductsWithVariants('핫식스', { - size: 10, - }); - - expect(result.totalCount).toBe(3); - expect(result.products).toHaveLength(1); - expect(result.products[0].itemCode).toBe('111'); + await expect(searchSevenElevenProductsWithVariants('핫식스', { size: 10 })).rejects.toThrow('전체 상품 검색 결과가 잘렸습니다'); }); it('itemCode가 없으면 productNo와 itemName을 순서대로 fallback 키로 사용한다', async () => { diff --git a/tests/services/seveneleven/searchPagination.test.ts b/tests/services/seveneleven/searchPagination.test.ts new file mode 100644 index 00000000..6ba96c36 --- /dev/null +++ b/tests/services/seveneleven/searchPagination.test.ts @@ -0,0 +1,54 @@ +/** 원본 컬렉션의 완전성 경계를 검증합니다. */ +import { expect, it } from 'vitest'; +import { SevenElevenSearchPagination } from '../../../src/services/seveneleven/searchPagination.js'; +const collection = (id: string, total: number, ids: number[]) => ({ + CollectionId: id, + Documentset: { totalCount: total, Document: ids.map((itemCd) => ({ itemCd })) }, +}); +it.each([ + [collection('A', -1, [])], + [collection('A', 1.5, [])], + [collection('A', 0, [1])], + [collection('A', 1, []), collection('A', 1, [1])], +])('잘못된 최초 컬렉션은 거절한다', (...collections) => { + expect(() => new SevenElevenSearchPagination().append(collections)).toThrow(); +}); +it('진행 중에 새 컬렉션이 나타나면 거절한다', () => { + const state = new SevenElevenSearchPagination(); + state.append([collection('A', 2, [1])]); + expect(() => state.append([collection('A', 2, [2]), collection('B', 1, [3])])).toThrow(); +}); +it('미완료 컬렉션의 빈 다음 페이지는 거절한다', () => { + const state = new SevenElevenSearchPagination(); + state.append([collection('A', 2, [1])]); + expect(() => state.append([collection('A', 2, [])])).toThrow(); +}); +it('빈 컬렉션과 컬렉션 식별자 없는 결과도 처리한다', () => { + expect(new SevenElevenSearchPagination().append([])).toBe(true); + expect( + new SevenElevenSearchPagination().append([{ Documentset: { totalCount: 0, Document: [] } }]), + ).toBe(true); +}); +it('상품 코드를 우선하고 번호와 이름과 원본을 식별자로 보완한다', () => { + const documents = [ + { field: { itemCd: 'a' } }, + { prdNo: 'b' }, + { itemOnm: 'c' }, + { other: 'd' }, + null, + ]; + const state = new SevenElevenSearchPagination(); + expect(state.append([{ Documentset: { totalCount: 6, Document: documents } }])).toBe(false); + expect(() => state.append([{ Documentset: { totalCount: 6, Document: [null] } }])).toThrow(); +}); +it.each([ + {}, + { Documentset: {} }, + { Documentset: { totalCount: 0 } }, + { Documentset: { Document: [] } }, +])('완전성 메타데이터가 누락되면 거절한다', (collection) => { + expect(() => new SevenElevenSearchPagination().append([collection])).toThrow(); +}); +it('같은 원본 페이지 안의 상품 중복도 거절한다', () => { + expect(() => new SevenElevenSearchPagination().append([collection('A', 2, [1, 1])])).toThrow(); +}); diff --git a/tests/services/seveneleven/tools/searchProducts.test.ts b/tests/services/seveneleven/tools/searchProducts.test.ts index ab5c4936..54dd0d4f 100644 --- a/tests/services/seveneleven/tools/searchProducts.test.ts +++ b/tests/services/seveneleven/tools/searchProducts.test.ts @@ -88,9 +88,11 @@ describe('createSearchProductsTool', () => { expect.objectContaining({ method: 'POST', body: JSON.stringify({ + collection: 'goods', query: '삼각김밥', - pageNo: 0, - pageSize: 20, + sort: 'quantity/desc,itemOnm/asc', + startCount: 0, + listCount: 100, }), }), );