mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Preserve Daiso pickup answers when Bearer auth degrades
Keep exact stock lookup on the official Bearer-token path while restoring the public selPkupStr fallback for repeated auth blocks. Constraint: PR #250 review required Bearer auth to remain primary without removing the resilient pickup eligibility API. Rejected: Throwing after the retry | it collapses callers back to a brittle single upstream-auth dependency. Confidence: high Scope-risk: narrow Directive: Keep pickupStock quantity semantics separate from pickupEligibility yes/no fallback. Tested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100. Not-tested: Live forced 403 from Daiso upstream; covered with injected fetch regression tests.
This commit is contained in:
parent
2641f43863
commit
d7263a54b9
4 changed files with 337 additions and 17 deletions
|
|
@ -2,4 +2,4 @@
|
||||||
"daiso-product-search": minor
|
"daiso-product-search": minor
|
||||||
---
|
---
|
||||||
|
|
||||||
Restore actionable Daiso pickup answers when store pickup stock is blocked by adding a `selPkupStr`-backed `getStorePickupEligibility()` helper plus `pickupEligibility` field on `lookupStoreProductAvailability()`. When pickup stock returns `Unauthorized`, the package now reports whether the selected store is registered as a pickup-capable store for the product instead of only saying "unknown".
|
Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ npm install
|
||||||
|
|
||||||
- 매장명과 상품명 둘 다 필요합니다.
|
- 매장명과 상품명 둘 다 필요합니다.
|
||||||
- 공식 다이소몰 표면을 우선 사용합니다.
|
- 공식 다이소몰 표면을 우선 사용합니다.
|
||||||
- `selStrPkupStck` 는 Bearer 토큰 인증이 필요합니다. `/api/auth/request` 로 비로그인 JWT를 받아 AES-128-CBC / 키 `"PRE_AUTH_ENC_KEY"` 로 암호화한 뒤 Bearer 헤더로 전달합니다. 403 응답 시 토큰을 재발급해 1회 재시도합니다.
|
- `selStrPkupStck` 는 Bearer 토큰 인증이 필요합니다. `/api/auth/request` 로 비로그인 JWT를 받아 AES-128-CBC / 키 `"PRE_AUTH_ENC_KEY"` 로 암호화한 뒤 Bearer 헤더로 전달합니다. 401/403 응답 시 토큰을 재발급해 1회 재시도합니다. 그래도 인증이 막히면 수량 조회는 `retrievalStatus: "blocked"` 로 반환하고 `selPkupStr` 픽업 가능 여부 폴백을 사용할 수 있습니다.
|
||||||
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 실제 재고 여부는 `inStock` 또는 `inventoryStatus` (`"in_stock"`, `"out_of_stock"`, `"unknown"`) 를 기준으로 판단합니다.
|
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 실제 재고 여부는 `inStock` 또는 `inventoryStatus` (`"in_stock"`, `"out_of_stock"`, `"unknown"`) 를 기준으로 판단합니다.
|
||||||
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
|
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
|
||||||
|
|
||||||
|
|
@ -39,6 +39,7 @@ async function main() {
|
||||||
console.log(result.selectedStore)
|
console.log(result.selectedStore)
|
||||||
console.log(result.selectedProduct)
|
console.log(result.selectedProduct)
|
||||||
console.log(result.pickupStock)
|
console.log(result.pickupStock)
|
||||||
|
console.log(result.pickupEligibility)
|
||||||
console.log(result.onlineStock)
|
console.log(result.onlineStock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,10 +83,15 @@ main().catch((error) => {
|
||||||
- `searchProducts(query, options?)`
|
- `searchProducts(query, options?)`
|
||||||
- 반환되는 각 상품 후보는 `pdNo` 와 함께 `onldPdNo` 를 포함할 수 있습니다. 다이소몰 온라인 재고 표면이 별도 마스터 상품 번호를 요구하는 경우 이 값을 그대로 `getOnlineStock()` 에 넘기면 됩니다.
|
- 반환되는 각 상품 후보는 `pdNo` 와 함께 `onldPdNo` 를 포함할 수 있습니다. 다이소몰 온라인 재고 표면이 별도 마스터 상품 번호를 요구하는 경우 이 값을 그대로 `getOnlineStock()` 에 넘기면 됩니다.
|
||||||
- `getStorePickupStock({ pdNo, strCd }, options?)`
|
- `getStorePickupStock({ pdNo, strCd }, options?)`
|
||||||
- 호출 전 `/api/auth/request` 로 Bearer 토큰을 자동 빌드합니다. 403 응답 시 토큰을 재발급해 1회 재시도합니다.
|
- 호출 전 `/api/auth/request` 로 Bearer 토큰을 자동 빌드합니다. 401/403 응답 시 토큰을 재발급해 1회 재시도합니다.
|
||||||
- 성공한 조회는 `status: "available"`, `retrievalStatus: "resolved"` 를 포함합니다. 여기서 `status` 는 조회 성공 범주이며 상품 재고 여부가 아닙니다.
|
- 성공한 조회는 `status: "available"`, `retrievalStatus: "resolved"` 를 포함합니다. 여기서 `status` 는 조회 성공 범주이며 상품 재고 여부가 아닙니다.
|
||||||
- 실제 재고 여부는 `inStock` 또는 `inventoryStatus` 로 확인합니다. 수량이 0이면 `status: "available"` 이면서 `inventoryStatus: "out_of_stock"` 일 수 있습니다.
|
- 실제 재고 여부는 `inStock` 또는 `inventoryStatus` 로 확인합니다. 수량이 0이면 `status: "available"` 이면서 `inventoryStatus: "out_of_stock"` 일 수 있습니다.
|
||||||
|
- 인증이 계속 막히면 예외 대신 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"` 를 반환합니다.
|
||||||
|
- `getStorePickupEligibility({ pdNo, strCd, storeName?, keyword?, pageSize? }, options?)`
|
||||||
|
- `selPkupStr` 로 특정 상품의 픽업 가능 매장 목록을 조회해 선택 매장이 픽업 가능 매장인지 확인합니다.
|
||||||
|
- 수량은 제공하지 않으며 `pickupEligible` (`true`/`false`/`null`) 과 `retrievalStatus` (`"resolved"`, `"blocked"`, `"insufficient_coverage"`) 로 폴백 판단을 전달합니다.
|
||||||
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
|
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
|
||||||
- 반환값은 `referenceOnly: true` 를 포함합니다. 온라인 재고는 다이소몰 온라인몰 재고 참고값이며 특정 매장의 픽업/진열 재고가 아닙니다.
|
- 반환값은 `referenceOnly: true` 를 포함합니다. 온라인 재고는 다이소몰 온라인몰 재고 참고값이며 특정 매장의 픽업/진열 재고가 아닙니다.
|
||||||
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`
|
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`
|
||||||
- 매장·상품 검색 → Bearer 인증 → 픽업 재고 조회를 한 번에 처리합니다.
|
- 매장·상품 검색 → Bearer 인증 → 픽업 재고 조회를 한 번에 처리합니다.
|
||||||
|
- 픽업 재고 인증이 계속 막혀 `pickupStock.retrievalStatus === "blocked"` 이면 `pickupEligibility` 에 `selPkupStr` 기반 픽업 가능 여부를 채웁니다. 필요 없으면 `includePickupEligibility: false` 를 전달합니다.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const {
|
||||||
BASE_SEARCH_URL,
|
BASE_SEARCH_URL,
|
||||||
buildSearchGoodsParams,
|
buildSearchGoodsParams,
|
||||||
normalizeOnlineStockResponse,
|
normalizeOnlineStockResponse,
|
||||||
|
normalizePickupEligibilityResponse,
|
||||||
normalizeProductIdentifier,
|
normalizeProductIdentifier,
|
||||||
normalizeSearchGoodsResponse,
|
normalizeSearchGoodsResponse,
|
||||||
normalizeStorePickupStockResponse,
|
normalizeStorePickupStockResponse,
|
||||||
|
|
@ -104,6 +105,22 @@ async function buildBearerToken(options = {}) {
|
||||||
return { bearer, uid }
|
return { bearer, uid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthBlockedError(error) {
|
||||||
|
return error instanceof DaisoRequestError && (error.status === 401 || error.status === 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthBlockedStock(request, error) {
|
||||||
|
return normalizeStorePickupStockResponse(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "Unauthorized",
|
||||||
|
status: error && error.status,
|
||||||
|
upstreamPayload: error && error.payload ? error.payload : null
|
||||||
|
},
|
||||||
|
request
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function searchStores(query, options = {}) {
|
async function searchStores(query, options = {}) {
|
||||||
const body = {
|
const body = {
|
||||||
keyword: String(query || "").trim(),
|
keyword: String(query || "").trim(),
|
||||||
|
|
@ -144,28 +161,33 @@ async function searchProducts(query, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStorePickupStock(request, options = {}) {
|
async function getStorePickupStock(request, options = {}) {
|
||||||
const { bearer, uid } = await buildBearerToken(options)
|
const body = [{ pdNo: String(request.pdNo), strCd: String(request.strCd) }]
|
||||||
const authHeaders = { Authorization: `Bearer ${bearer}`, "X-DM-UID": uid }
|
|
||||||
|
|
||||||
try {
|
async function requestStockWithFreshToken() {
|
||||||
|
const { bearer, uid } = await buildBearerToken(options)
|
||||||
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
|
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
|
||||||
...options,
|
...options,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: authHeaders,
|
headers: { Authorization: `Bearer ${bearer}`, "X-DM-UID": uid },
|
||||||
body: [{ pdNo: String(request.pdNo), strCd: String(request.strCd) }]
|
body
|
||||||
})
|
})
|
||||||
|
|
||||||
return normalizeStorePickupStockResponse(payload, request)
|
return normalizeStorePickupStockResponse(payload, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await requestStockWithFreshToken()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DaisoRequestError && error.status === 403) {
|
if (!isAuthBlockedError(error)) {
|
||||||
const { bearer: newBearer, uid: newUid } = await buildBearerToken(options)
|
throw error
|
||||||
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
|
}
|
||||||
...options,
|
}
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${newBearer}`, "X-DM-UID": newUid },
|
try {
|
||||||
body: [{ pdNo: String(request.pdNo), strCd: String(request.strCd) }]
|
return await requestStockWithFreshToken()
|
||||||
})
|
} catch (error) {
|
||||||
return normalizeStorePickupStockResponse(payload, request)
|
if (isAuthBlockedError(error)) {
|
||||||
|
return normalizeAuthBlockedStock(request, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
|
|
@ -186,6 +208,73 @@ async function getOnlineStock(request, options = {}) {
|
||||||
return normalizeOnlineStockResponse(payload, normalizedRequest)
|
return normalizeOnlineStockResponse(payload, normalizedRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPickupEligibilityKeyword(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/\d+\s*호점\s*$/u, "")
|
||||||
|
.replace(/[(].*?[)]/gu, " ")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStorePickupEligibility(request, options = {}) {
|
||||||
|
const pdNo = String(request.pdNo || "").trim()
|
||||||
|
const strCd = String(request.strCd || "").trim()
|
||||||
|
const explicitKeyword =
|
||||||
|
typeof request.keyword === "string" && request.keyword.trim() ? request.keyword.trim() : null
|
||||||
|
const derivedKeyword = explicitKeyword || buildPickupEligibilityKeyword(request.storeName)
|
||||||
|
const pageSize = Number(request.pageSize || 50)
|
||||||
|
|
||||||
|
if (!pdNo) {
|
||||||
|
throw new Error("pdNo is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strCd && !derivedKeyword) {
|
||||||
|
return {
|
||||||
|
pdNo,
|
||||||
|
strCd,
|
||||||
|
pickupEligible: null,
|
||||||
|
eligibleStoreCount: null,
|
||||||
|
eligibleStores: [],
|
||||||
|
matchedStore: null,
|
||||||
|
searchedKeyword: "",
|
||||||
|
pageSize,
|
||||||
|
totalCount: null,
|
||||||
|
retrievalStatus: "insufficient_coverage",
|
||||||
|
reason: "missing_search_keyword",
|
||||||
|
raw: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await requestJson(`${BASE_API_URL}/ms/msg/selPkupStr`, {
|
||||||
|
...options,
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
pdNo,
|
||||||
|
keyword: derivedKeyword || "",
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return normalizePickupEligibilityResponse(payload, {
|
||||||
|
pdNo,
|
||||||
|
strCd,
|
||||||
|
keyword: derivedKeyword || "",
|
||||||
|
pageSize
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DaisoRequestError) {
|
||||||
|
return normalizePickupEligibilityResponse(
|
||||||
|
error.payload || { success: false, message: `HTTP ${error.status}` },
|
||||||
|
{ pdNo, strCd, keyword: derivedKeyword || "", pageSize }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function lookupStoreProductAvailability(options = {}) {
|
async function lookupStoreProductAvailability(options = {}) {
|
||||||
const storeQuery = String(options.storeQuery || "").trim()
|
const storeQuery = String(options.storeQuery || "").trim()
|
||||||
const productQuery = String(options.productQuery || "").trim()
|
const productQuery = String(options.productQuery || "").trim()
|
||||||
|
|
@ -228,6 +317,23 @@ async function lookupStoreProductAvailability(options = {}) {
|
||||||
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options)
|
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
let pickupEligibility = null
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.includePickupEligibility !== false &&
|
||||||
|
pickupStock &&
|
||||||
|
pickupStock.retrievalStatus === "blocked"
|
||||||
|
) {
|
||||||
|
pickupEligibility = await getStorePickupEligibility(
|
||||||
|
{
|
||||||
|
pdNo: selectedProduct.pdNo,
|
||||||
|
strCd: selectedStore.strCd,
|
||||||
|
storeName: selectedStore.name
|
||||||
|
},
|
||||||
|
options
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const onlineStock = await onlineStockPromise
|
const onlineStock = await onlineStockPromise
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -239,6 +345,7 @@ async function lookupStoreProductAvailability(options = {}) {
|
||||||
storeDetail: storeDetailPayload.data || null,
|
storeDetail: storeDetailPayload.data || null,
|
||||||
selectedProduct,
|
selectedProduct,
|
||||||
pickupStock,
|
pickupStock,
|
||||||
|
pickupEligibility,
|
||||||
onlineStock
|
onlineStock
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +353,7 @@ async function lookupStoreProductAvailability(options = {}) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getOnlineStock,
|
getOnlineStock,
|
||||||
getStoreDetail,
|
getStoreDetail,
|
||||||
|
getStorePickupEligibility,
|
||||||
getStorePickupStock,
|
getStorePickupStock,
|
||||||
lookupStoreProductAvailability,
|
lookupStoreProductAvailability,
|
||||||
searchProducts,
|
searchProducts,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const path = require("node:path")
|
||||||
const {
|
const {
|
||||||
getOnlineStock,
|
getOnlineStock,
|
||||||
getStoreDetail,
|
getStoreDetail,
|
||||||
|
getStorePickupEligibility,
|
||||||
getStorePickupStock,
|
getStorePickupStock,
|
||||||
lookupStoreProductAvailability,
|
lookupStoreProductAvailability,
|
||||||
searchProducts,
|
searchProducts,
|
||||||
|
|
@ -13,6 +14,7 @@ const {
|
||||||
} = require("../src/index")
|
} = require("../src/index")
|
||||||
const {
|
const {
|
||||||
buildSearchGoodsParams,
|
buildSearchGoodsParams,
|
||||||
|
normalizePickupEligibilityResponse,
|
||||||
normalizeSearchGoodsResponse,
|
normalizeSearchGoodsResponse,
|
||||||
normalizeStorePickupStockResponse,
|
normalizeStorePickupStockResponse,
|
||||||
normalizeStoreSearchResponse
|
normalizeStoreSearchResponse
|
||||||
|
|
@ -24,6 +26,27 @@ const searchGoodsPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "se
|
||||||
const storeDetailPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-detail.json"), "utf8"))
|
const storeDetailPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-detail.json"), "utf8"))
|
||||||
const storePickupStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-pickup-stock.json"), "utf8"))
|
const storePickupStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-pickup-stock.json"), "utf8"))
|
||||||
const onlineStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "online-stock.json"), "utf8"))
|
const onlineStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "online-stock.json"), "utf8"))
|
||||||
|
|
||||||
|
const storePickupEligibilityPayload = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
strCd: "10224",
|
||||||
|
strNm: "강남역2호점",
|
||||||
|
strAddr: "서울특별시 강남구 강남대로",
|
||||||
|
strDtlAddr: "지하 1층",
|
||||||
|
strTno: "02-1234-5678",
|
||||||
|
pkupYn: "Y",
|
||||||
|
opngTime: "1000",
|
||||||
|
clsngTime: "2200",
|
||||||
|
km: "0.2",
|
||||||
|
strLttd: "37.498095",
|
||||||
|
strLitd: "127.02761",
|
||||||
|
totalCnt: 1,
|
||||||
|
currentPageCnt: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
success: true
|
||||||
|
}
|
||||||
const liveSearchGoodsPayload = {
|
const liveSearchGoodsPayload = {
|
||||||
resultSet: {
|
resultSet: {
|
||||||
result: [
|
result: [
|
||||||
|
|
@ -562,3 +585,186 @@ test("lookupStoreProductAvailability reuses a product candidate's online stock i
|
||||||
global.fetch = originalFetch
|
global.fetch = originalFetch
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("getStorePickupStock sends Bearer auth headers and returns blocked after repeated auth failures", async () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
const stockRequests = []
|
||||||
|
let authCallCount = 0
|
||||||
|
|
||||||
|
global.fetch = async (url, init = {}) => {
|
||||||
|
if (String(url).includes("/api/auth/request")) {
|
||||||
|
authCallCount++
|
||||||
|
return makeAuthResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
|
||||||
|
stockRequests.push({ headers: init.headers, body: JSON.parse(init.body) })
|
||||||
|
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pickupStock = await getStorePickupStock({ pdNo: "1049275", strCd: "10224" })
|
||||||
|
|
||||||
|
assert.equal(authCallCount, 2)
|
||||||
|
assert.equal(stockRequests.length, 2)
|
||||||
|
for (const request of stockRequests) {
|
||||||
|
assert.match(request.headers.Authorization, /^Bearer /)
|
||||||
|
assert.equal(request.headers["X-DM-UID"], "test-uid-123")
|
||||||
|
assert.deepEqual(request.body, [{ pdNo: "1049275", strCd: "10224" }])
|
||||||
|
}
|
||||||
|
assert.equal(pickupStock.status, "unavailable")
|
||||||
|
assert.equal(pickupStock.retrievalStatus, "blocked")
|
||||||
|
assert.equal(pickupStock.reason, "unauthorized")
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getStorePickupEligibility posts pdNo and a derived store keyword to selPkupStr", async () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
let capturedBody = null
|
||||||
|
let capturedUrl = null
|
||||||
|
|
||||||
|
global.fetch = async (url, init = {}) => {
|
||||||
|
capturedUrl = String(url)
|
||||||
|
capturedBody = JSON.parse(init.body)
|
||||||
|
return makeResponse(storePickupEligibilityPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eligibility = await getStorePickupEligibility({
|
||||||
|
pdNo: "1049275",
|
||||||
|
strCd: "10224",
|
||||||
|
storeName: "강남역2호점"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.match(capturedUrl, /\/api\/ms\/msg\/selPkupStr$/)
|
||||||
|
assert.equal(capturedBody.pdNo, "1049275")
|
||||||
|
assert.equal(capturedBody.keyword, "강남역")
|
||||||
|
assert.equal(capturedBody.currentPage, 1)
|
||||||
|
assert.equal(typeof capturedBody.pageSize, "number")
|
||||||
|
assert.equal(eligibility.pickupEligible, true)
|
||||||
|
assert.equal(eligibility.matchedStore.strCd, "10224")
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lookupStoreProductAvailability falls back to pickup eligibility when Bearer stock remains forbidden", async () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
let eligibilityCalled = false
|
||||||
|
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
if (String(url).includes("/api/auth/request")) {
|
||||||
|
return makeAuthResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
|
||||||
|
return makeResponse(storeSearchPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/ssn/search/SearchGoods")) {
|
||||||
|
return makeResponse(searchGoodsPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
|
||||||
|
return makeResponse(storeDetailPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
|
||||||
|
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/ms/msg/selPkupStr")) {
|
||||||
|
eligibilityCalled = true
|
||||||
|
return makeResponse(storePickupEligibilityPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/pdo/selOnlStck")) {
|
||||||
|
return makeResponse(onlineStockPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const availability = await lookupStoreProductAvailability({
|
||||||
|
storeQuery: "강남역2호점",
|
||||||
|
productQuery: "VT 리들샷 100"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
|
||||||
|
assert.equal(eligibilityCalled, true)
|
||||||
|
assert.equal(availability.pickupEligibility.pickupEligible, true)
|
||||||
|
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
|
||||||
|
assert.equal(availability.onlineStock.quantity, 13047)
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test("lookupStoreProductAvailability falls back to pickup eligibility when token issuance is forbidden", async () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
let eligibilityCalled = false
|
||||||
|
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
if (String(url).includes("/api/auth/request")) {
|
||||||
|
return new Response("forbidden", { status: 403, headers: { "content-type": "text/plain" } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
|
||||||
|
return makeResponse(storeSearchPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/ssn/search/SearchGoods")) {
|
||||||
|
return makeResponse(searchGoodsPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
|
||||||
|
return makeResponse(storeDetailPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/ms/msg/selPkupStr")) {
|
||||||
|
eligibilityCalled = true
|
||||||
|
return makeResponse(storePickupEligibilityPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(url).includes("/api/pdo/selOnlStck")) {
|
||||||
|
return makeResponse(onlineStockPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const availability = await lookupStoreProductAvailability({
|
||||||
|
storeQuery: "강남역2호점",
|
||||||
|
productQuery: "VT 리들샷 100"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
|
||||||
|
assert.equal(availability.pickupStock.inventoryStatus, "unknown")
|
||||||
|
assert.equal(eligibilityCalled, true)
|
||||||
|
assert.equal(availability.pickupEligibility.pickupEligible, true)
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizePickupEligibilityResponse keeps blocked fallback shape stable", () => {
|
||||||
|
const eligibility = normalizePickupEligibilityResponse(
|
||||||
|
{ success: false, message: "Upstream error" },
|
||||||
|
{ pdNo: "1049275", strCd: "10224" }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(eligibility.pickupEligible, null)
|
||||||
|
assert.equal(eligibility.eligibleStoreCount, null)
|
||||||
|
assert.deepEqual(eligibility.eligibleStores, [])
|
||||||
|
assert.equal(eligibility.matchedStore, null)
|
||||||
|
assert.equal(eligibility.retrievalStatus, "blocked")
|
||||||
|
assert.equal(eligibility.reason, "upstream_error")
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue